1// Copyright 2017 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5import 'dart:io'; 6import 'dart:math'; 7 8import 'package:collection/collection.dart'; 9import 'package:meta/meta.dart'; 10import 'package:vector_math/vector_math_64.dart'; 11import 'package:xml/xml.dart' as xml show parse; 12import 'package:xml/xml.dart' hide parse; 13 14// String to use for a single indentation. 15const String kIndent = ' '; 16 17/// Represents an animation, and provides logic to generate dart code for it. 18class Animation { 19 const Animation(this.size, this.paths); 20 21 factory Animation.fromFrameData(List<FrameData> frames) { 22 _validateFramesData(frames); 23 final Point<double> size = frames[0].size; 24 final List<PathAnimation> paths = <PathAnimation>[]; 25 for (int i = 0; i < frames[0].paths.length; i += 1) { 26 paths.add(PathAnimation.fromFrameData(frames, i)); 27 } 28 return Animation(size, paths); 29 } 30 31 /// The size of the animation (width, height) in pixels. 32 final Point<double> size; 33 34 /// List of paths in the animation. 35 final List<PathAnimation> paths; 36 37 static void _validateFramesData(List<FrameData> frames) { 38 final Point<double> size = frames[0].size; 39 final int numPaths = frames[0].paths.length; 40 for (int i = 0; i < frames.length; i += 1) { 41 final FrameData frame = frames[i]; 42 if (size != frame.size) 43 throw Exception( 44 'All animation frames must have the same size,\n' 45 'first frame size was: (${size.x}, ${size.y})\n' 46 'frame $i size was: (${frame.size.x}, ${frame.size.y})' 47 ); 48 if (numPaths != frame.paths.length) 49 throw Exception( 50 'All animation frames must have the same number of paths,\n' 51 'first frame has $numPaths paths\n' 52 'frame $i has ${frame.paths.length} paths' 53 ); 54 } 55 } 56 57 String toDart(String className, String varName) { 58 final StringBuffer sb = StringBuffer(); 59 sb.write('const $className $varName = const $className(\n'); 60 sb.write('${kIndent}const Size(${size.x}, ${size.y}),\n'); 61 sb.write('${kIndent}const <_PathFrames>[\n'); 62 for (PathAnimation path in paths) 63 sb.write(path.toDart()); 64 sb.write('$kIndent],\n'); 65 sb.write(');'); 66 return sb.toString(); 67 } 68} 69 70/// Represents the animation of a single path. 71class PathAnimation { 72 const PathAnimation(this.commands, {@required this.opacities}); 73 74 factory PathAnimation.fromFrameData(List<FrameData> frames, int pathIdx) { 75 if (frames.isEmpty) 76 return const PathAnimation(<PathCommandAnimation>[], opacities: <double>[]); 77 78 final List<PathCommandAnimation> commands = <PathCommandAnimation>[]; 79 for (int commandIdx = 0; commandIdx < frames[0].paths[pathIdx].commands.length; commandIdx += 1) { 80 final int numPointsInCommand = frames[0].paths[pathIdx].commands[commandIdx].points.length; 81 final List<List<Point<double>>> points = List<List<Point<double>>>(numPointsInCommand); 82 for (int j = 0; j < numPointsInCommand; j += 1) 83 points[j] = <Point<double>>[]; 84 final String commandType = frames[0].paths[pathIdx].commands[commandIdx].type; 85 for (int i = 0; i < frames.length; i += 1) { 86 final FrameData frame = frames[i]; 87 final String currentCommandType = frame.paths[pathIdx].commands[commandIdx].type; 88 if (commandType != currentCommandType) 89 throw Exception( 90 'Paths must be built from the same commands in all frames' 91 'command $commandIdx at frame 0 was of type \'$commandType\'' 92 'command $commandIdx at frame $i was of type \'$currentCommandType\'' 93 ); 94 for (int j = 0; j < numPointsInCommand; j += 1) 95 points[j].add(frame.paths[pathIdx].commands[commandIdx].points[j]); 96 } 97 commands.add(PathCommandAnimation(commandType, points)); 98 } 99 100 final List<double> opacities = 101 frames.map<double>((FrameData d) => d.paths[pathIdx].opacity).toList(); 102 103 return PathAnimation(commands, opacities: opacities); 104 } 105 106 /// List of commands for drawing the path. 107 final List<PathCommandAnimation> commands; 108 /// The path opacity for each animation frame. 109 final List<double> opacities; 110 111 @override 112 String toString() { 113 return 'PathAnimation(commands: $commands, opacities: $opacities)'; 114 } 115 116 String toDart() { 117 final StringBuffer sb = StringBuffer(); 118 sb.write('${kIndent * 2}const _PathFrames(\n'); 119 sb.write('${kIndent * 3}opacities: const <double>[\n'); 120 for (double opacity in opacities) 121 sb.write('${kIndent * 4}$opacity,\n'); 122 sb.write('${kIndent * 3}],\n'); 123 sb.write('${kIndent * 3}commands: const <_PathCommand>[\n'); 124 for (PathCommandAnimation command in commands) 125 sb.write(command.toDart()); 126 sb.write('${kIndent * 3}],\n'); 127 sb.write('${kIndent * 2}),\n'); 128 return sb.toString(); 129 } 130} 131 132/// Represents the animation of a single path command. 133class PathCommandAnimation { 134 const PathCommandAnimation(this.type, this.points); 135 136 /// The command type. 137 final String type; 138 139 /// A matrix with the command's points in different frames. 140 /// 141 /// points[i][j] is the i-th point of the command at frame j. 142 final List<List<Point<double>>> points; 143 144 @override 145 String toString() { 146 return 'PathCommandAnimation(type: $type, points: $points)'; 147 } 148 149 String toDart() { 150 String dartCommandClass; 151 switch (type) { 152 case 'M': 153 dartCommandClass = '_PathMoveTo'; 154 break; 155 case 'C': 156 dartCommandClass = '_PathCubicTo'; 157 break; 158 case 'L': 159 dartCommandClass = '_PathLineTo'; 160 break; 161 case 'Z': 162 dartCommandClass = '_PathClose'; 163 break; 164 default: 165 throw Exception('unsupported path command: $type'); 166 } 167 final StringBuffer sb = StringBuffer(); 168 sb.write('${kIndent * 4}const $dartCommandClass(\n'); 169 for (List<Point<double>> pointFrames in points) { 170 sb.write('${kIndent * 5}const <Offset>[\n'); 171 for (Point<double> point in pointFrames) 172 sb.write('${kIndent * 6}const Offset(${point.x}, ${point.y}),\n'); 173 sb.write('${kIndent * 5}],\n'); 174 } 175 sb.write('${kIndent * 4}),\n'); 176 return sb.toString(); 177 } 178} 179 180/// Interprets some subset of an SVG file. 181/// 182/// Recursively goes over the SVG tree, applying transforms and opacities, 183/// and build a FrameData which is a flat representation of the paths in the SVG 184/// file, after applying transformations and converting relative coordinates to 185/// absolute. 186/// 187/// This does not support the SVG specification, but is just built to 188/// support SVG files exported by a specific tool the motion design team is 189/// using. 190FrameData interpretSvg(String svgFilePath) { 191 final File file = File(svgFilePath); 192 final String fileData = file.readAsStringSync(); 193 final XmlElement svgElement = _extractSvgElement(xml.parse(fileData)); 194 final double width = parsePixels(_extractAttr(svgElement, 'width')).toDouble(); 195 final double height = parsePixels(_extractAttr(svgElement, 'height')).toDouble(); 196 197 final List<SvgPath> paths = 198 _interpretSvgGroup(svgElement.children, _Transform()); 199 return FrameData(Point<double>(width, height), paths); 200} 201 202List<SvgPath> _interpretSvgGroup(List<XmlNode> children, _Transform transform) { 203 final List<SvgPath> paths = <SvgPath>[]; 204 for (XmlNode node in children) { 205 if (node.nodeType != XmlNodeType.ELEMENT) 206 continue; 207 final XmlElement element = node; 208 209 if (element.name.local == 'path') { 210 paths.add(SvgPath.fromElement(element).applyTransform(transform)); 211 } 212 213 if (element.name.local == 'g') { 214 double opacity = transform.opacity; 215 if (_hasAttr(element, 'opacity')) 216 opacity *= double.parse(_extractAttr(element, 'opacity')); 217 218 Matrix3 transformMatrix = transform.transformMatrix; 219 if (_hasAttr(element, 'transform')) 220 transformMatrix = transformMatrix.multiplied( 221 _parseSvgTransform(_extractAttr(element, 'transform'))); 222 223 final _Transform subtreeTransform = _Transform( 224 transformMatrix: transformMatrix, 225 opacity: opacity, 226 ); 227 paths.addAll(_interpretSvgGroup(element.children, subtreeTransform)); 228 } 229 } 230 return paths; 231} 232 233// Given a points list in the form e.g: "25.0, 1.0 12.0, 12.0 23.0, 9.0" matches 234// the coordinated of the first point and the rest of the string, for the 235// example above: 236// group 1 will match "25.0" 237// group 2 will match "1.0" 238// group 3 will match "12.0, 12.0 23.0, 9.0" 239// 240// Commas are optional. 241final RegExp _pointMatcher = RegExp(r'^ *([\-\.0-9]+) *,? *([\-\.0-9]+)(.*)'); 242 243/// Parse a string with a list of points, e.g: 244/// '25.0, 1.0 12.0, 12.0 23.0, 9.0' will be parsed to: 245/// [Point(25.0, 1.0), Point(12.0, 12.0), Point(23.0, 9.0)]. 246/// 247/// Commas are optional. 248List<Point<double>> parsePoints(String points) { 249 String unParsed = points; 250 final List<Point<double>> result = <Point<double>>[]; 251 while (unParsed.isNotEmpty && _pointMatcher.hasMatch(unParsed)) { 252 final Match m = _pointMatcher.firstMatch(unParsed); 253 result.add(Point<double>( 254 double.parse(m.group(1)), 255 double.parse(m.group(2)), 256 )); 257 unParsed = m.group(3); 258 } 259 return result; 260} 261 262/// Data for a single animation frame. 263class FrameData { 264 const FrameData(this.size, this.paths); 265 266 final Point<double> size; 267 final List<SvgPath> paths; 268 269 @override 270 bool operator ==(Object other) { 271 if (runtimeType != other.runtimeType) 272 return false; 273 final FrameData typedOther = other; 274 return size == typedOther.size 275 && const ListEquality<SvgPath>().equals(paths, typedOther.paths); 276 } 277 278 @override 279 int get hashCode => size.hashCode ^ paths.hashCode; 280 281 @override 282 String toString() { 283 return 'FrameData(size: $size, paths: $paths)'; 284 } 285} 286 287/// Represents an SVG path element. 288class SvgPath { 289 const SvgPath(this.id, this.commands, {this.opacity = 1.0}); 290 291 final String id; 292 final List<SvgPathCommand> commands; 293 final double opacity; 294 295 static const String _pathCommandAtom = ' *([a-zA-Z]) *([\-\.0-9 ,]*)'; 296 static final RegExp _pathCommandValidator = RegExp('^($_pathCommandAtom)*\$'); 297 static final RegExp _pathCommandMatcher = RegExp(_pathCommandAtom); 298 299 static SvgPath fromElement(XmlElement pathElement) { 300 assert(pathElement.name.local == 'path'); 301 final String id = _extractAttr(pathElement, 'id'); 302 final String dAttr = _extractAttr(pathElement, 'd'); 303 final List<SvgPathCommand> commands = <SvgPathCommand>[]; 304 final SvgPathCommandBuilder commandsBuilder = SvgPathCommandBuilder(); 305 if (!_pathCommandValidator.hasMatch(dAttr)) 306 throw Exception('illegal or unsupported path d expression: $dAttr'); 307 for (Match match in _pathCommandMatcher.allMatches(dAttr)) { 308 final String commandType = match.group(1); 309 final String pointStr = match.group(2); 310 commands.add(commandsBuilder.build(commandType, parsePoints(pointStr))); 311 } 312 return SvgPath(id, commands); 313 } 314 315 SvgPath applyTransform(_Transform transform) { 316 final List<SvgPathCommand> transformedCommands = 317 commands.map<SvgPathCommand>((SvgPathCommand c) => c.applyTransform(transform)).toList(); 318 return SvgPath(id, transformedCommands, opacity: opacity * transform.opacity); 319 } 320 321 @override 322 bool operator ==(Object other) { 323 if (runtimeType != other.runtimeType) 324 return false; 325 final SvgPath typedOther = other; 326 return id == typedOther.id 327 && opacity == typedOther.opacity 328 && const ListEquality<SvgPathCommand>().equals(commands, typedOther.commands); 329 } 330 331 @override 332 int get hashCode => id.hashCode ^ commands.hashCode ^ opacity.hashCode; 333 334 @override 335 String toString() { 336 return 'SvgPath(id: $id, opacity: $opacity, commands: $commands)'; 337 } 338 339} 340 341/// Represents a single SVG path command from an SVG d element. 342/// 343/// This class normalizes all the 'd' commands into a single type, that has 344/// a command type and a list of points. 345/// 346/// Some examples of how d commands translated to SvgPathCommand: 347/// * "M 0.0, 1.0" => SvgPathCommand('M', [Point(0.0, 1.0)]) 348/// * "Z" => SvgPathCommand('Z', []) 349/// * "C 1.0, 1.0 2.0, 2.0 3.0, 3.0" SvgPathCommand('C', [Point(1.0, 1.0), 350/// Point(2.0, 2.0), Point(3.0, 3.0)]) 351class SvgPathCommand { 352 const SvgPathCommand(this.type, this.points); 353 354 /// The command type. 355 final String type; 356 357 /// List of points used by this command. 358 final List<Point<double>> points; 359 360 SvgPathCommand applyTransform(_Transform transform) { 361 final List<Point<double>> transformedPoints = 362 _vector3ArrayToPoints( 363 transform.transformMatrix.applyToVector3Array( 364 _pointsToVector3Array(points) 365 ) 366 ); 367 return SvgPathCommand(type, transformedPoints); 368 } 369 370 @override 371 bool operator ==(Object other) { 372 if (runtimeType != other.runtimeType) 373 return false; 374 final SvgPathCommand typedOther = other; 375 return type == typedOther.type 376 && const ListEquality<Point<double>>().equals(points, typedOther.points); 377 } 378 379 @override 380 int get hashCode => type.hashCode ^ points.hashCode; 381 382 @override 383 String toString() { 384 return 'SvgPathCommand(type: $type, points: $points)'; 385 } 386} 387 388class SvgPathCommandBuilder { 389 static const Map<String, void> kRelativeCommands = <String, void> { 390 'c': null, 391 'l': null, 392 'm': null, 393 't': null, 394 's': null, 395 }; 396 397 Point<double> lastPoint = const Point<double>(0.0, 0.0); 398 Point<double> subPathStartPoint = const Point<double>(0.0, 0.0); 399 400 SvgPathCommand build(String type, List<Point<double>> points) { 401 List<Point<double>> absPoints = points; 402 if (_isRelativeCommand(type)) { 403 absPoints = points.map<Point<double>>((Point<double> p) => p + lastPoint).toList(); 404 } 405 406 if (type == 'M' || type == 'm') 407 subPathStartPoint = absPoints.last; 408 409 if (type == 'Z' || type == 'z') 410 lastPoint = subPathStartPoint; 411 else 412 lastPoint = absPoints.last; 413 414 return SvgPathCommand(type.toUpperCase(), absPoints); 415 } 416 417 static bool _isRelativeCommand(String type) { 418 return kRelativeCommands.containsKey(type); 419 } 420} 421 422List<double> _pointsToVector3Array(List<Point<double>> points) { 423 final List<double> result = List<double>(points.length * 3); 424 for (int i = 0; i < points.length; i += 1) { 425 result[i * 3] = points[i].x; 426 result[i * 3 + 1] = points[i].y; 427 result[i * 3 + 2] = 1.0; 428 } 429 return result; 430} 431 432List<Point<double>> _vector3ArrayToPoints(List<double> vector) { 433 final int numPoints = (vector.length / 3).floor(); 434 final List<Point<double>> points = List<Point<double>>(numPoints); 435 for (int i = 0; i < numPoints; i += 1) { 436 points[i] = Point<double>(vector[i*3], vector[i*3 + 1]); 437 } 438 return points; 439} 440 441/// Represents a transformation to apply on an SVG subtree. 442/// 443/// This includes more transforms than the ones described by the SVG transform 444/// attribute, e.g opacity. 445class _Transform { 446 447 /// Constructs a new _Transform, default arguments create a no-op transform. 448 _Transform({Matrix3 transformMatrix, this.opacity = 1.0}) : 449 transformMatrix = transformMatrix ?? Matrix3.identity(); 450 451 final Matrix3 transformMatrix; 452 final double opacity; 453 454 _Transform applyTransform(_Transform transform) { 455 return _Transform( 456 transformMatrix: transform.transformMatrix.multiplied(transformMatrix), 457 opacity: transform.opacity * opacity, 458 ); 459 } 460} 461 462 463const String _transformCommandAtom = ' *([^(]+)\\(([^)]*)\\)'; 464final RegExp _transformValidator = RegExp('^($_transformCommandAtom)*\$'); 465final RegExp _transformCommand = RegExp(_transformCommandAtom); 466 467Matrix3 _parseSvgTransform(String transform) { 468 if (!_transformValidator.hasMatch(transform)) 469 throw Exception('illegal or unsupported transform: $transform'); 470 final Iterable<Match> matches =_transformCommand.allMatches(transform).toList().reversed; 471 Matrix3 result = Matrix3.identity(); 472 for (Match m in matches) { 473 final String command = m.group(1); 474 final String params = m.group(2); 475 if (command == 'translate') { 476 result = _parseSvgTranslate(params).multiplied(result); 477 continue; 478 } 479 if (command == 'scale') { 480 result = _parseSvgScale(params).multiplied(result); 481 continue; 482 } 483 if (command == 'rotate') { 484 result = _parseSvgRotate(params).multiplied(result); 485 continue; 486 } 487 throw Exception('unimplemented transform: $command'); 488 } 489 return result; 490} 491 492final RegExp _valueSeparator = RegExp('( *, *| +)'); 493 494Matrix3 _parseSvgTranslate(String paramsStr) { 495 final List<String> params = paramsStr.split(_valueSeparator); 496 assert(params.isNotEmpty); 497 assert(params.length <= 2); 498 final double x = double.parse(params[0]); 499 final double y = params.length < 2 ? 0 : double.parse(params[1]); 500 return _matrix(1.0, 0.0, 0.0, 1.0, x, y); 501} 502 503Matrix3 _parseSvgScale(String paramsStr) { 504 final List<String> params = paramsStr.split(_valueSeparator); 505 assert(params.isNotEmpty); 506 assert(params.length <= 2); 507 final double x = double.parse(params[0]); 508 final double y = params.length < 2 ? 0 : double.parse(params[1]); 509 return _matrix(x, 0.0, 0.0, y, 0.0, 0.0); 510} 511 512Matrix3 _parseSvgRotate(String paramsStr) { 513 final List<String> params = paramsStr.split(_valueSeparator); 514 assert(params.length == 1); 515 final double a = radians(double.parse(params[0])); 516 return _matrix(cos(a), sin(a), -sin(a), cos(a), 0.0, 0.0); 517} 518 519Matrix3 _matrix(double a, double b, double c, double d, double e, double f) { 520 return Matrix3(a, b, 0.0, c, d, 0.0, e, f, 1.0); 521} 522 523// Matches a pixels expression e.g "14px". 524// First group is just the number. 525final RegExp _pixelsExp = RegExp('^([0-9]+)px\$'); 526 527/// Parses a pixel expression, e.g "14px", and returns the number. 528/// Throws an [ArgumentError] if the given string doesn't match the pattern. 529int parsePixels(String pixels) { 530 if (!_pixelsExp.hasMatch(pixels)) 531 throw ArgumentError( 532 'illegal pixels expression: \'$pixels\'' 533 ' (the tool currently only support pixel units).'); 534 return int.parse(_pixelsExp.firstMatch(pixels).group(1)); 535} 536 537String _extractAttr(XmlElement element, String name) { 538 try { 539 return element.attributes.singleWhere((XmlAttribute x) => x.name.local == name) 540 .value; 541 } catch (e) { 542 throw ArgumentError( 543 'Can\'t find a single \'$name\' attributes in ${element.name}, ' 544 'attributes were: ${element.attributes}' 545 ); 546 } 547} 548 549bool _hasAttr(XmlElement element, String name) { 550 return element.attributes.where((XmlAttribute a) => a.name.local == name).isNotEmpty; 551} 552 553XmlElement _extractSvgElement(XmlDocument document) { 554 return document.children.singleWhere( 555 (XmlNode node) => node.nodeType == XmlNodeType.ELEMENT && 556 _asElement(node).name.local == 'svg' 557 ); 558} 559 560XmlElement _asElement(XmlNode node) => node; 561