update_icons.dart 18.2 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
const String _defaultDemoFilePath = '/tmp/new_icons_demo.dart';
29

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

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

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

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

166 167 168 169 170
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;

171
  final ArgResults argResults = _handleArguments(args);
172

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

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

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

200 201 202 203 204 205 206
  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();
207

208 209 210 211 212 213 214
  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,
  );
215 216

  if (argResults[_dryRunOption] as bool) {
217
    stdout.write(newIconsContents);
218
  } else {
219
    iconsFile.writeAsStringSync(newIconsContents);
220 221 222 223 224 225

    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);
226
  }
227 228
}

229 230
ArgResults _handleArguments(List<String> args) {
  final ArgParser argParser = ArgParser()
231 232 233 234 235 236 237
    ..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')
238 239 240 241 242 243
    ..addOption(_newCodepointsPathOption,
        defaultsTo: _defaultNewCodepointsPath,
        help: 'Location of the new codepoints directory')
    ..addOption(_oldCodepointsPathOption,
        defaultsTo: _defaultOldCodepointsPath,
        help: 'Location of the existing codepoints directory')
244 245 246 247 248 249
    ..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')
250
    ..addFlag(_dryRunOption);
251 252 253 254 255 256
  argParser.addFlag('help', abbr: 'h', negatable: false, callback: (bool help) {
    if (help) {
      print(argParser.usage);
      exit(1);
    }
  });
257 258 259
  return argParser.parse(args);
}

260
Map<String, String> stringToTokenPairMap(String codepointData) {
261 262 263 264
  final Iterable<String> cleanData = LineSplitter.split(codepointData)
      .map((String line) => line.trim())
      .where((String line) => line.isNotEmpty);

265
  final Map<String, String> pairs = <String, String>{};
266 267 268 269 270 271 272 273 274 275 276 277

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

278 279 280 281 282 283
String _regenerateIconsFile(
    String templateFileContents,
    Map<String, String> tokenPairMap,
    String fontFamily,
    bool enforceSafetyChecks,
  ) {
284
  final List<Icon> newIcons = tokenPairMap.entries
285
      .map((MapEntry<String, String> entry) => Icon(entry, fontFamily: fontFamily))
286
      .toList();
287
  newIcons.sort((Icon a, Icon b) => a._compareTo(b));
288

289
  final StringBuffer buf = StringBuffer();
290
  bool generating = false;
Pierre-Louis's avatar
Pierre-Louis committed
291

292
  for (final String line in LineSplitter.split(templateFileContents)) {
293
    if (!generating) {
294
      buf.writeln(line);
295
    }
Pierre-Louis's avatar
Pierre-Louis committed
296

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

Pierre-Louis's avatar
Pierre-Louis committed
333 334 335
    // Generate for Icons
    if (line.contains(_beginGeneratedMark)) {
      generating = true;
336
      final String iconDeclarationsString = newIcons.map((Icon icon) => icon.fullDeclaration).join();
337 338
      buf.write(iconDeclarationsString);
    } else if (line.contains(_endGeneratedMark)) {
339 340 341 342 343 344 345
      generating = false;
      buf.writeln(line);
    }
  }
  return buf.toString();
}

346 347
@visibleForTesting
bool testIsSuperset(Map<String, String> newCodepoints, Map<String, String> oldCodepoints) {
348 349 350
  final Set<String> newCodepointsSet = newCodepoints.keys.toSet();
  final Set<String> oldCodepointsSet = oldCodepoints.keys.toSet();

351 352 353 354
  final int diff = newCodepointsSet.length - oldCodepointsSet.length;
  if (diff > 0) {
    stderr.writeln('🆕 $diff new codepoints: ${newCodepointsSet.difference(oldCodepointsSet)}');
  }
355
  if (!newCodepointsSet.containsAll(oldCodepointsSet)) {
356 357 358 359
    stderr.writeln(
        '❌ new codepoints file does not contain all ${oldCodepointsSet.length} '
        'existing codepoints. Missing: ${oldCodepointsSet.difference(newCodepointsSet)}');
    return false;
360
  } else {
361 362 363 364 365 366 367 368 369 370 371 372 373 374 375
    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);
      }
376
    }
377 378 379 380 381 382 383 384
  });

  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;
385
  }
386
}
Pierre-Louis's avatar
Pierre-Louis committed
387

388
void _regenerateCodepointsFile(File oldCodepointsFile, Map<String, String> tokenPairMap) {
389
  stderr.writeln('Regenerating old codepoints file ${oldCodepointsFile.path}');
390 391

  final StringBuffer buf = StringBuffer();
392
  tokenPairMap.forEach((String key, String value) => buf.writeln('$key $value'));
393
  oldCodepointsFile.writeAsStringSync(buf.toString());
394 395
}

396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421
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 [
422
                $newIconUsages
423 424 425 426 427 428 429 430 431 432
              ],
            ),
          ),
        );
      }
    }
    ''';
  demoFilePath.writeAsStringSync(demoFileContents);
}

433
class Icon {
434
  // Parse tokenPair (e.g. {"6_ft_apart_outlined": "e004"}).
435
  Icon(MapEntry<String, String> tokenPair, {this.fontFamily = _defaultFontFamily}) {
436 437 438
    id = tokenPair.key;
    hexCodepoint = tokenPair.value;

439 440 441 442 443 444 445 446 447
    // 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';
448
      htmlSuffix = '-outlined';
449 450 451
    } else if (id.endsWith('_monoline_filled')) {
      family = 'Monoline';
      htmlSuffix = '-filled';
452
    } else {
453
      family = 'material';
454 455 456 457
      if (id.endsWith('_baseline')) {
        id = _removeLast(id, '_baseline');
        htmlSuffix = '';
      } else if (id.endsWith('_outlined')) {
458 459 460 461 462
        htmlSuffix = '-outlined';
      } else if (id.endsWith('_rounded')) {
        htmlSuffix = '-round';
      } else if (id.endsWith('_sharp')) {
        htmlSuffix = '-sharp';
463 464
      }
    }
465

466 467
    shortId = _generateShortId(id);
    flutterId = generateFlutterId(id);
468 469
  }

470 471 472 473 474 475 476
  static const List<String> _idSuffixes = <String>[
    '_gm_outlined',
    '_gm_filled',
    '_monoline_outlined',
    '_monoline_filled',
    '_outlined',
    '_rounded',
477
    '_sharp',
478
  ];
479

480 481 482 483 484
  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
485 486
  late String htmlSuffix = ''; // The suffix for the 'material-icons' HTML class.
  String fontFamily; // The IconData font family.
487

488
  String get name => shortId.replaceAll('_', ' ').trim();
489

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

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

495 496
  String get usage => 'Icon(Icons.$flutterId),';

497 498 499
  String get mirroredInRTL => _iconsMirroredWhenRTL.contains(shortId)
      ? ', matchTextDirection: true'
      : '';
500 501

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

Pierre-Louis's avatar
Pierre-Louis committed
504 505 506 507 508 509
  String get fullDeclaration => '''

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

510
  static String platformAdaptiveDeclaration(String fullFlutterId, Icon agnosticIcon, Icon iOSIcon) => '''
Pierre-Louis's avatar
Pierre-Louis committed
511 512

  /// Platform-adaptive icon for ${agnosticIcon.dartDoc} and ${iOSIcon.dartDoc}.;
513
  IconData get $fullFlutterId => !_isCupertino() ? Icons.${agnosticIcon.flutterId} : Icons.${iOSIcon.flutterId};
Pierre-Louis's avatar
Pierre-Louis committed
514 515 516 517
''';

  @override
  String toString() => id;
518

519
  /// Analogous to [String.compareTo]
520
  int _compareTo(Icon b) {
521
    if (shortId == b.shortId) {
522
      // Sort a regular icon before its variants.
523 524
      return id.length - b.id.length;
    }
525
    return shortId.compareTo(b.shortId);
526 527
  }

528
  static String _removeLast(String string, String toReplace) {
529 530
    return string.replaceAll(RegExp('$toReplace\$'), '');
  }
531 532 533 534

  static String _generateShortId(String id) {
    String shortId = id;
    for (final String styleSuffix in _idSuffixes) {
535
      shortId = _removeLast(shortId, styleSuffix);
536 537 538 539 540 541 542 543 544 545 546
      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.
547
    for (final MapEntry<String, String> rewritePair in _identifierExactRewrites.entries) {
548
      final String shortId = Icon._generateShortId(id);
549
      if (shortId == rewritePair.key) {
550 551
        flutterId = id.replaceFirst(
          rewritePair.key,
552
          _identifierExactRewrites[rewritePair.key]!,
553
        );
554 555
      }
    }
556
    // Prefix identifier rewrites.
557
    for (final MapEntry<String, String> rewritePair in _identifierPrefixRewrites.entries) {
558
      if (id.startsWith(rewritePair.key)) {
559 560
        flutterId = id.replaceFirst(
          rewritePair.key,
561
          _identifierPrefixRewrites[rewritePair.key]!,
562
        );
563 564
      }
    }
565 566 567 568

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

569 570
    return flutterId;
  }
571
}