update_icons.dart 12.7 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 9 10 11 12 13

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

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

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

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

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

38
const Map<String, String> _identifierRewrites = <String, String>{
39 40
  '360': 'threesixty',
  '3d_rotation': 'threed_rotation',
41 42
  '6_ft': 'six_ft',
  '5g': 'five_g',
43 44 45
  '1k': 'one_k',
  '2k': 'two_k',
  '3k': 'three_k',
46
  '4k': 'four_k',
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 78 79 80 81 82 83 84 85
  '5k': 'five_k',
  '6k': 'six_k',
  '7k': 'seven_k',
  '8k': 'eight_k',
  '9k': 'nine_k',
  '10k': 'ten_k',
  '1k_plus': 'one_k_plus',
  '2k_plus': 'two_k_plus',
  '3k_plus': 'three_k_plus',
  '4k_plus': 'four_k_plus',
  '5k_plus': 'five_k_plus',
  '6k_plus': 'six_k_plus',
  '7k_plus': 'seven_k_plus',
  '8k_plus': 'eight_k_plus',
  '9k_plus': 'nine_k_plus',
  '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',
86 87 88
  'class': 'class_',
};

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

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

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

194 195 196 197 198
  _testIsMapSuperset(newTokenPairMap, oldTokenPairMap);

  final String iconClassFileData = iconClassFile.readAsStringSync();

  stderr.writeln('Generating new token pairs.');
199
  final String newIconData = regenerateIconsFile(iconClassFileData, newTokenPairMap);
200 201

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

210 211
ArgResults _handleArguments(List<String> args) {
  final ArgParser argParser = ArgParser()
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 226
// Do not make this method private as it is used by g3 roll.
Map<String, String> stringToTokenPairMap(String codepointData) {
227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243
  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;
}

244 245
// Do not make this method private as it is used by g3 roll.
String regenerateIconsFile(String iconData, Map<String, String> tokenPairMap) {
Pierre-Louis's avatar
Pierre-Louis committed
246
  final Iterable<_Icon> newIcons = tokenPairMap.entries.map((MapEntry<String, String> entry) => _Icon(entry));
247
  final StringBuffer buf = StringBuffer();
248
  bool generating = false;
Pierre-Louis's avatar
Pierre-Louis committed
249

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

    // Generate for _PlatformAdaptiveIcons
    if (line.contains(_beginPlatformAdaptiveGeneratedMark)) {
257
      generating = true;
258

Pierre-Louis's avatar
Pierre-Louis committed
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288
      final List<String> platformAdaptiveDeclarations = <String>[];
      _platformAdaptiveIdentifiers.forEach((String flutterId, List<String> ids) {
        // Automatically finds and generates styled icon declarations.
        for (final IconStyle iconStyle in IconStyle.values) {
          final String style = iconStyle.idSuffix();
          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) {
            if (iconStyle == IconStyle.regular) {
              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 314
        Missing: ${oldCodepointsSet.difference(newCodepointsSet)}
        ''',
    );
    exit(1);
  }
315 316
}

317 318 319 320 321 322 323
enum IconStyle {
  regular,
  outlined,
  rounded,
  sharp,
}

Pierre-Louis's avatar
Pierre-Louis committed
324
extension IconStyleExtension on IconStyle {
325
  // The suffix for the 'material-icons' HTML class.
Pierre-Louis's avatar
Pierre-Louis committed
326
  String htmlSuffix() {
327 328 329 330 331 332
    switch (this) {
      case IconStyle.outlined: return '-outlined';
      case IconStyle.rounded: return '-round';
      case IconStyle.sharp: return '-sharp';
      default: return '';
    }
333
  }
Pierre-Louis's avatar
Pierre-Louis committed
334 335 336 337 338 339 340 341 342 343 344

  // The suffix for icon ids.
  String idSuffix() {
    switch (this) {
      case IconStyle.outlined:
      case IconStyle.rounded:
      case IconStyle.sharp:
        return '_' + toString().split('.').last;
      default: return '';
    }
  }
345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372
}

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') {
      style = IconStyle.outlined;
      shortId = id.replaceAll('_outlined', '');
    } else if (id.endsWith('_rounded')) {
      style = IconStyle.rounded;
      shortId = id.replaceAll('_rounded', '');
    } else if (id.endsWith('_sharp')) {
      style = IconStyle.sharp;
      shortId = id.replaceAll('_sharp', '');
    } else {
      style = IconStyle.regular;
      shortId = id;
    }

    flutterId = id;
    for (final MapEntry<String, String> rewritePair in _identifierRewrites.entries) {
      if (id.startsWith(rewritePair.key)) {
        flutterId = id.replaceFirst(rewritePair.key, _identifierRewrites[rewritePair.key]);
      }
    }
373 374
  }

375 376 377 378 379 380 381 382 383 384
  // e.g. 5g, 5g_outlined, 5g_rounded, 5g_sharp
  String id;
  // e.g. 5g
  String shortId;
  // e.g. five_g
  String flutterId;
  // e.g. IconStyle.outlined
  IconStyle style;
  // e.g. e547
  String hexCodepoint;
385

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

388
  String get name => id.replaceAll('_', ' ');
389

390
  String get dartDoc =>
Pierre-Louis's avatar
Pierre-Louis committed
391
      '<i class="material-icons${style.htmlSuffix()} md-36">$shortId</i> &#x2014; material icon named "$name"';
392 393 394 395

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

Pierre-Louis's avatar
Pierre-Louis committed
396 397 398 399 400 401 402 403 404 405 406 407 408 409
  String get fullDeclaration => '''

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

  static String platformAdaptiveDeclaration(String flutterId, _Icon agnosticIcon, _Icon iOSIcon) => '''

  /// Platform-adaptive icon for ${agnosticIcon.dartDoc} and ${iOSIcon.dartDoc}.;
  IconData get $flutterId => !_isCupertino() ? Icons.${agnosticIcon.flutterId} : Icons.${iOSIcon.flutterId};
''';

  @override
  String toString() => id;
410 411 412 413 414 415 416
}

// Replace the old codepoints file with the new.
void _cleanUpFiles(File newCodepointsFile, File oldCodepointsFile) {
  stderr.writeln('\nMoving new codepoints file to ${oldCodepointsFile.path}.\n');
  newCodepointsFile.renameSync(oldCodepointsFile.path);
}