update_icons.dart 18.6 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
const String _fontFamilyOption = 'font-family';
21
const String _classNameOption = 'class-name';
22
const String _enforceSafetyChecks = 'enforce-safety-checks';
23
const String _dryRunOption = 'dry-run';
24

25
const String _defaultIconsPath = 'packages/flutter/lib/src/material/icons.dart';
26 27
const String _defaultNewCodepointsPath = 'codepoints';
const String _defaultOldCodepointsPath = 'bin/cache/artifacts/material_fonts/codepoints';
28
const String _defaultFontFamily = 'MaterialIcons';
29
const String _defaultClassName = 'Icons';
30
const String _defaultDemoFilePath = '/tmp/new_icons_demo.dart';
31

Pierre-Louis's avatar
Pierre-Louis committed
32 33 34 35 36 37 38 39 40 41 42 43 44 45
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'],
};
46

47
// Rewrite certain Flutter IDs (numbers) using prefix matching.
48
const Map<String, String> _identifierPrefixRewrites = <String, String>{
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 77 78 79
  '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',
80 81 82
};

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

92
const Set<String> _iconsMirroredWhenRTL = <String>{
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 163 164 165
  // 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',
166
};
167

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

174
  final ArgResults argResults = _handleArguments(args);
175

176 177 178 179 180 181 182 183
  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}');
184 185
    exit(1);
  }
186
  final File newCodepointsFile = File(argResults[_newCodepointsPathOption] as String);
187 188
  if (!newCodepointsFile.existsSync()) {
    stderr.writeln('Error: New codepoints file not found: ${newCodepointsFile.path}');
189 190
    exit(1);
  }
191
  final File oldCodepointsFile = File(argResults[_oldCodepointsPathOption] as String);
192 193
  if (!oldCodepointsFile.existsSync()) {
    stderr.writeln('Error: Old codepoints file not found: ${oldCodepointsFile.path}');
194 195 196
    exit(1);
  }

197
  final String newCodepointsString = newCodepointsFile.readAsStringSync();
198
  final Map<String, String> newTokenPairMap = stringToTokenPairMap(newCodepointsString);
199 200

  final String oldCodepointsString = oldCodepointsFile.readAsStringSync();
201
  final Map<String, String> oldTokenPairMap = stringToTokenPairMap(oldCodepointsString);
202

203 204 205 206 207 208 209
  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();
210

211 212 213 214 215
  stderr.writeln("Generating icons ${argResults[_dryRunOption] as bool ? '' : 'to ${iconsFile.path}'}");
  final String newIconsContents = _regenerateIconsFile(
    iconsTemplateContents,
    newTokenPairMap,
    argResults[_fontFamilyOption] as String,
216
    argResults[_classNameOption] as String,
217 218
    argResults[_enforceSafetyChecks] as bool,
  );
219 220

  if (argResults[_dryRunOption] as bool) {
221
    stdout.write(newIconsContents);
222
  } else {
223
    iconsFile.writeAsStringSync(newIconsContents);
224 225 226 227 228 229

    final SplayTreeMap<String, String> sortedNewTokenPairMap = SplayTreeMap<String, String>.of(newTokenPairMap);
    _regenerateCodepointsFile(oldCodepointsFile, sortedNewTokenPairMap);

    sortedNewTokenPairMap.removeWhere((String key, String value) => oldTokenPairMap.containsKey(key));
    _generateIconDemo(File(_defaultDemoFilePath), sortedNewTokenPairMap);
230
  }
231 232
}

233 234
ArgResults _handleArguments(List<String> args) {
  final ArgParser argParser = ArgParser()
235 236 237 238 239 240 241
    ..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')
242 243 244 245 246 247
    ..addOption(_newCodepointsPathOption,
        defaultsTo: _defaultNewCodepointsPath,
        help: 'Location of the new codepoints directory')
    ..addOption(_oldCodepointsPathOption,
        defaultsTo: _defaultOldCodepointsPath,
        help: 'Location of the existing codepoints directory')
248 249 250
    ..addOption(_fontFamilyOption,
        defaultsTo: _defaultFontFamily,
        help: 'The font family to use for the IconData constants')
251 252 253
    ..addOption(_classNameOption,
        defaultsTo: _defaultClassName,
        help: 'The containing class for all icons')
254 255 256
    ..addFlag(_enforceSafetyChecks,
        defaultsTo: true,
        help: 'Whether to exit if safety checks fail (e.g. codepoints are missing or unstable')
257
    ..addFlag(_dryRunOption);
258 259 260 261 262 263
  argParser.addFlag('help', abbr: 'h', negatable: false, callback: (bool help) {
    if (help) {
      print(argParser.usage);
      exit(1);
    }
  });
264 265 266
  return argParser.parse(args);
}

267
Map<String, String> stringToTokenPairMap(String codepointData) {
268 269 270 271
  final Iterable<String> cleanData = LineSplitter.split(codepointData)
      .map((String line) => line.trim())
      .where((String line) => line.isNotEmpty);

272
  final Map<String, String> pairs = <String, String>{};
273 274 275 276 277 278 279 280 281 282 283 284

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

285 286 287 288
String _regenerateIconsFile(
    String templateFileContents,
    Map<String, String> tokenPairMap,
    String fontFamily,
289
    String className,
290 291
    bool enforceSafetyChecks,
  ) {
292
  final List<Icon> newIcons = tokenPairMap.entries
293 294
      .map((MapEntry<String, String> entry) =>
        Icon(entry, fontFamily: fontFamily, className: className))
295
      .toList();
296
  newIcons.sort((Icon a, Icon b) => a._compareTo(b));
297

298
  final StringBuffer buf = StringBuffer();
299
  bool generating = false;
Pierre-Louis's avatar
Pierre-Louis committed
300

301
  for (final String line in LineSplitter.split(templateFileContents)) {
302
    if (!generating) {
303
      buf.writeln(line);
304
    }
Pierre-Louis's avatar
Pierre-Louis committed
305

306
    // Generate for PlatformAdaptiveIcons
Pierre-Louis's avatar
Pierre-Louis committed
307
    if (line.contains(_beginPlatformAdaptiveGeneratedMark)) {
308
      generating = true;
Pierre-Louis's avatar
Pierre-Louis committed
309 310
      final List<String> platformAdaptiveDeclarations = <String>[];
      _platformAdaptiveIdentifiers.forEach((String flutterId, List<String> ids) {
311
        // Automatically finds and generates all icon declarations.
312
        for (final String style in <String>['', '_outlined', '_rounded', '_sharp']) {
Pierre-Louis's avatar
Pierre-Louis committed
313
          try {
314 315
            final Icon agnosticIcon = newIcons.firstWhere(
                (Icon icon) => icon.id == '${ids[0]}$style',
Pierre-Louis's avatar
Pierre-Louis committed
316
                orElse: () => throw ids[0]);
317 318
            final Icon iOSIcon = newIcons.firstWhere(
                (Icon icon) => icon.id == '${ids[1]}$style',
Pierre-Louis's avatar
Pierre-Louis committed
319
                orElse: () => throw ids[1]);
320 321
            platformAdaptiveDeclarations.add(
              agnosticIcon.platformAdaptiveDeclaration('$flutterId$style', iOSIcon),
322
            );
Pierre-Louis's avatar
Pierre-Louis committed
323
          } catch (e) {
324
            if (style == '') {
325 326 327 328 329 330
              // 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
331 332 333 334 335 336 337 338 339 340 341
            } 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);
    }
342

Pierre-Louis's avatar
Pierre-Louis committed
343 344 345
    // Generate for Icons
    if (line.contains(_beginGeneratedMark)) {
      generating = true;
346
      final String iconDeclarationsString = newIcons.map((Icon icon) => icon.fullDeclaration).join();
347 348
      buf.write(iconDeclarationsString);
    } else if (line.contains(_endGeneratedMark)) {
349 350 351 352 353 354 355
      generating = false;
      buf.writeln(line);
    }
  }
  return buf.toString();
}

356 357
@visibleForTesting
bool testIsSuperset(Map<String, String> newCodepoints, Map<String, String> oldCodepoints) {
358 359 360
  final Set<String> newCodepointsSet = newCodepoints.keys.toSet();
  final Set<String> oldCodepointsSet = oldCodepoints.keys.toSet();

361 362 363 364
  final int diff = newCodepointsSet.length - oldCodepointsSet.length;
  if (diff > 0) {
    stderr.writeln('🆕 $diff new codepoints: ${newCodepointsSet.difference(oldCodepointsSet)}');
  }
365
  if (!newCodepointsSet.containsAll(oldCodepointsSet)) {
366 367 368 369
    stderr.writeln(
        '❌ new codepoints file does not contain all ${oldCodepointsSet.length} '
        'existing codepoints. Missing: ${oldCodepointsSet.difference(newCodepointsSet)}');
    return false;
370
  } else {
371 372 373 374 375 376 377 378 379 380 381 382 383 384 385
    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);
      }
386
    }
387 388 389 390 391 392 393 394
  });

  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;
395
  }
396
}
Pierre-Louis's avatar
Pierre-Louis committed
397

398
void _regenerateCodepointsFile(File oldCodepointsFile, Map<String, String> tokenPairMap) {
399
  stderr.writeln('Regenerating old codepoints file ${oldCodepointsFile.path}');
400 401

  final StringBuffer buf = StringBuffer();
402
  tokenPairMap.forEach((String key, String value) => buf.writeln('$key $value'));
403
  oldCodepointsFile.writeAsStringSync(buf.toString());
404 405
}

406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431
void _generateIconDemo(File demoFilePath, Map<String, String> tokenPairMap) {
  if (tokenPairMap.isEmpty) {
    stderr.writeln('No new icons, skipping generating icon demo');
    return;
  }
  stderr.writeln('Generating icon demo at $_defaultDemoFilePath');

  final StringBuffer newIconUsages = StringBuffer();
  for (final MapEntry<String, String> entry in tokenPairMap.entries) {
    newIconUsages.writeln(Icon(entry).usage);
  }
  final String demoFileContents = '''
    import 'package:flutter/material.dart';

    void main() => runApp(const IconDemo());

    class IconDemo extends StatelessWidget {
      const IconDemo({ Key? key }) : super(key: key);

      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          home: Scaffold(
            body: Wrap(
              children: const [
432
                $newIconUsages
433 434 435 436 437 438 439 440 441 442
              ],
            ),
          ),
        );
      }
    }
    ''';
  demoFilePath.writeAsStringSync(demoFileContents);
}

443
class Icon {
444
  // Parse tokenPair (e.g. {"6_ft_apart_outlined": "e004"}).
445 446 447 448
  Icon(MapEntry<String, String> tokenPair, {
    this.fontFamily = _defaultFontFamily,
    this.className = _defaultClassName,
  }) {
449 450 451
    id = tokenPair.key;
    hexCodepoint = tokenPair.value;

452 453 454 455 456 457 458 459 460
    // 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';
461
      htmlSuffix = '-outlined';
462 463 464
    } else if (id.endsWith('_monoline_filled')) {
      family = 'Monoline';
      htmlSuffix = '-filled';
465
    } else {
466
      family = 'material';
467 468 469 470
      if (id.endsWith('_baseline')) {
        id = _removeLast(id, '_baseline');
        htmlSuffix = '';
      } else if (id.endsWith('_outlined')) {
471 472 473 474 475
        htmlSuffix = '-outlined';
      } else if (id.endsWith('_rounded')) {
        htmlSuffix = '-round';
      } else if (id.endsWith('_sharp')) {
        htmlSuffix = '-sharp';
476 477
      }
    }
478

479 480
    shortId = _generateShortId(id);
    flutterId = generateFlutterId(id);
481 482
  }

483 484 485 486 487 488 489
  static const List<String> _idSuffixes = <String>[
    '_gm_outlined',
    '_gm_filled',
    '_monoline_outlined',
    '_monoline_filled',
    '_outlined',
    '_rounded',
490
    '_sharp',
491
  ];
492

493 494 495 496 497
  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
498 499
  late String htmlSuffix = ''; // The suffix for the 'material-icons' HTML class.
  String fontFamily; // The IconData font family.
500
  String className; // The containing class.
501

502
  String get name => shortId.replaceAll('_', ' ').trim();
503

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

506 507 508
  String get dartDoc =>
      '<i class="material-icons$htmlSuffix md-36">$shortId</i> &#x2014; $family icon named "$name"$style';

509
  String get usage => 'Icon($className.$flutterId),';
510

511 512 513
  String get mirroredInRTL => _iconsMirroredWhenRTL.contains(shortId)
      ? ', matchTextDirection: true'
      : '';
514 515

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

Pierre-Louis's avatar
Pierre-Louis committed
518 519 520 521 522 523
  String get fullDeclaration => '''

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

524
  String platformAdaptiveDeclaration(String fullFlutterId, Icon iOSIcon) => '''
Pierre-Louis's avatar
Pierre-Louis committed
525

526 527
  /// Platform-adaptive icon for $dartDoc and ${iOSIcon.dartDoc}.;
  IconData get $fullFlutterId => !_isCupertino() ? $className.$flutterId : $className.${iOSIcon.flutterId};
Pierre-Louis's avatar
Pierre-Louis committed
528 529 530 531
''';

  @override
  String toString() => id;
532

533
  /// Analogous to [String.compareTo]
534
  int _compareTo(Icon b) {
535
    if (shortId == b.shortId) {
536
      // Sort a regular icon before its variants.
537 538
      return id.length - b.id.length;
    }
539
    return shortId.compareTo(b.shortId);
540 541
  }

542
  static String _removeLast(String string, String toReplace) {
543 544
    return string.replaceAll(RegExp('$toReplace\$'), '');
  }
545 546 547 548

  static String _generateShortId(String id) {
    String shortId = id;
    for (final String styleSuffix in _idSuffixes) {
549
      shortId = _removeLast(shortId, styleSuffix);
550 551 552 553 554 555 556 557 558 559 560
      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.
561
    for (final MapEntry<String, String> rewritePair in _identifierExactRewrites.entries) {
562
      final String shortId = Icon._generateShortId(id);
563
      if (shortId == rewritePair.key) {
564 565
        flutterId = id.replaceFirst(
          rewritePair.key,
566
          _identifierExactRewrites[rewritePair.key]!,
567
        );
568 569
      }
    }
570
    // Prefix identifier rewrites.
571
    for (final MapEntry<String, String> rewritePair in _identifierPrefixRewrites.entries) {
572
      if (id.startsWith(rewritePair.key)) {
573 574
        flutterId = id.replaceFirst(
          rewritePair.key,
575
          _identifierPrefixRewrites[rewritePair.key]!,
576
        );
577 578
      }
    }
579 580 581 582

    // Prevent double underscores.
    flutterId = flutterId.replaceAll('__', '_');

583 584
    return flutterId;
  }
585
}