update_icons.dart 14.9 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 13 14
import 'dart:convert' show LineSplitter;
import 'dart:io';

import 'package:args/args.dart';
import 'package:path/path.dart' as path;

15 16 17 18
const String _newCodepointsPathOption = 'new-codepoints';
const String _oldCodepointsPathOption = 'old-codepoints';
const String _iconsClassPathOption = 'icons';
const String _dryRunOption = 'dry-run';
19

20 21 22
const String _defaultNewCodepointsPath = 'codepoints';
const String _defaultOldCodepointsPath = 'bin/cache/artifacts/material_fonts/codepoints';
const String _defaultIconsPath = 'packages/flutter/lib/src/material/icons.dart';
23

Pierre-Louis's avatar
Pierre-Louis committed
24 25 26 27 28 29 30 31 32 33 34 35 36 37
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'],
};
38

39 40 41 42 43 44 45 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
// Rewrite certain Flutter IDs (numbers) using prefix matching.
const Map<String, String> identifierPrefixRewrites = <String, String>{
  '_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_',
  '_360': 'threesixty',
  '_2d': 'twod',
  '_3d': 'threed',
  '_3d_rotation': 'threed_rotation',
};

// Rewrite certain Flutter IDs (reserved keywords) using exact matching.
const Map<String, String> identifierExactRewrites = <String, String>{
75
  'class': 'class_',
76 77
  'new': 'new_',
  'switch': 'switch_',
78
  'try': 'try_sms_star',
Pierre-Louis's avatar
Pierre-Louis committed
79 80
  'door_back': 'door_back_door',
  'door_front': 'door_front_door',
81 82
};

83
const Set<String> _iconsMirroredWhenRTL = <String>{
84 85 86 87 88 89 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
  // 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',
157
};
158

159 160 161 162 163
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;

164
  final ArgResults argResults = _handleArguments(args);
165

166 167 168 169 170
  final File iconClassFile = File(path.normalize(path.absolute(argResults[_iconsClassPathOption] as String)));
  if (!iconClassFile.existsSync()) {
    stderr.writeln('Error: Icons file not found: ${iconClassFile.path}');
    exit(1);
  }
171
  final File newCodepointsFile = File(argResults[_newCodepointsPathOption] as String);
172 173
  if (!newCodepointsFile.existsSync()) {
    stderr.writeln('Error: New codepoints file not found: ${newCodepointsFile.path}');
174 175
    exit(1);
  }
176
  final File oldCodepointsFile = File(argResults[_oldCodepointsPathOption] as String);
177 178
  if (!oldCodepointsFile.existsSync()) {
    stderr.writeln('Error: Old codepoints file not found: ${oldCodepointsFile.path}');
179 180 181
    exit(1);
  }

182
  final String newCodepointsString = newCodepointsFile.readAsStringSync();
183
  final Map<String, String> newTokenPairMap = _stringToTokenPairMap(newCodepointsString);
184 185

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

188 189 190 191
  _testIsMapSuperset(newTokenPairMap, oldTokenPairMap);

  final String iconClassFileData = iconClassFile.readAsStringSync();

192
  stderr.writeln('Generating icons file...');
193
  final String newIconData = _regenerateIconsFile(iconClassFileData, newTokenPairMap);
194 195

  if (argResults[_dryRunOption] as bool) {
196
    stdout.write(newIconData);
197 198 199
  } else {
    stderr.writeln('\nWriting to ${iconClassFile.path}.');
    iconClassFile.writeAsStringSync(newIconData);
200
    _regenerateCodepointsFile(oldCodepointsFile, newTokenPairMap);
201
  }
202 203
}

204 205
ArgResults _handleArguments(List<String> args) {
  final ArgParser argParser = ArgParser()
206 207 208 209 210 211 212 213 214
    ..addOption(_newCodepointsPathOption,
        defaultsTo: _defaultNewCodepointsPath,
        help: 'Location of the new codepoints directory')
    ..addOption(_oldCodepointsPathOption,
        defaultsTo: _defaultOldCodepointsPath,
        help: 'Location of the existing codepoints directory')
    ..addOption(_iconsClassPathOption,
        defaultsTo: _defaultIconsPath,
        help: 'Location of the material icons file')
215
    ..addFlag(_dryRunOption, defaultsTo: false);
216 217 218 219 220 221
  argParser.addFlag('help', abbr: 'h', negatable: false, callback: (bool help) {
    if (help) {
      print(argParser.usage);
      exit(1);
    }
  });
222 223 224
  return argParser.parse(args);
}

225
Map<String, String> _stringToTokenPairMap(String codepointData) {
226 227 228 229
  final Iterable<String> cleanData = LineSplitter.split(codepointData)
      .map((String line) => line.trim())
      .where((String line) => line.isNotEmpty);

230
  final Map<String, String> pairs = <String,String>{};
231 232 233 234 235 236 237 238 239 240 241 242

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

243 244 245 246
String _regenerateIconsFile(String iconData, Map<String, String> tokenPairMap) {
  final List<_Icon> newIcons = tokenPairMap.entries
      .map((MapEntry<String, String> entry) => _Icon(entry))
      .toList();
247 248
  newIcons.sort((_Icon a, _Icon b) => a._compareTo(b));

249
  final StringBuffer buf = StringBuffer();
250
  bool generating = false;
Pierre-Louis's avatar
Pierre-Louis committed
251

252
  for (final String line in LineSplitter.split(iconData)) {
253
    if (!generating) {
254
      buf.writeln(line);
255
    }
Pierre-Louis's avatar
Pierre-Louis committed
256 257 258

    // Generate for _PlatformAdaptiveIcons
    if (line.contains(_beginPlatformAdaptiveGeneratedMark)) {
259
      generating = true;
Pierre-Louis's avatar
Pierre-Louis committed
260 261 262
      final List<String> platformAdaptiveDeclarations = <String>[];
      _platformAdaptiveIdentifiers.forEach((String flutterId, List<String> ids) {
        // Automatically finds and generates styled icon declarations.
263
        for (final String style in <String>['', '_outlined', '_rounded', '_sharp']) {
Pierre-Louis's avatar
Pierre-Louis committed
264 265
          try {
            final _Icon agnosticIcon = newIcons.firstWhere(
266
                    (_Icon icon) => icon.id == '${ids[0]}$style',
Pierre-Louis's avatar
Pierre-Louis committed
267 268
                orElse: () => throw ids[0]);
            final _Icon iOSIcon = newIcons.firstWhere(
269
                    (_Icon icon) => icon.id == '${ids[1]}$style',
Pierre-Louis's avatar
Pierre-Louis committed
270 271 272
                orElse: () => throw ids[1]);
            platformAdaptiveDeclarations.add(_Icon.platformAdaptiveDeclaration('$flutterId$style', agnosticIcon, iOSIcon));
          } catch (e) {
273 274
            if (style == '') {
              // Throw an error for regular (unstyled) icons.
Pierre-Louis's avatar
Pierre-Louis committed
275 276 277 278 279 280 281 282 283 284 285 286 287
              stderr.writeln("Error while generating platformAdaptiveDeclarations: Icon '$e' not found.");
              exit(1);
            } 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);
    }
288

Pierre-Louis's avatar
Pierre-Louis committed
289 290 291 292
    // Generate for Icons
    if (line.contains(_beginGeneratedMark)) {
      generating = true;
      final String iconDeclarationsString = newIcons.map((_Icon icon) => icon.fullDeclaration).join('');
293 294
      buf.write(iconDeclarationsString);
    } else if (line.contains(_endGeneratedMark)) {
295 296 297 298 299 300 301
      generating = false;
      buf.writeln(line);
    }
  }
  return buf.toString();
}

302 303 304 305 306
void _testIsMapSuperset(Map<String, String> newCodepoints, Map<String, String> oldCodepoints) {
  final Set<String> newCodepointsSet = newCodepoints.keys.toSet();
  final Set<String> oldCodepointsSet = oldCodepoints.keys.toSet();

  if (!newCodepointsSet.containsAll(oldCodepointsSet)) {
307
    stderr.writeln('''
308
Error: New codepoints file does not contain all ${oldCodepointsSet.length} existing codepoints.\n
309 310 311 312
        Missing: ${oldCodepointsSet.difference(newCodepointsSet)}
        ''',
    );
    exit(1);
313 314 315 316 317
  } else {
    final int diff = newCodepointsSet.length - oldCodepointsSet.length;
    stderr.writeln('New codepoints file contains all ${oldCodepointsSet.length} existing codepoints.');
    if (diff > 0) {
      stderr.writeln('It also contains $diff new codepoints: ${newCodepointsSet.difference(oldCodepointsSet)}');
318
    }
319
  }
320
}
Pierre-Louis's avatar
Pierre-Louis committed
321

322 323 324 325 326 327 328
void _regenerateCodepointsFile(File oldCodepointsFile, Map<String, String> newTokenPairMap) {
  stderr.writeln('Regenerating old codepoints file ${oldCodepointsFile.path}.\n');

  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());
329 330 331 332 333 334 335 336
}

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

337 338 339 340 341 342 343 344 345
    // 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';
346
      htmlSuffix = '-outlined';
347 348 349
    } else if (id.endsWith('_monoline_filled')) {
      family = 'Monoline';
      htmlSuffix = '-filled';
350
    } else {
351 352 353 354 355 356 357 358 359
      family = 'material';
      if (id.endsWith('_outlined') && id != 'insert_chart_outlined') {
        htmlSuffix = '-outlined';
      } else if (id.endsWith('_rounded')) {
        htmlSuffix = '-round';
      } else if (id.endsWith('_sharp')) {
        htmlSuffix = '-sharp';
      } else {
        htmlSuffix = '';
360 361
      }
    }
362

363 364
    shortId = _generateShortId(id);
    flutterId = generateFlutterId(id);
365 366
  }

367 368 369 370 371 372 373 374 375
  static const List<String> _idSuffixes = <String>[
    '_gm_outlined',
    '_gm_filled',
    '_monoline_outlined',
    '_monoline_filled',
    '_outlined',
    '_rounded',
    '_sharp'
  ];
376

377 378 379 380 381 382
  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
  late String htmlSuffix; // The suffix for the 'material-icons' HTML class.
383

384
  String get name => shortId.replaceAll('_', ' ').trim();
385

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

388 389 390 391 392 393
  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'
      : '';
394 395 396 397

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

Pierre-Louis's avatar
Pierre-Louis committed
398 399 400 401 402 403
  String get fullDeclaration => '''

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

404
  static String platformAdaptiveDeclaration(String fullFlutterId, _Icon agnosticIcon, _Icon iOSIcon) => '''
Pierre-Louis's avatar
Pierre-Louis committed
405 406

  /// Platform-adaptive icon for ${agnosticIcon.dartDoc} and ${iOSIcon.dartDoc}.;
407
  IconData get $fullFlutterId => !_isCupertino() ? Icons.${agnosticIcon.flutterId} : Icons.${iOSIcon.flutterId};
Pierre-Louis's avatar
Pierre-Louis committed
408 409 410 411
''';

  @override
  String toString() => id;
412

413 414 415
  /// Analogous to [String.compareTo]
  int _compareTo(_Icon b) {
    if (shortId == b.shortId) {
416
      // Sort a regular icon before its variants.
417 418
      return id.length - b.id.length;
    }
419
    return shortId.compareTo(b.shortId);
420 421
  }

422
  static String _replaceLast(String string, String toReplace) {
423 424
    return string.replaceAll(RegExp('$toReplace\$'), '');
  }
425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449

  static String _generateShortId(String id) {
    String shortId = id;
    for (final String styleSuffix in _idSuffixes) {
      if (styleSuffix == '_outlined' && id == 'insert_chart_outlined')
        continue;
      shortId = _replaceLast(shortId, styleSuffix);
      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.
    for (final MapEntry<String, String> rewritePair
    in identifierExactRewrites.entries) {
      final String shortId = _Icon._generateShortId(id);
      if (shortId == rewritePair.key) {
        flutterId = id.replaceFirst(rewritePair.key, identifierExactRewrites[rewritePair.key]!);
      }
    }
450
    // Prefix identifier rewrites.
451 452 453 454 455 456 457 458 459 460 461 462
    for (final MapEntry<String, String> rewritePair
    in identifierPrefixRewrites.entries) {
      if (id.startsWith(rewritePair.key)) {
        flutterId = id.replaceFirst(rewritePair.key, identifierPrefixRewrites[rewritePair.key]!);
      }
      // TODO(guidezpl): With the next icon update, this won't be necessary, remove it.
      if (id.startsWith(rewritePair.key.replaceFirst('_', ''))) {
        flutterId = id.replaceFirst(rewritePair.key.replaceFirst('_', ''), identifierPrefixRewrites[rewritePair.key]!);
      }
    }
    return flutterId;
  }
463
}