// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:io'; import 'dart:math'; import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; import 'package:vector_math/vector_math_64.dart'; import 'package:xml/xml.dart'; // String to use for a single indentation. const String kIndent = ' '; /// Represents an animation, and provides logic to generate dart code for it. class Animation { const Animation(this.size, this.paths); factory Animation.fromFrameData(List<FrameData> frames) { _validateFramesData(frames); final Point<double> size = frames[0].size; final List<PathAnimation> paths = <PathAnimation>[]; for (int i = 0; i < frames[0].paths.length; i += 1) { paths.add(PathAnimation.fromFrameData(frames, i)); } return Animation(size, paths); } /// The size of the animation (width, height) in pixels. final Point<double> size; /// List of paths in the animation. final List<PathAnimation> paths; static void _validateFramesData(List<FrameData> frames) { final Point<double> size = frames[0].size; final int numPaths = frames[0].paths.length; for (int i = 0; i < frames.length; i += 1) { final FrameData frame = frames[i]; if (size != frame.size) { throw Exception( 'All animation frames must have the same size,\n' 'first frame size was: (${size.x}, ${size.y})\n' 'frame $i size was: (${frame.size.x}, ${frame.size.y})' ); } if (numPaths != frame.paths.length) { throw Exception( 'All animation frames must have the same number of paths,\n' 'first frame has $numPaths paths\n' 'frame $i has ${frame.paths.length} paths' ); } } } String toDart(String className, String varName) { final StringBuffer sb = StringBuffer(); sb.write('const $className $varName = const $className(\n'); sb.write('${kIndent}const Size(${size.x}, ${size.y}),\n'); sb.write('${kIndent}const <_PathFrames>[\n'); for (final PathAnimation path in paths) { sb.write(path.toDart()); } sb.write('$kIndent],\n'); sb.write(');'); return sb.toString(); } } /// Represents the animation of a single path. class PathAnimation { const PathAnimation(this.commands, {required this.opacities}); factory PathAnimation.fromFrameData(List<FrameData> frames, int pathIdx) { if (frames.isEmpty) { return const PathAnimation(<PathCommandAnimation>[], opacities: <double>[]); } final List<PathCommandAnimation> commands = <PathCommandAnimation>[]; for (int commandIdx = 0; commandIdx < frames[0].paths[pathIdx].commands.length; commandIdx += 1) { final int numPointsInCommand = frames[0].paths[pathIdx].commands[commandIdx].points.length; final List<List<Point<double>>> points = List<List<Point<double>>>.filled(numPointsInCommand, <Point<double>>[]); final String commandType = frames[0].paths[pathIdx].commands[commandIdx].type; for (int i = 0; i < frames.length; i += 1) { final FrameData frame = frames[i]; final String currentCommandType = frame.paths[pathIdx].commands[commandIdx].type; if (commandType != currentCommandType) { throw Exception( 'Paths must be built from the same commands in all frames ' "command $commandIdx at frame 0 was of type '$commandType' " "command $commandIdx at frame $i was of type '$currentCommandType'" ); } for (int j = 0; j < numPointsInCommand; j += 1) { points[j].add(frame.paths[pathIdx].commands[commandIdx].points[j]); } } commands.add(PathCommandAnimation(commandType, points)); } final List<double> opacities = frames.map<double>((FrameData d) => d.paths[pathIdx].opacity).toList(); return PathAnimation(commands, opacities: opacities); } /// List of commands for drawing the path. final List<PathCommandAnimation> commands; /// The path opacity for each animation frame. final List<double> opacities; @override String toString() { return 'PathAnimation(commands: $commands, opacities: $opacities)'; } String toDart() { final StringBuffer sb = StringBuffer(); sb.write('${kIndent * 2}const _PathFrames(\n'); sb.write('${kIndent * 3}opacities: const <double>[\n'); for (final double opacity in opacities) { sb.write('${kIndent * 4}$opacity,\n'); } sb.write('${kIndent * 3}],\n'); sb.write('${kIndent * 3}commands: const <_PathCommand>[\n'); for (final PathCommandAnimation command in commands) { sb.write(command.toDart()); } sb.write('${kIndent * 3}],\n'); sb.write('${kIndent * 2}),\n'); return sb.toString(); } } /// Represents the animation of a single path command. class PathCommandAnimation { const PathCommandAnimation(this.type, this.points); /// The command type. final String type; /// A matrix with the command's points in different frames. /// /// points[i][j] is the i-th point of the command at frame j. final List<List<Point<double>>> points; @override String toString() { return 'PathCommandAnimation(type: $type, points: $points)'; } String toDart() { String dartCommandClass; switch (type) { case 'M': dartCommandClass = '_PathMoveTo'; break; case 'C': dartCommandClass = '_PathCubicTo'; break; case 'L': dartCommandClass = '_PathLineTo'; break; case 'Z': dartCommandClass = '_PathClose'; break; default: throw Exception('unsupported path command: $type'); } final StringBuffer sb = StringBuffer(); sb.write('${kIndent * 4}const $dartCommandClass(\n'); for (final List<Point<double>> pointFrames in points) { sb.write('${kIndent * 5}const <Offset>[\n'); for (final Point<double> point in pointFrames) { sb.write('${kIndent * 6}const Offset(${point.x}, ${point.y}),\n'); } sb.write('${kIndent * 5}],\n'); } sb.write('${kIndent * 4}),\n'); return sb.toString(); } } /// Interprets some subset of an SVG file. /// /// Recursively goes over the SVG tree, applying transforms and opacities, /// and build a FrameData which is a flat representation of the paths in the SVG /// file, after applying transformations and converting relative coordinates to /// absolute. /// /// This does not support the SVG specification, but is just built to /// support SVG files exported by a specific tool the motion design team is /// using. FrameData interpretSvg(String svgFilePath) { final File file = File(svgFilePath); final String fileData = file.readAsStringSync(); final XmlElement svgElement = _extractSvgElement(XmlDocument.parse(fileData)); final double width = parsePixels(_extractAttr(svgElement, 'width')).toDouble(); final double height = parsePixels(_extractAttr(svgElement, 'height')).toDouble(); final List<SvgPath> paths = _interpretSvgGroup(svgElement.children, _Transform()); return FrameData(Point<double>(width, height), paths); } List<SvgPath> _interpretSvgGroup(List<XmlNode> children, _Transform transform) { final List<SvgPath> paths = <SvgPath>[]; for (final XmlNode node in children) { if (node.nodeType != XmlNodeType.ELEMENT) { continue; } final XmlElement element = node as XmlElement; if (element.name.local == 'path') { paths.add(SvgPath.fromElement(element)._applyTransform(transform)); } if (element.name.local == 'g') { double opacity = transform.opacity; if (_hasAttr(element, 'opacity')) { opacity *= double.parse(_extractAttr(element, 'opacity')); } Matrix3 transformMatrix = transform.transformMatrix; if (_hasAttr(element, 'transform')) { transformMatrix = transformMatrix.multiplied( _parseSvgTransform(_extractAttr(element, 'transform'))); } final _Transform subtreeTransform = _Transform( transformMatrix: transformMatrix, opacity: opacity, ); paths.addAll(_interpretSvgGroup(element.children, subtreeTransform)); } } return paths; } // Given a points list in the form e.g: "25.0, 1.0 12.0, 12.0 23.0, 9.0" matches // the coordinated of the first point and the rest of the string, for the // example above: // group 1 will match "25.0" // group 2 will match "1.0" // group 3 will match "12.0, 12.0 23.0, 9.0" // // Commas are optional. final RegExp _pointMatcher = RegExp(r'^ *([\-\.0-9]+) *,? *([\-\.0-9]+)(.*)'); /// Parse a string with a list of points, e.g: /// '25.0, 1.0 12.0, 12.0 23.0, 9.0' will be parsed to: /// [Point(25.0, 1.0), Point(12.0, 12.0), Point(23.0, 9.0)]. /// /// Commas are optional. List<Point<double>> parsePoints(String points) { String unParsed = points; final List<Point<double>> result = <Point<double>>[]; while (unParsed.isNotEmpty && _pointMatcher.hasMatch(unParsed)) { final Match m = _pointMatcher.firstMatch(unParsed)!; result.add(Point<double>( double.parse(m.group(1)!), double.parse(m.group(2)!), )); unParsed = m.group(3)!; } return result; } /// Data for a single animation frame. @immutable class FrameData { const FrameData(this.size, this.paths); final Point<double> size; final List<SvgPath> paths; @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) { return false; } return other is FrameData && other.size == size && const ListEquality<SvgPath>().equals(other.paths, paths); } @override int get hashCode => Object.hash(size, Object.hashAll(paths)); @override String toString() { return 'FrameData(size: $size, paths: $paths)'; } } /// Represents an SVG path element. @immutable class SvgPath { const SvgPath(this.id, this.commands, {this.opacity = 1.0}); final String id; final List<SvgPathCommand> commands; final double opacity; static const String _pathCommandAtom = r' *([a-zA-Z]) *([\-\.0-9 ,]*)'; static final RegExp _pathCommandValidator = RegExp('^($_pathCommandAtom)*\$'); static final RegExp _pathCommandMatcher = RegExp(_pathCommandAtom); static SvgPath fromElement(XmlElement pathElement) { assert(pathElement.name.local == 'path'); final String id = _extractAttr(pathElement, 'id'); final String dAttr = _extractAttr(pathElement, 'd'); final List<SvgPathCommand> commands = <SvgPathCommand>[]; final SvgPathCommandBuilder commandsBuilder = SvgPathCommandBuilder(); if (!_pathCommandValidator.hasMatch(dAttr)) { throw Exception('illegal or unsupported path d expression: $dAttr'); } for (final Match match in _pathCommandMatcher.allMatches(dAttr)) { final String commandType = match.group(1)!; final String pointStr = match.group(2)!; commands.add(commandsBuilder.build(commandType, parsePoints(pointStr))); } return SvgPath(id, commands); } SvgPath _applyTransform(_Transform transform) { final List<SvgPathCommand> transformedCommands = commands.map<SvgPathCommand>((SvgPathCommand c) => c._applyTransform(transform)).toList(); return SvgPath(id, transformedCommands, opacity: opacity * transform.opacity); } @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) { return false; } return other is SvgPath && other.id == id && other.opacity == opacity && const ListEquality<SvgPathCommand>().equals(other.commands, commands); } @override int get hashCode => Object.hash(id, Object.hashAll(commands), opacity); @override String toString() { return 'SvgPath(id: $id, opacity: $opacity, commands: $commands)'; } } /// Represents a single SVG path command from an SVG d element. /// /// This class normalizes all the 'd' commands into a single type, that has /// a command type and a list of points. /// /// Some examples of how d commands translated to SvgPathCommand: /// * "M 0.0, 1.0" => SvgPathCommand('M', [Point(0.0, 1.0)]) /// * "Z" => SvgPathCommand('Z', []) /// * "C 1.0, 1.0 2.0, 2.0 3.0, 3.0" SvgPathCommand('C', [Point(1.0, 1.0), /// Point(2.0, 2.0), Point(3.0, 3.0)]) @immutable class SvgPathCommand { const SvgPathCommand(this.type, this.points); /// The command type. final String type; /// List of points used by this command. final List<Point<double>> points; SvgPathCommand _applyTransform(_Transform transform) { final List<Point<double>> transformedPoints = _vector3ArrayToPoints( transform.transformMatrix.applyToVector3Array( _pointsToVector3Array(points) ) ); return SvgPathCommand(type, transformedPoints); } @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) { return false; } return other is SvgPathCommand && other.type == type && const ListEquality<Point<double>>().equals(other.points, points); } @override int get hashCode => Object.hash(type, Object.hashAll(points)); @override String toString() { return 'SvgPathCommand(type: $type, points: $points)'; } } class SvgPathCommandBuilder { static const Map<String, void> kRelativeCommands = <String, void> { 'c': null, 'l': null, 'm': null, 't': null, 's': null, }; Point<double> lastPoint = const Point<double>(0.0, 0.0); Point<double> subPathStartPoint = const Point<double>(0.0, 0.0); SvgPathCommand build(String type, List<Point<double>> points) { List<Point<double>> absPoints = points; if (_isRelativeCommand(type)) { absPoints = points.map<Point<double>>((Point<double> p) => p + lastPoint).toList(); } if (type == 'M' || type == 'm') { subPathStartPoint = absPoints.last; } if (type == 'Z' || type == 'z') { lastPoint = subPathStartPoint; } else { lastPoint = absPoints.last; } return SvgPathCommand(type.toUpperCase(), absPoints); } static bool _isRelativeCommand(String type) { return kRelativeCommands.containsKey(type); } } List<double> _pointsToVector3Array(List<Point<double>> points) { final List<double> result = List<double>.filled(points.length * 3, 0.0); for (int i = 0; i < points.length; i += 1) { result[i * 3] = points[i].x; result[i * 3 + 1] = points[i].y; result[i * 3 + 2] = 1.0; } return result; } List<Point<double>> _vector3ArrayToPoints(List<double> vector) { final int numPoints = (vector.length / 3).floor(); final List<Point<double>> points = <Point<double>>[ for (int i = 0; i < numPoints; i += 1) Point<double>(vector[i*3], vector[i*3 + 1]), ]; return points; } /// Represents a transformation to apply on an SVG subtree. /// /// This includes more transforms than the ones described by the SVG transform /// attribute, e.g opacity. class _Transform { /// Constructs a new _Transform, default arguments create a no-op transform. _Transform({Matrix3? transformMatrix, this.opacity = 1.0}) : transformMatrix = transformMatrix ?? Matrix3.identity(); final Matrix3 transformMatrix; final double opacity; _Transform applyTransform(_Transform transform) { return _Transform( transformMatrix: transform.transformMatrix.multiplied(transformMatrix), opacity: transform.opacity * opacity, ); } } const String _transformCommandAtom = r' *([^(]+)\(([^)]*)\)'; final RegExp _transformValidator = RegExp('^($_transformCommandAtom)*\$'); final RegExp _transformCommand = RegExp(_transformCommandAtom); Matrix3 _parseSvgTransform(String transform) { if (!_transformValidator.hasMatch(transform)) { throw Exception('illegal or unsupported transform: $transform'); } final Iterable<Match> matches =_transformCommand.allMatches(transform).toList().reversed; Matrix3 result = Matrix3.identity(); for (final Match m in matches) { final String command = m.group(1)!; final String params = m.group(2)!; if (command == 'translate') { result = _parseSvgTranslate(params).multiplied(result); continue; } if (command == 'scale') { result = _parseSvgScale(params).multiplied(result); continue; } if (command == 'rotate') { result = _parseSvgRotate(params).multiplied(result); continue; } throw Exception('unimplemented transform: $command'); } return result; } final RegExp _valueSeparator = RegExp('( *, *| +)'); Matrix3 _parseSvgTranslate(String paramsStr) { final List<String> params = paramsStr.split(_valueSeparator); assert(params.isNotEmpty); assert(params.length <= 2); final double x = double.parse(params[0]); final double y = params.length < 2 ? 0 : double.parse(params[1]); return _matrix(1.0, 0.0, 0.0, 1.0, x, y); } Matrix3 _parseSvgScale(String paramsStr) { final List<String> params = paramsStr.split(_valueSeparator); assert(params.isNotEmpty); assert(params.length <= 2); final double x = double.parse(params[0]); final double y = params.length < 2 ? 0 : double.parse(params[1]); return _matrix(x, 0.0, 0.0, y, 0.0, 0.0); } Matrix3 _parseSvgRotate(String paramsStr) { final List<String> params = paramsStr.split(_valueSeparator); assert(params.length == 1); final double a = radians(double.parse(params[0])); return _matrix(cos(a), sin(a), -sin(a), cos(a), 0.0, 0.0); } Matrix3 _matrix(double a, double b, double c, double d, double e, double f) { return Matrix3(a, b, 0.0, c, d, 0.0, e, f, 1.0); } // Matches a pixels expression e.g "14px". // First group is just the number. final RegExp _pixelsExp = RegExp(r'^([0-9]+)px$'); /// Parses a pixel expression, e.g "14px", and returns the number. /// Throws an [ArgumentError] if the given string doesn't match the pattern. int parsePixels(String pixels) { if (!_pixelsExp.hasMatch(pixels)) { throw ArgumentError( "illegal pixels expression: '$pixels'" ' (the tool currently only support pixel units).'); } return int.parse(_pixelsExp.firstMatch(pixels)!.group(1)!); } String _extractAttr(XmlElement element, String name) { try { return element.attributes.singleWhere((XmlAttribute x) => x.name.local == name) .value; } catch (e) { throw ArgumentError( "Can't find a single '$name' attributes in ${element.name}, " 'attributes were: ${element.attributes}' ); } } bool _hasAttr(XmlElement element, String name) { return element.attributes.where((XmlAttribute a) => a.name.local == name).isNotEmpty; } XmlElement _extractSvgElement(XmlDocument document) { return document.children.singleWhere( (XmlNode node) => node.nodeType == XmlNodeType.ELEMENT && _asElement(node).name.local == 'svg' ) as XmlElement; } XmlElement _asElement(XmlNode node) => node as XmlElement;