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

5 6
// @dart = 2.8

7
// Regenerates the material icons file.
8
// See https://github.com/flutter/flutter/wiki/Updating-Material-Design-Fonts-&-Icons
9 10 11 12 13 14 15

import 'dart:convert' show LineSplitter;
import 'dart:io';

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

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

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

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

40
// Rewrite certain Flutter IDs (reserved keywords, numbers) using prefix matching.
41
const Map<String, String> identifierRewrites = <String, String>{
42
  '1x': 'one_x',
43
  '360': 'threesixty',
44 45 46
  '2d': 'twod',
  '3d': 'threed',
  '3p': 'three_p',
47
  '6_ft': 'six_ft',
48 49
  '3g': 'three_g',
  '4g': 'four_g',
50
  '5g': 'five_g',
51 52
  '30fps': 'thirty_fps',
  '60fps': 'sixty_fps',
53 54 55
  '1k': 'one_k',
  '2k': 'two_k',
  '3k': 'three_k',
56
  '4k': 'four_k',
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
  '5k': 'five_k',
  '6k': 'six_k',
  '7k': 'seven_k',
  '8k': 'eight_k',
  '9k': 'nine_k',
  '10k': 'ten_k',
  '1mp': 'one_mp',
  '2mp': 'two_mp',
  '3mp': 'three_mp',
  '4mp': 'four_mp',
  '5mp': 'five_mp',
  '6mp': 'six_mp',
  '7mp': 'seven_mp',
  '8mp': 'eight_mp',
  '9mp': 'nine_mp',
  '10mp': 'ten_mp',
  '11mp': 'eleven_mp',
  '12mp': 'twelve_mp',
  '13mp': 'thirteen_mp',
  '14mp': 'fourteen_mp',
  '15mp': 'fifteen_mp',
  '16mp': 'sixteen_mp',
  '17mp': 'seventeen_mp',
  '18mp': 'eighteen_mp',
  '19mp': 'nineteen_mp',
  '20mp': 'twenty_mp',
  '21mp': 'twenty_one_mp',
  '22mp': 'twenty_two_mp',
  '23mp': 'twenty_three_mp',
  '24mp': 'twenty_four_mp',
87
  'class': 'class_',
88
  'try': 'try_sms_star',
89 90
};

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

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

172
  final ArgResults argResults = _handleArguments(args);
173

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

190
  final String newCodepointsString = newCodepointsFile.readAsStringSync();
191
  final Map<String, String> newTokenPairMap = stringToTokenPairMap(newCodepointsString);
192 193

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

196 197 198 199
  _testIsMapSuperset(newTokenPairMap, oldTokenPairMap);

  final String iconClassFileData = iconClassFile.readAsStringSync();

200
  stderr.writeln('Generating icons file...');
201
  final String newIconData = regenerateIconsFile(iconClassFileData, newTokenPairMap);
202 203

  if (argResults[_dryRunOption] as bool) {
204
    stdout.write(newIconData);
205 206 207
  } else {
    stderr.writeln('\nWriting to ${iconClassFile.path}.');
    iconClassFile.writeAsStringSync(newIconData);
208
    _overwriteOldCodepoints(newCodepointsFile, oldCodepointsFile);
209
  }
210 211
}

212 213
ArgResults _handleArguments(List<String> args) {
  final ArgParser argParser = ArgParser()
214 215 216
    ..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')
217
    ..addFlag(_dryRunOption, defaultsTo: false);
218 219 220 221 222 223
  argParser.addFlag('help', abbr: 'h', negatable: false, callback: (bool help) {
    if (help) {
      print(argParser.usage);
      exit(1);
    }
  });
224 225 226
  return argParser.parse(args);
}

227
Map<String, String> stringToTokenPairMap(String codepointData) {
228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244
  final Iterable<String> cleanData = LineSplitter.split(codepointData)
      .map((String line) => line.trim())
      .where((String line) => line.isNotEmpty);

  final Map<String, String> pairs = <String, String>{};

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

245
String regenerateIconsFile(String iconData, Map<String, String> tokenPairMap) {
246 247 248
  final List<_Icon> newIcons = tokenPairMap.entries.map((MapEntry<String, String> entry) => _Icon(entry)).toList();
  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 _Icon.styleSuffixes) {
Pierre-Louis's avatar
Pierre-Louis committed
264 265 266 267 268 269 270 271 272 273
          try {
            final _Icon agnosticIcon = newIcons.firstWhere(
                (_Icon icon) => icon.id == '${ids[0]}$style',
                orElse: () => throw ids[0]);
            final _Icon iOSIcon = newIcons.firstWhere(
                (_Icon icon) => icon.id == '${ids[1]}$style',
                orElse: () => throw ids[1]);

            platformAdaptiveDeclarations.add(_Icon.platformAdaptiveDeclaration('$flutterId$style', agnosticIcon, iOSIcon));
          } catch (e) {
274 275
            if (style == '') {
              // Throw an error for regular (unstyled) icons.
Pierre-Louis's avatar
Pierre-Louis committed
276 277 278 279 280 281 282 283 284 285 286 287 288
              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);
    }
289

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

303 304 305 306 307
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)) {
308 309
    stderr.writeln('''
Error: New codepoints file does not contain all the existing codepoints.\n
310 311 312 313
        Missing: ${oldCodepointsSet.difference(newCodepointsSet)}
        ''',
    );
    exit(1);
314 315 316 317 318
  } 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)}');
319
    }
320
  }
321
}
Pierre-Louis's avatar
Pierre-Louis committed
322

323 324 325 326
// Replace the old codepoints file with the new.
void _overwriteOldCodepoints(File newCodepointsFile, File oldCodepointsFile) {
  stderr.writeln('Copying new codepoints file to ${oldCodepointsFile.path}.\n');
  newCodepointsFile.copySync(oldCodepointsFile.path);
327 328 329 330 331 332 333 334 335
}

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

    if (id.endsWith('_outlined') && id!='insert_chart_outlined') {
336 337
      shortId = _replaceLast(id, '_outlined');
      htmlSuffix = '-outlined';
338
    } else if (id.endsWith('_rounded')) {
339 340
      shortId = _replaceLast(id, '_rounded');
      htmlSuffix = '-round';
341
    } else if (id.endsWith('_sharp')) {
342 343
      shortId = _replaceLast(id, '_sharp');
      htmlSuffix = '-sharp';
344 345
    } else {
      shortId = id;
346
      htmlSuffix = '';
347 348 349
    }

    flutterId = id;
350
    for (final MapEntry<String, String> rewritePair in identifierRewrites.entries) {
351
      if (id.startsWith(rewritePair.key)) {
352
        flutterId = id.replaceFirst(rewritePair.key, identifierRewrites[rewritePair.key]);
353 354
      }
    }
355 356

    name = id.replaceAll('_', ' ');
357 358
  }

359
  static const List<String> styleSuffixes = <String>['', '_outlined', '_rounded', '_sharp'];
360

361 362 363 364 365 366 367 368
  String id;            // e.g. 5g, 5g_outlined, 5g_rounded, 5g_sharp
  String shortId;       // e.g. 5g
  String flutterId;     // e.g. five_g, five_g_outlined, five_g_rounded, five_g_sharp
  String name;          // e.g. five g, five g outlined, five g rounded, five g sharp
  String hexCodepoint;  // e.g. e547

  // The suffix for the 'material-icons' HTML class.
  String htmlSuffix;
369

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

372
  String get dartDoc => '<i class="material-icons$htmlSuffix md-36">$shortId</i> &#x2014; material icon named "$name"';
373 374 375 376

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

Pierre-Louis's avatar
Pierre-Louis committed
377 378 379 380 381 382
  String get fullDeclaration => '''

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

383
  static String platformAdaptiveDeclaration(String fullFlutterId, _Icon agnosticIcon, _Icon iOSIcon) => '''
Pierre-Louis's avatar
Pierre-Louis committed
384 385

  /// Platform-adaptive icon for ${agnosticIcon.dartDoc} and ${iOSIcon.dartDoc}.;
386
  IconData get $fullFlutterId => !_isCupertino() ? Icons.${agnosticIcon.flutterId} : Icons.${iOSIcon.flutterId};
Pierre-Louis's avatar
Pierre-Louis committed
387 388 389 390
''';

  @override
  String toString() => id;
391

392 393 394 395 396 397 398 399 400 401
  /// Analogous to [String.compareTo]
  int _compareTo(_Icon b) {
    // Sort a regular icon before its variants.
    if (shortId == b.shortId) {
      return id.length - b.id.length;
    } else {
      return flutterId.compareTo(b.flutterId);
    }
  }

402 403 404
  String _replaceLast(String string, String toReplace) {
    return string.replaceAll(RegExp('$toReplace\$'), '');
  }
405
}