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
// 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';
11
import 'package:xml/xml.dart';
12 13 14 15 16 17 18 19 20 21 22 23 24

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

  /// 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];
41
      if (size != frame.size) {
42
        throw Exception(
43 44 45 46
            '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})'
        );
47 48
      }
      if (numPaths != frame.paths.length) {
49
        throw Exception(
50 51 52 53
            'All animation frames must have the same number of paths,\n'
            'first frame has $numPaths paths\n'
            'frame $i has ${frame.paths.length} paths'
        );
54
      }
55 56 57 58
    }
  }

  String toDart(String className, String varName) {
59
    final StringBuffer sb = StringBuffer();
60 61 62
    sb.write('const $className $varName = const $className(\n');
    sb.write('${kIndent}const Size(${size.x}, ${size.y}),\n');
    sb.write('${kIndent}const <_PathFrames>[\n');
63
    for (final PathAnimation path in paths) {
64
      sb.write(path.toDart());
65
    }
66 67 68 69 70 71 72 73
    sb.write('$kIndent],\n');
    sb.write(');');
    return sb.toString();
  }
}

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

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

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

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

106
    return PathAnimation(commands, opacities: opacities);
107 108 109 110 111 112 113 114 115 116 117 118 119
  }

  /// 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() {
120
    final StringBuffer sb = StringBuffer();
121 122
    sb.write('${kIndent * 2}const _PathFrames(\n');
    sb.write('${kIndent * 3}opacities: const <double>[\n');
123
    for (final double opacity in opacities) {
124
      sb.write('${kIndent * 4}$opacity,\n');
125
    }
126 127
    sb.write('${kIndent * 3}],\n');
    sb.write('${kIndent * 3}commands: const <_PathCommand>[\n');
128
    for (final PathCommandAnimation command in commands) {
129
      sb.write(command.toDart());
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
    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() {
155 156 157 158 159 160 161
    final String dartCommandClass = switch (type) {
      'M' => '_PathMoveTo',
      'C' => '_PathCubicTo',
      'L' => '_PathLineTo',
      'Z' => '_PathClose',
      _ => throw Exception('unsupported path command: $type'),
    };
162
    final StringBuffer sb = StringBuffer();
163
    sb.write('${kIndent * 4}const $dartCommandClass(\n');
164
    for (final List<Point<double>> pointFrames in points) {
165
      sb.write('${kIndent * 5}const <Offset>[\n');
166
      for (final Point<double> point in pointFrames) {
167
        sb.write('${kIndent * 6}const Offset(${point.x}, ${point.y}),\n');
168
      }
169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186
      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) {
187
  final File file = File(svgFilePath);
188
  final String fileData = file.readAsStringSync();
Dan Field's avatar
Dan Field committed
189
  final XmlElement svgElement = _extractSvgElement(XmlDocument.parse(fileData));
190 191 192 193
  final double width = parsePixels(_extractAttr(svgElement, 'width')).toDouble();
  final double height = parsePixels(_extractAttr(svgElement, 'height')).toDouble();

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

List<SvgPath> _interpretSvgGroup(List<XmlNode> children, _Transform transform) {
  final List<SvgPath> paths = <SvgPath>[];
200
  for (final XmlNode node in children) {
201
    if (node.nodeType != XmlNodeType.ELEMENT) {
202
      continue;
203
    }
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
    }

    if (element.name.local == 'g') {
      double opacity = transform.opacity;
212
      if (_hasAttr(element, 'opacity')) {
213
        opacity *= double.parse(_extractAttr(element, 'opacity'));
214
      }
215 216

      Matrix3 transformMatrix = transform.transformMatrix;
217
      if (_hasAttr(element, 'transform')) {
218 219
        transformMatrix = transformMatrix.multiplied(
          _parseSvgTransform(_extractAttr(element, 'transform')));
220
      }
221

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

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

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

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

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

  @override
280
  int get hashCode => Object.hash(size, Object.hashAll(paths));
281 282 283 284 285 286 287 288

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

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

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

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

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

318
  SvgPath _applyTransform(_Transform transform) {
319
    final List<SvgPathCommand> transformedCommands =
320
      commands.map<SvgPathCommand>((SvgPathCommand c) => c._applyTransform(transform)).toList();
321
    return SvgPath(id, transformedCommands, opacity: opacity * transform.opacity);
322 323 324 325
  }

  @override
  bool operator ==(Object other) {
326
    if (other.runtimeType != runtimeType) {
327
      return false;
328
    }
329 330 331 332
    return other is SvgPath
        && other.id == id
        && other.opacity == opacity
        && const ListEquality<SvgPathCommand>().equals(other.commands, commands);
333 334 335
  }

  @override
336
  int get hashCode => Object.hash(id, Object.hashAll(commands), opacity);
337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354

  @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)])
355
@immutable
356 357 358 359 360 361 362 363 364
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;

365
  SvgPathCommand _applyTransform(_Transform transform) {
366 367 368 369 370 371
    final List<Point<double>> transformedPoints =
    _vector3ArrayToPoints(
        transform.transformMatrix.applyToVector3Array(
            _pointsToVector3Array(points)
        )
    );
372
    return SvgPathCommand(type, transformedPoints);
373 374 375 376
  }

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

  @override
386
  int get hashCode => Object.hash(type, Object.hashAll(points));
387 388 389 390 391 392 393 394

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

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

412
    if (type == 'M' || type == 'm') {
413
      subPathStartPoint = absPoints.last;
414
    }
415

416
    if (type == 'Z' || type == 'z') {
417
      lastPoint = subPathStartPoint;
418
    } else {
419
      lastPoint = absPoints.last;
420
    }
421

422
    return SvgPathCommand(type.toUpperCase(), absPoints);
423 424 425 426 427 428 429 430
  }

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

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

  final Matrix3 transformMatrix;
  final double opacity;

  _Transform applyTransform(_Transform transform) {
463
    return _Transform(
464 465 466 467 468 469 470
        transformMatrix: transform.transformMatrix.multiplied(transformMatrix),
        opacity: transform.opacity * opacity,
    );
  }
}


471
const String _transformCommandAtom = r' *([^(]+)\(([^)]*)\)';
472 473
final RegExp _transformValidator = RegExp('^($_transformCommandAtom)*\$');
final RegExp _transformCommand = RegExp(_transformCommandAtom);
474

475
Matrix3 _parseSvgTransform(String transform) {
476
  if (!_transformValidator.hasMatch(transform)) {
477
    throw Exception('illegal or unsupported transform: $transform');
478
  }
479
  final Iterable<Match> matches =_transformCommand.allMatches(transform).toList().reversed;
480
  Matrix3 result = Matrix3.identity();
481
  for (final Match m in matches) {
482 483
    final String command = m.group(1)!;
    final String params = m.group(2)!;
484 485 486 487 488 489 490 491 492 493 494 495
    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;
    }
496
    throw Exception('unimplemented transform: $command');
497 498 499 500
  }
  return result;
}

501
final RegExp _valueSeparator = RegExp('( *, *| +)');
502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528

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) {
529
  return Matrix3(a, b, 0.0, c, d, 0.0, e, f, 1.0);
530 531 532 533
}

// Matches a pixels expression e.g "14px".
// First group is just the number.
534
final RegExp _pixelsExp = RegExp(r'^([0-9]+)px$');
535 536 537 538

/// 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) {
539
  if (!_pixelsExp.hasMatch(pixels)) {
540
    throw ArgumentError(
541
      "illegal pixels expression: '$pixels'"
542
      ' (the tool currently only support pixel units).');
543
  }
544
  return int.parse(_pixelsExp.firstMatch(pixels)!.group(1)!);
545 546 547 548 549 550 551
}

String _extractAttr(XmlElement element, String name) {
  try {
    return element.attributes.singleWhere((XmlAttribute x) => x.name.local == name)
        .value;
  } catch (e) {
552
    throw ArgumentError(
553
        "Can't find a single '$name' attributes in ${element.name}, "
554 555 556 557 558 559 560 561 562 563 564 565 566
        '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'
567
  ) as XmlElement;
568 569
}

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