update_icons.dart 16.8 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// Regenerates the material icons file.
6
// See https://github.com/flutter/flutter/wiki/Updating-Material-Design-Fonts-&-Icons
7

8
import 'dart:collection';
9 10 11 12
import 'dart:convert' show LineSplitter;
import 'dart:io';

import 'package:args/args.dart';
13
import 'package:meta/meta.dart';
14 15
import 'package:path/path.dart' as path;

16 17
const String _iconsPathOption = 'icons';
const String _iconsTemplatePathOption = 'icons-template';
18 19
const String _newCodepointsPathOption = 'new-codepoints';
const String _oldCodepointsPathOption = 'old-codepoints';
20 21
const String _fontFamilyOption = 'font-family';
const String _enforceSafetyChecks = 'enforce-safety-checks';
22
const String _dryRunOption = 'dry-run';
23

24
const String _defaultIconsPath = 'packages/flutter/lib/src/material/icons.dart';
25 26
const String _defaultNewCodepointsPath = 'codepoints';
const String _defaultOldCodepointsPath = 'bin/cache/artifacts/material_fonts/codepoints';
27
const String _defaultFontFamily = 'MaterialIcons';
28

Pierre-Louis's avatar
Pierre-Louis committed
29 30 31 32 33 34 35 36 37 38 39 40 41 42
const String _beginGeneratedMark = '// BEGIN GENERATED ICONS';
const String _endGeneratedMark = '// END GENERATED ICONS';
const String _beginPlatformAdaptiveGeneratedMark = '// BEGIN GENERATED PLATFORM ADAPTIVE ICONS';
const String _endPlatformAdaptiveGeneratedMark = '// END GENERATED PLATFORM ADAPTIVE ICONS';

const Map<String, List<String>> _platformAdaptiveIdentifiers = <String, List<String>>{
  // Mapping of Flutter IDs to an Android/agnostic ID and an iOS ID.
  // Flutter IDs can be anything, but should be chosen to be agnostic.
  'arrow_back': <String>['arrow_back', 'arrow_back_ios'],
  'arrow_forward': <String>['arrow_forward', 'arrow_forward_ios'],
  'flip_camera': <String>['flip_camera_android', 'flip_camera_ios'],
  'more': <String>['more_vert', 'more_horiz'],
  'share': <String>['share', 'ios_share'],
};
43

44 45
// Rewrite certain Flutter IDs (numbers) using prefix matching.
const Map<String, String> identifierPrefixRewrites = <String, String>{
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
  '1': 'one_',
  '2': 'two_',
  '3': 'three_',
  '4': 'four_',
  '5': 'five_',
  '6': 'six_',
  '7': 'seven_',
  '8': 'eight_',
  '9': 'nine_',
  '10': 'ten_',
  '11': 'eleven_',
  '12': 'twelve_',
  '13': 'thirteen_',
  '14': 'fourteen_',
  '15': 'fifteen_',
  '16': 'sixteen_',
  '17': 'seventeen_',
  '18': 'eighteen_',
  '19': 'nineteen_',
  '20': 'twenty_',
  '21': 'twenty_one_',
  '22': 'twenty_two_',
  '23': 'twenty_three_',
  '24': 'twenty_four_',
  '30': 'thirty_',
  '60': 'sixty_',
  '123': 'onetwothree',
  '360': 'threesixty',
  '2d': 'twod',
  '3d': 'threed',
  '3d_rotation': 'threed_rotation',
77 78 79 80
};

// Rewrite certain Flutter IDs (reserved keywords) using exact matching.
const Map<String, String> identifierExactRewrites = <String, String>{
81
  'class': 'class_',
82 83
  'new': 'new_',
  'switch': 'switch_',
84
  'try': 'try_sms_star',
Pierre-Louis's avatar
Pierre-Louis committed
85 86
  'door_back': 'door_back_door',
  'door_front': 'door_front_door',
87 88
};

89
const Set<String> _iconsMirroredWhenRTL = <String>{
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 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 162
  // This list is obtained from:
  // http://google.github.io/material-design-icons/#icons-in-rtl
  'arrow_back',
  'arrow_back_ios',
  'arrow_forward',
  'arrow_forward_ios',
  'arrow_left',
  'arrow_right',
  'assignment',
  'assignment_return',
  'backspace',
  'battery_unknown',
  'call_made',
  'call_merge',
  'call_missed',
  'call_missed_outgoing',
  'call_received',
  'call_split',
  'chevron_left',
  'chevron_right',
  'chrome_reader_mode',
  'device_unknown',
  'dvr',
  'event_note',
  'featured_play_list',
  'featured_video',
  'first_page',
  'flight_land',
  'flight_takeoff',
  'format_indent_decrease',
  'format_indent_increase',
  'format_list_bulleted',
  'forward',
  'functions',
  'help',
  'help_outline',
  'input',
  'keyboard_backspace',
  'keyboard_tab',
  'label',
  'label_important',
  'label_outline',
  'last_page',
  'launch',
  'list',
  'live_help',
  'mobile_screen_share',
  'multiline_chart',
  'navigate_before',
  'navigate_next',
  'next_week',
  'note',
  'open_in_new',
  'playlist_add',
  'queue_music',
  'redo',
  'reply',
  'reply_all',
  'screen_share',
  'send',
  'short_text',
  'show_chart',
  'sort',
  'star_half',
  'subject',
  'trending_flat',
  'toc',
  'trending_down',
  'trending_up',
  'undo',
  'view_list',
  'view_quilt',
  'wrap_text',
163
};
164

165 166 167 168 169
void main(List<String> args) {
  // If we're run from the `tools` dir, set the cwd to the repo root.
  if (path.basename(Directory.current.path) == 'tools')
    Directory.current = Directory.current.parent.parent;

170
  final ArgResults argResults = _handleArguments(args);
171

172 173 174 175 176 177 178 179
  final File iconsFile = File(path.normalize(path.absolute(argResults[_iconsPathOption] as String)));
  if (!iconsFile.existsSync()) {
    stderr.writeln('Error: Icons file not found: ${iconsFile.path}');
    exit(1);
  }
  final File iconsTemplateFile = File(path.normalize(path.absolute(argResults[_iconsTemplatePathOption] as String)));
  if (!iconsTemplateFile.existsSync()) {
    stderr.writeln('Error: Icons template file not found: ${iconsTemplateFile.path}');
180 181
    exit(1);
  }
182
  final File newCodepointsFile = File(argResults[_newCodepointsPathOption] as String);
183 184
  if (!newCodepointsFile.existsSync()) {
    stderr.writeln('Error: New codepoints file not found: ${newCodepointsFile.path}');
185 186
    exit(1);
  }
187
  final File oldCodepointsFile = File(argResults[_oldCodepointsPathOption] as String);
188 189
  if (!oldCodepointsFile.existsSync()) {
    stderr.writeln('Error: Old codepoints file not found: ${oldCodepointsFile.path}');
190 191 192
    exit(1);
  }

193
  final String newCodepointsString = newCodepointsFile.readAsStringSync();
194
  final Map<String, String> newTokenPairMap = _stringToTokenPairMap(newCodepointsString);
195 196

  final String oldCodepointsString = oldCodepointsFile.readAsStringSync();
197
  final Map<String, String> oldTokenPairMap = _stringToTokenPairMap(oldCodepointsString);
198

199 200 201 202 203 204 205
  stderr.writeln('Performing safety checks');
  final bool isSuperset = testIsSuperset(newTokenPairMap, oldTokenPairMap);
  final bool isStable = testIsStable(newTokenPairMap, oldTokenPairMap);
  if ((!isSuperset || !isStable) && argResults[_enforceSafetyChecks] as bool) {
    exit(1);
  }
  final String iconsTemplateContents = iconsTemplateFile.readAsStringSync();
206

207 208 209 210 211 212 213
  stderr.writeln("Generating icons ${argResults[_dryRunOption] as bool ? '' : 'to ${iconsFile.path}'}");
  final String newIconsContents = _regenerateIconsFile(
    iconsTemplateContents,
    newTokenPairMap,
    argResults[_fontFamilyOption] as String,
    argResults[_enforceSafetyChecks] as bool,
  );
214 215

  if (argResults[_dryRunOption] as bool) {
216
    stdout.write(newIconsContents);
217
  } else {
218
    iconsFile.writeAsStringSync(newIconsContents);
219
    _regenerateCodepointsFile(oldCodepointsFile, newTokenPairMap);
220
  }
221 222
}

223 224
ArgResults _handleArguments(List<String> args) {
  final ArgParser argParser = ArgParser()
225 226 227 228 229 230 231
    ..addOption(_iconsPathOption,
        defaultsTo: _defaultIconsPath,
        help: 'Location of the material icons file')
    ..addOption(_iconsTemplatePathOption,
        defaultsTo: _defaultIconsPath,
        help:
            'Location of the material icons file template. Usually the same as --$_iconsPathOption')
232 233 234 235 236 237
    ..addOption(_newCodepointsPathOption,
        defaultsTo: _defaultNewCodepointsPath,
        help: 'Location of the new codepoints directory')
    ..addOption(_oldCodepointsPathOption,
        defaultsTo: _defaultOldCodepointsPath,
        help: 'Location of the existing codepoints directory')
238 239 240 241 242 243
    ..addOption(_fontFamilyOption,
        defaultsTo: _defaultFontFamily,
        help: 'The font family to use for the IconData constants')
    ..addFlag(_enforceSafetyChecks,
        defaultsTo: true,
        help: 'Whether to exit if safety checks fail (e.g. codepoints are missing or unstable')
244
    ..addFlag(_dryRunOption);
245 246 247 248 249 250
  argParser.addFlag('help', abbr: 'h', negatable: false, callback: (bool help) {
    if (help) {
      print(argParser.usage);
      exit(1);
    }
  });
251 252 253
  return argParser.parse(args);
}

254
Map<String, String> _stringToTokenPairMap(String codepointData) {
255 256 257 258
  final Iterable<String> cleanData = LineSplitter.split(codepointData)
      .map((String line) => line.trim())
      .where((String line) => line.isNotEmpty);

259
  final Map<String, String> pairs = <String, String>{};
260 261 262 263 264 265 266 267 268 269 270 271

  for (final String line in cleanData) {
    final List<String> tokens = line.split(' ');
    if (tokens.length != 2) {
      throw FormatException('Unexpected codepoint data: $line');
    }
    pairs.putIfAbsent(tokens[0], () => tokens[1]);
  }

  return pairs;
}

272 273 274 275 276 277
String _regenerateIconsFile(
    String templateFileContents,
    Map<String, String> tokenPairMap,
    String fontFamily,
    bool enforceSafetyChecks,
  ) {
278
  final List<_Icon> newIcons = tokenPairMap.entries
279
      .map((MapEntry<String, String> entry) => _Icon(entry, fontFamily))
280
      .toList();
281 282
  newIcons.sort((_Icon a, _Icon b) => a._compareTo(b));

283
  final StringBuffer buf = StringBuffer();
284
  bool generating = false;
Pierre-Louis's avatar
Pierre-Louis committed
285

286
  for (final String line in LineSplitter.split(templateFileContents)) {
287
    if (!generating) {
288
      buf.writeln(line);
289
    }
Pierre-Louis's avatar
Pierre-Louis committed
290

291
    // Generate for PlatformAdaptiveIcons
Pierre-Louis's avatar
Pierre-Louis committed
292
    if (line.contains(_beginPlatformAdaptiveGeneratedMark)) {
293
      generating = true;
Pierre-Louis's avatar
Pierre-Louis committed
294 295
      final List<String> platformAdaptiveDeclarations = <String>[];
      _platformAdaptiveIdentifiers.forEach((String flutterId, List<String> ids) {
296
        // Automatically finds and generates all icon declarations.
297
        for (final String style in <String>['', '_outlined', '_rounded', '_sharp']) {
Pierre-Louis's avatar
Pierre-Louis committed
298 299
          try {
            final _Icon agnosticIcon = newIcons.firstWhere(
300
                (_Icon icon) => icon.id == '${ids[0]}$style',
Pierre-Louis's avatar
Pierre-Louis committed
301 302
                orElse: () => throw ids[0]);
            final _Icon iOSIcon = newIcons.firstWhere(
303
                (_Icon icon) => icon.id == '${ids[1]}$style',
Pierre-Louis's avatar
Pierre-Louis committed
304
                orElse: () => throw ids[1]);
305 306
            platformAdaptiveDeclarations.add(_Icon.platformAdaptiveDeclaration('$flutterId$style', agnosticIcon, iOSIcon),
            );
Pierre-Louis's avatar
Pierre-Louis committed
307
          } catch (e) {
308
            if (style == '') {
309 310 311 312 313 314
              // Throw an error for baseline icons.
              stderr.writeln("❌ Platform adaptive icon '$e' not found.");
              if (enforceSafetyChecks) {
                stderr.writeln('Safety checks failed');
                exit(1);
              }
Pierre-Louis's avatar
Pierre-Louis committed
315 316 317 318 319 320 321 322 323 324 325
            } else {
              // Ignore errors for styled icons since some don't exist.
            }
          }
        }
      });
      buf.write(platformAdaptiveDeclarations.join());
    } else if (line.contains(_endPlatformAdaptiveGeneratedMark)) {
      generating = false;
      buf.writeln(line);
    }
326

Pierre-Louis's avatar
Pierre-Louis committed
327 328 329
    // Generate for Icons
    if (line.contains(_beginGeneratedMark)) {
      generating = true;
330
      final String iconDeclarationsString = newIcons.map((_Icon icon) => icon.fullDeclaration).join();
331 332
      buf.write(iconDeclarationsString);
    } else if (line.contains(_endGeneratedMark)) {
333 334 335 336 337 338 339
      generating = false;
      buf.writeln(line);
    }
  }
  return buf.toString();
}

340 341
@visibleForTesting
bool testIsSuperset(Map<String, String> newCodepoints, Map<String, String> oldCodepoints) {
342 343 344
  final Set<String> newCodepointsSet = newCodepoints.keys.toSet();
  final Set<String> oldCodepointsSet = oldCodepoints.keys.toSet();

345 346 347 348
  final int diff = newCodepointsSet.length - oldCodepointsSet.length;
  if (diff > 0) {
    stderr.writeln('🆕 $diff new codepoints: ${newCodepointsSet.difference(oldCodepointsSet)}');
  }
349
  if (!newCodepointsSet.containsAll(oldCodepointsSet)) {
350 351 352 353
    stderr.writeln(
        '❌ new codepoints file does not contain all ${oldCodepointsSet.length} '
        'existing codepoints. Missing: ${oldCodepointsSet.difference(newCodepointsSet)}');
    return false;
354
  } else {
355 356 357 358 359 360 361 362 363 364 365 366 367 368 369
    stderr.writeln('✅ new codepoints file contains all ${oldCodepointsSet.length} existing codepoints');
  }
  return true;
}

@visibleForTesting
bool testIsStable(Map<String, String> newCodepoints, Map<String, String> oldCodepoints) {
  final int oldCodepointsCount = oldCodepoints.length;
  final List<String> unstable = <String>[];

  oldCodepoints.forEach((String key, String value) {
    if (newCodepoints.containsKey(key)) {
      if (value != newCodepoints[key]) {
        unstable.add(key);
      }
370
    }
371 372 373 374 375 376 377 378
  });

  if (unstable.isNotEmpty) {
    stderr.writeln('❌ out of $oldCodepointsCount existing codepoints, ${unstable.length} were unstable: $unstable');
    return false;
  } else {
    stderr.writeln('✅ all existing $oldCodepointsCount codepoints are stable');
    return true;
379
  }
380
}
Pierre-Louis's avatar
Pierre-Louis committed
381

382
void _regenerateCodepointsFile(File oldCodepointsFile, Map<String, String> newTokenPairMap) {
383
  stderr.writeln('Regenerating old codepoints file ${oldCodepointsFile.path}');
384 385 386 387 388

  final StringBuffer buf = StringBuffer();
  final SplayTreeMap<String, String> sortedNewTokenPairMap = SplayTreeMap<String, String>.of(newTokenPairMap);
  sortedNewTokenPairMap.forEach((String key, String value) => buf.writeln('$key $value'));
  oldCodepointsFile.writeAsStringSync(buf.toString());
389 390 391 392
}

class _Icon {
  // Parse tokenPair (e.g. {"6_ft_apart_outlined": "e004"}).
393
  _Icon(MapEntry<String, String> tokenPair, this.fontFamily) {
394 395 396
    id = tokenPair.key;
    hexCodepoint = tokenPair.value;

397 398 399 400 401 402 403 404 405
    // Determine family and htmlSuffix.
    if (id.endsWith('_gm_outlined')) {
      family = 'GM';
      htmlSuffix = '-outlined';
    } else if (id.endsWith('_gm_filled')) {
      family = 'GM';
      htmlSuffix = '-filled';
    } else if (id.endsWith('_monoline_outlined')) {
      family = 'Monoline';
406
      htmlSuffix = '-outlined';
407 408 409
    } else if (id.endsWith('_monoline_filled')) {
      family = 'Monoline';
      htmlSuffix = '-filled';
410
    } else {
411
      family = 'material';
412 413 414 415
      if (id.endsWith('_baseline')) {
        id = _removeLast(id, '_baseline');
        htmlSuffix = '';
      } else if (id.endsWith('_outlined')) {
416 417 418 419 420
        htmlSuffix = '-outlined';
      } else if (id.endsWith('_rounded')) {
        htmlSuffix = '-round';
      } else if (id.endsWith('_sharp')) {
        htmlSuffix = '-sharp';
421 422
      }
    }
423

424 425
    shortId = _generateShortId(id);
    flutterId = generateFlutterId(id);
426 427
  }

428 429 430 431 432 433 434 435 436
  static const List<String> _idSuffixes = <String>[
    '_gm_outlined',
    '_gm_filled',
    '_monoline_outlined',
    '_monoline_filled',
    '_outlined',
    '_rounded',
    '_sharp'
  ];
437

438 439 440 441 442
  late String id; // e.g. 5g, 5g_outlined, 5g_rounded, 5g_sharp
  late String shortId; // e.g. 5g
  late String flutterId; // e.g. five_g, five_g_outlined, five_g_rounded, five_g_sharp
  late String family; // e.g. material
  late String hexCodepoint; // e.g. e547
443 444
  late String htmlSuffix = ''; // The suffix for the 'material-icons' HTML class.
  String fontFamily; // The IconData font family.
445

446
  String get name => shortId.replaceAll('_', ' ').trim();
447

448
  String get style => htmlSuffix == '' ? '' : ' (${htmlSuffix.replaceFirst('-', '')})';
449

450 451 452 453 454 455
  String get dartDoc =>
      '<i class="material-icons$htmlSuffix md-36">$shortId</i> &#x2014; $family icon named "$name"$style';

  String get mirroredInRTL => _iconsMirroredWhenRTL.contains(shortId)
      ? ', matchTextDirection: true'
      : '';
456 457

  String get declaration =>
458
      "static const IconData $flutterId = IconData(0x$hexCodepoint, fontFamily: '$fontFamily'$mirroredInRTL);";
459

Pierre-Louis's avatar
Pierre-Louis committed
460 461 462 463 464 465
  String get fullDeclaration => '''

  /// $dartDoc.
  $declaration
''';

466
  static String platformAdaptiveDeclaration(String fullFlutterId, _Icon agnosticIcon, _Icon iOSIcon) => '''
Pierre-Louis's avatar
Pierre-Louis committed
467 468

  /// Platform-adaptive icon for ${agnosticIcon.dartDoc} and ${iOSIcon.dartDoc}.;
469
  IconData get $fullFlutterId => !_isCupertino() ? Icons.${agnosticIcon.flutterId} : Icons.${iOSIcon.flutterId};
Pierre-Louis's avatar
Pierre-Louis committed
470 471 472 473
''';

  @override
  String toString() => id;
474

475 476 477
  /// Analogous to [String.compareTo]
  int _compareTo(_Icon b) {
    if (shortId == b.shortId) {
478
      // Sort a regular icon before its variants.
479 480
      return id.length - b.id.length;
    }
481
    return shortId.compareTo(b.shortId);
482 483
  }

484
  static String _removeLast(String string, String toReplace) {
485 486
    return string.replaceAll(RegExp('$toReplace\$'), '');
  }
487 488 489 490

  static String _generateShortId(String id) {
    String shortId = id;
    for (final String styleSuffix in _idSuffixes) {
491
      shortId = _removeLast(shortId, styleSuffix);
492 493 494 495 496 497 498 499 500 501 502
      if (shortId != id) {
        break;
      }
    }
    return shortId;
  }

  /// Given some icon's raw id, returns a valid Dart icon identifier
  static String generateFlutterId(String id) {
    String flutterId = id;
    // Exact identifier rewrites.
503
    for (final MapEntry<String, String> rewritePair in identifierExactRewrites.entries) {
504 505
      final String shortId = _Icon._generateShortId(id);
      if (shortId == rewritePair.key) {
506 507 508 509
        flutterId = id.replaceFirst(
          rewritePair.key,
          identifierExactRewrites[rewritePair.key]!,
        );
510 511
      }
    }
512
    // Prefix identifier rewrites.
513
    for (final MapEntry<String, String> rewritePair in identifierPrefixRewrites.entries) {
514
      if (id.startsWith(rewritePair.key)) {
515 516 517 518
        flutterId = id.replaceFirst(
          rewritePair.key,
          identifierPrefixRewrites[rewritePair.key]!,
        );
519 520 521 522
      }
    }
    return flutterId;
  }
523
}