• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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