vitool.dart 18.4 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
// 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' hide parse;

// 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) {
25
      paths.add(PathAnimation.fromFrameData(frames, i));
26
    }
27
    return Animation(size, paths);
28 29 30 31 32 33 34 35 36 37 38 39 40 41
  }

  /// 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)
42
        throw Exception(
43 44 45 46 47
            '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)
48
        throw Exception(
49 50 51 52 53 54 55 56
            '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) {
57
    final StringBuffer sb = StringBuffer();
58 59 60
    sb.write('const $className $varName = const $className(\n');
    sb.write('${kIndent}const Size(${size.x}, ${size.y}),\n');
    sb.write('${kIndent}const <_PathFrames>[\n');
61
    for (final PathAnimation path in paths)
62 63 64 65 66 67 68 69 70
      sb.write(path.toDart());
    sb.write('$kIndent],\n');
    sb.write(');');
    return sb.toString();
  }
}

/// Represents the animation of a single path.
class PathAnimation {
71
  const PathAnimation(this.commands, {required this.opacities});
72 73 74

  factory PathAnimation.fromFrameData(List<FrameData> frames, int pathIdx) {
    if (frames.isEmpty)
75
      return const PathAnimation(<PathCommandAnimation>[], opacities: <double>[]);
76 77 78 79

    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;
80
      final List<List<Point<double>>> points = List<List<Point<double>>>.filled(numPointsInCommand, <Point<double>>[]);
81 82 83 84 85
      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)
86
          throw Exception(
87
              'Paths must be built from the same commands in all frames '
88 89
              "command $commandIdx at frame 0 was of type '$commandType' "
              "command $commandIdx at frame $i was of type '$currentCommandType'"
90 91 92 93
          );
        for (int j = 0; j < numPointsInCommand; j += 1)
          points[j].add(frame.paths[pathIdx].commands[commandIdx].points[j]);
      }
94
      commands.add(PathCommandAnimation(commandType, points));
95 96 97 98 99
    }

    final List<double> opacities =
      frames.map<double>((FrameData d) => d.paths[pathIdx].opacity).toList();

100
    return PathAnimation(commands, opacities: opacities);
101 102 103 104 105 106 107 108 109 110 111 112 113
  }

  /// 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() {
114
    final StringBuffer sb = StringBuffer();
115 116
    sb.write('${kIndent * 2}const _PathFrames(\n');
    sb.write('${kIndent * 3}opacities: const <double>[\n');
117
    for (final double opacity in opacities)
118 119 120
      sb.write('${kIndent * 4}$opacity,\n');
    sb.write('${kIndent * 3}],\n');
    sb.write('${kIndent * 3}commands: const <_PathCommand>[\n');
121
    for (final PathCommandAnimation command in commands)
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161
      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:
162
        throw Exception('unsupported path command: $type');
163
    }
164
    final StringBuffer sb = StringBuffer();
165
    sb.write('${kIndent * 4}const $dartCommandClass(\n');
166
    for (final List<Point<double>> pointFrames in points) {
167
      sb.write('${kIndent * 5}const <Offset>[\n');
168
      for (final Point<double> point in pointFrames)
169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187
        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) {
188
  final File file = File(svgFilePath);
189
  final String fileData = file.readAsStringSync();
Dan Field's avatar
Dan Field committed
190
  final XmlElement svgElement = _extractSvgElement(XmlDocument.parse(fileData));
191 192 193 194
  final double width = parsePixels(_extractAttr(svgElement, 'width')).toDouble();
  final double height = parsePixels(_extractAttr(svgElement, 'height')).toDouble();

  final List<SvgPath> paths =
195 196
    _interpretSvgGroup(svgElement.children, _Transform());
  return FrameData(Point<double>(width, height), paths);
197 198 199 200
}

List<SvgPath> _interpretSvgGroup(List<XmlNode> children, _Transform transform) {
  final List<SvgPath> paths = <SvgPath>[];
201
  for (final XmlNode node in children) {
202 203
    if (node.nodeType != XmlNodeType.ELEMENT)
      continue;
204
    final XmlElement element = node as XmlElement;
205 206

    if (element.name.local == 'path') {
207
      paths.add(SvgPath.fromElement(element)._applyTransform(transform));
208 209 210 211 212 213 214 215 216 217 218 219
    }

    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')));

220
      final _Transform subtreeTransform = _Transform(
221
        transformMatrix: transformMatrix,
222
        opacity: opacity,
223 224 225 226 227 228 229 230 231 232 233 234 235 236 237
      );
      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.
238
final RegExp _pointMatcher = RegExp(r'^ *([\-\.0-9]+) *,? *([\-\.0-9]+)(.*)');
239 240 241 242 243 244 245 246 247 248

/// 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)) {
249
    final Match m = _pointMatcher.firstMatch(unParsed)!;
250
    result.add(Point<double>(
251 252
        double.parse(m.group(1)!),
        double.parse(m.group(2)!),
253
    ));
254
    unParsed = m.group(3)!;
255 256 257 258 259
  }
  return result;
}

/// Data for a single animation frame.
260
@immutable
261 262 263 264 265 266 267
class FrameData {
  const FrameData(this.size, this.paths);

  final Point<double> size;
  final List<SvgPath> paths;

  @override
268
  bool operator ==(Object other) {
269
    if (other.runtimeType != runtimeType)
270
      return false;
271 272 273
    return other is FrameData
        && other.size == size
        && const ListEquality<SvgPath>().equals(other.paths, paths);
274 275 276 277 278 279 280 281 282 283 284 285
  }

  @override
  int get hashCode => size.hashCode ^ paths.hashCode;

  @override
  String toString() {
    return 'FrameData(size: $size, paths: $paths)';
  }
}

/// Represents an SVG path element.
286
@immutable
287 288 289 290 291 292 293
class SvgPath {
  const SvgPath(this.id, this.commands, {this.opacity = 1.0});

  final String id;
  final List<SvgPathCommand> commands;
  final double opacity;

294
  static const String _pathCommandAtom = r' *([a-zA-Z]) *([\-\.0-9 ,]*)';
295 296
  static final RegExp _pathCommandValidator = RegExp('^($_pathCommandAtom)*\$');
  static final RegExp _pathCommandMatcher = RegExp(_pathCommandAtom);
297 298 299 300 301 302

  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>[];
303
    final SvgPathCommandBuilder commandsBuilder = SvgPathCommandBuilder();
304
    if (!_pathCommandValidator.hasMatch(dAttr))
305
      throw Exception('illegal or unsupported path d expression: $dAttr');
306
    for (final Match match in _pathCommandMatcher.allMatches(dAttr)) {
307 308
      final String commandType = match.group(1)!;
      final String pointStr = match.group(2)!;
309 310
      commands.add(commandsBuilder.build(commandType, parsePoints(pointStr)));
    }
311
    return SvgPath(id, commands);
312 313
  }

314
  SvgPath _applyTransform(_Transform transform) {
315
    final List<SvgPathCommand> transformedCommands =
316
      commands.map<SvgPathCommand>((SvgPathCommand c) => c._applyTransform(transform)).toList();
317
    return SvgPath(id, transformedCommands, opacity: opacity * transform.opacity);
318 319 320 321
  }

  @override
  bool operator ==(Object other) {
322
    if (other.runtimeType != runtimeType)
323
      return false;
324 325 326 327
    return other is SvgPath
        && other.id == id
        && other.opacity == opacity
        && const ListEquality<SvgPathCommand>().equals(other.commands, commands);
328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349
  }

  @override
  int get hashCode => id.hashCode ^ commands.hashCode ^ opacity.hashCode;

  @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)])
350
@immutable
351 352 353 354 355 356 357 358 359
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;

360
  SvgPathCommand _applyTransform(_Transform transform) {
361 362 363 364 365 366
    final List<Point<double>> transformedPoints =
    _vector3ArrayToPoints(
        transform.transformMatrix.applyToVector3Array(
            _pointsToVector3Array(points)
        )
    );
367
    return SvgPathCommand(type, transformedPoints);
368 369 370 371
  }

  @override
  bool operator ==(Object other) {
372
    if (other.runtimeType != runtimeType)
373
      return false;
374 375 376
    return other is SvgPathCommand
        && other.type == type
        && const ListEquality<Point<double>>().equals(other.points, points);
377 378 379 380 381 382 383 384 385 386 387 388
  }

  @override
  int get hashCode => type.hashCode ^ points.hashCode;

  @override
  String toString() {
    return 'SvgPathCommand(type: $type, points: $points)';
  }
}

class SvgPathCommandBuilder {
389
  static const Map<String, void> kRelativeCommands = <String, void> {
390 391 392 393 394 395 396 397 398 399 400 401 402
    '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)) {
403
      absPoints = points.map<Point<double>>((Point<double> p) => p + lastPoint).toList();
404 405 406 407 408 409 410 411 412 413
    }

    if (type == 'M' || type == 'm')
      subPathStartPoint = absPoints.last;

    if (type == 'Z' || type == 'z')
      lastPoint = subPathStartPoint;
    else
      lastPoint = absPoints.last;

414
    return SvgPathCommand(type.toUpperCase(), absPoints);
415 416 417 418 419 420 421 422
  }

  static bool _isRelativeCommand(String type) {
    return kRelativeCommands.containsKey(type);
  }
}

List<double> _pointsToVector3Array(List<Point<double>> points) {
423
  final List<double> result = List<double>.filled(points.length * 3, 0.0);
424 425 426 427 428 429 430 431 432 433
  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();
434 435 436 437
  final List<Point<double>> points = <Point<double>>[
    for (int i = 0; i < numPoints; i += 1)
      Point<double>(vector[i*3], vector[i*3 + 1]),
  ];
438 439 440 441 442 443 444 445 446 447
  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.
448
  _Transform({Matrix3? transformMatrix, this.opacity = 1.0}) :
449
      transformMatrix = transformMatrix ?? Matrix3.identity();
450 451 452 453 454

  final Matrix3 transformMatrix;
  final double opacity;

  _Transform applyTransform(_Transform transform) {
455
    return _Transform(
456 457 458 459 460 461 462
        transformMatrix: transform.transformMatrix.multiplied(transformMatrix),
        opacity: transform.opacity * opacity,
    );
  }
}


463
const String _transformCommandAtom = r' *([^(]+)\(([^)]*)\)';
464 465
final RegExp _transformValidator = RegExp('^($_transformCommandAtom)*\$');
final RegExp _transformCommand = RegExp(_transformCommandAtom);
466

467
Matrix3 _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 (final Match m in matches) {
473 474
    final String command = m.group(1)!;
    final String params = m.group(2)!;
475 476 477 478 479 480 481 482 483 484 485 486
    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;
    }
487
    throw Exception('unimplemented transform: $command');
488 489 490 491
  }
  return result;
}

492
final RegExp _valueSeparator = RegExp('( *, *| +)');
493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519

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) {
520
  return Matrix3(a, b, 0.0, c, d, 0.0, e, f, 1.0);
521 522 523 524
}

// Matches a pixels expression e.g "14px".
// First group is just the number.
525
final RegExp _pixelsExp = RegExp(r'^([0-9]+)px$');
526 527 528 529 530

/// 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))
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 537 538 539 540 541
}

String _extractAttr(XmlElement element, String name) {
  try {
    return element.attributes.singleWhere((XmlAttribute x) => x.name.local == name)
        .value;
  } catch (e) {
542
    throw ArgumentError(
543
        "Can't find a single '$name' attributes in ${element.name}, "
544 545 546 547 548 549 550 551 552 553 554 555 556
        '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'
557
  ) as XmlElement;
558 559
}

560
XmlElement _asElement(XmlNode node) => node as XmlElement;