| 1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
| 2 | // Use of this source code is governed by a BSD-style license that can be |
| 3 | // found in the LICENSE file. |
| 4 | |
| 5 | import 'package:meta/meta.dart' ; |
| 6 | import 'package:mime/mime.dart' as mime; |
| 7 | import 'package:process/process.dart' ; |
| 8 | |
| 9 | import '../../artifacts.dart'; |
| 10 | import '../../base/common.dart'; |
| 11 | import '../../base/file_system.dart'; |
| 12 | import '../../base/io.dart'; |
| 13 | import '../../base/logger.dart'; |
| 14 | import '../../base/process.dart'; |
| 15 | import '../../build_info.dart'; |
| 16 | import '../../convert.dart'; |
| 17 | import '../../devfs.dart'; |
| 18 | import '../build_system.dart'; |
| 19 | |
| 20 | List<Map<String, Object?>> _getList(Object? object, String errorMessage) { |
| 21 | if (object is List<Object?>) { |
| 22 | return object.cast<Map<String, Object?>>(); |
| 23 | } |
| 24 | throw IconTreeShakerException._(errorMessage); |
| 25 | } |
| 26 | |
| 27 | /// A class that wraps the functionality of the const finder package and the |
| 28 | /// font subset utility to tree shake unused icons from fonts. |
| 29 | class IconTreeShaker { |
| 30 | /// Creates a wrapper for icon font subsetting. |
| 31 | /// |
| 32 | /// If the `fontManifest` parameter is null, [enabled] will return false since |
| 33 | /// there are no fonts to shake. |
| 34 | /// |
| 35 | /// The constructor will validate the environment and print a warning if |
| 36 | /// font subsetting has been requested in a debug build mode. |
| 37 | IconTreeShaker( |
| 38 | this._environment, |
| 39 | DevFSStringContent? fontManifest, { |
| 40 | required ProcessManager processManager, |
| 41 | required Logger logger, |
| 42 | required FileSystem fileSystem, |
| 43 | required Artifacts artifacts, |
| 44 | required TargetPlatform targetPlatform, |
| 45 | }) : _processManager = processManager, |
| 46 | _logger = logger, |
| 47 | _fs = fileSystem, |
| 48 | _artifacts = artifacts, |
| 49 | _fontManifest = fontManifest?.string, |
| 50 | _targetPlatform = targetPlatform { |
| 51 | if (_environment.defines[kIconTreeShakerFlag] == 'true' && |
| 52 | _environment.defines[kBuildMode] == 'debug' ) { |
| 53 | logger.printError( |
| 54 | 'Font subsetting is not supported in debug mode. The ' |
| 55 | '--tree-shake-icons flag will be ignored.' , |
| 56 | ); |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | /// The MIME types for supported font sets. |
| 61 | static const kTtfMimeTypes = <String>{ |
| 62 | 'font/ttf' , // based on internet search |
| 63 | 'font/opentype' , |
| 64 | 'font/otf' , |
| 65 | 'application/x-font-opentype' , |
| 66 | 'application/x-font-otf' , |
| 67 | 'application/x-font-ttf' , // based on running locally. |
| 68 | }; |
| 69 | |
| 70 | /// The [Source] inputs that targets using this should depend on. |
| 71 | /// |
| 72 | /// See [Target.inputs]. |
| 73 | static const inputs = <Source>[ |
| 74 | Source.pattern( |
| 75 | '{FLUTTER_ROOT}/packages/flutter_tools/lib/src/build_system/targets/icon_tree_shaker.dart' , |
| 76 | ), |
| 77 | Source.artifact(Artifact.constFinder), |
| 78 | Source.artifact(Artifact.fontSubset), |
| 79 | ]; |
| 80 | |
| 81 | final Environment _environment; |
| 82 | final String? _fontManifest; |
| 83 | Future<void>? _iconDataProcessing; |
| 84 | Map<String, _IconTreeShakerData>? _iconData; |
| 85 | |
| 86 | final ProcessManager _processManager; |
| 87 | final Logger _logger; |
| 88 | final FileSystem _fs; |
| 89 | final Artifacts _artifacts; |
| 90 | final TargetPlatform _targetPlatform; |
| 91 | |
| 92 | /// Whether font subsetting should be used for this [Environment]. |
| 93 | bool get enabled => |
| 94 | _fontManifest != null && |
| 95 | _environment.defines[kIconTreeShakerFlag] == 'true' && |
| 96 | _environment.defines[kBuildMode] != 'debug' ; |
| 97 | |
| 98 | // Fills the [_iconData] map. |
| 99 | Future<void> _getIconData(Environment environment) async { |
| 100 | if (!enabled) { |
| 101 | return; |
| 102 | } |
| 103 | |
| 104 | final File appDill = environment.buildDir.childFile('app.dill' ); |
| 105 | if (!appDill.existsSync()) { |
| 106 | throw IconTreeShakerException._( |
| 107 | 'Expected to find kernel file at ${appDill.path}, but no file found.' , |
| 108 | ); |
| 109 | } |
| 110 | final File constFinder = _fs.file(_artifacts.getArtifactPath(Artifact.constFinder)); |
| 111 | final File dart = _fs.file(_artifacts.getArtifactPath(Artifact.engineDartBinary)); |
| 112 | |
| 113 | final Map<String, List<int>> iconData = await _findConstants(dart, constFinder, appDill); |
| 114 | final Set<String> familyKeys = iconData.keys.toSet(); |
| 115 | |
| 116 | final Map<String, String> fonts = await _parseFontJson( |
| 117 | _fontManifest!, // Guarded by `enabled`. |
| 118 | familyKeys, |
| 119 | ); |
| 120 | |
| 121 | if (fonts.length != iconData.length) { |
| 122 | environment.logger.printStatus( |
| 123 | 'Expected to find fonts for ${iconData.keys}, but found ' |
| 124 | ' ${fonts.keys}. This usually means you are referring to ' |
| 125 | 'font families in an IconData class but not including them ' |
| 126 | 'in the assets section of your pubspec.yaml, are missing ' |
| 127 | 'the package that would include them, or are missing ' |
| 128 | '"uses-material-design: true".' , |
| 129 | ); |
| 130 | } |
| 131 | |
| 132 | final result = <String, _IconTreeShakerData>{}; |
| 133 | const kSpacePoint = 32; |
| 134 | for (final MapEntry<String, String> entry in fonts.entries) { |
| 135 | final List<int>? codePoints = iconData[entry.key]; |
| 136 | if (codePoints == null) { |
| 137 | throw IconTreeShakerException._( |
| 138 | 'Expected to font code points for ${entry.key}, but none were found.' , |
| 139 | ); |
| 140 | } |
| 141 | |
| 142 | // Add space as an optional code point, as web uses it to measure the font height. |
| 143 | final optionalCodePoints = _targetPlatform == TargetPlatform.web_javascript |
| 144 | ? <int>[kSpacePoint] |
| 145 | : <int>[]; |
| 146 | result[entry.value] = _IconTreeShakerData( |
| 147 | family: entry.key, |
| 148 | relativePath: entry.value, |
| 149 | codePoints: codePoints, |
| 150 | optionalCodePoints: optionalCodePoints, |
| 151 | ); |
| 152 | } |
| 153 | _iconData = result; |
| 154 | } |
| 155 | |
| 156 | /// Calls font-subset, which transforms the [input] font file to a |
| 157 | /// subsetted version at [outputPath]. |
| 158 | /// |
| 159 | /// If [enabled] is false, or the relative path is not recognized as an icon |
| 160 | /// font used in the Flutter application, this returns false. |
| 161 | /// If the font-subset subprocess fails, it will [throwToolExit]. |
| 162 | /// Otherwise, it will return true. |
| 163 | Future<bool> subsetFont({ |
| 164 | required File input, |
| 165 | required String outputPath, |
| 166 | required String relativePath, |
| 167 | }) async { |
| 168 | if (!enabled) { |
| 169 | return false; |
| 170 | } |
| 171 | if (input.lengthSync() < 12) { |
| 172 | return false; |
| 173 | } |
| 174 | final String? mimeType = mime.lookupMimeType( |
| 175 | input.path, |
| 176 | headerBytes: await input.openRead(0, 12).first, |
| 177 | ); |
| 178 | if (!kTtfMimeTypes.contains(mimeType)) { |
| 179 | return false; |
| 180 | } |
| 181 | await (_iconDataProcessing ??= _getIconData(_environment)); |
| 182 | assert(_iconData != null); |
| 183 | |
| 184 | final _IconTreeShakerData? iconTreeShakerData = _iconData![relativePath]; |
| 185 | if (iconTreeShakerData == null) { |
| 186 | return false; |
| 187 | } |
| 188 | |
| 189 | final File fontSubset = _fs.file(_artifacts.getArtifactPath(Artifact.fontSubset)); |
| 190 | if (!fontSubset.existsSync()) { |
| 191 | throw IconTreeShakerException._('The font-subset utility is missing. Run "flutter doctor".' ); |
| 192 | } |
| 193 | |
| 194 | final cmd = <String>[fontSubset.path, outputPath, input.path]; |
| 195 | final Iterable<String> requiredCodePointStrings = iconTreeShakerData.codePoints.map( |
| 196 | (int codePoint) => codePoint.toString(), |
| 197 | ); |
| 198 | final Iterable<String> optionalCodePointStrings = iconTreeShakerData.optionalCodePoints.map( |
| 199 | (int codePoint) => 'optional: $codePoint' , |
| 200 | ); |
| 201 | final String codePointsString = requiredCodePointStrings |
| 202 | .followedBy(optionalCodePointStrings) |
| 203 | .join(' ' ); |
| 204 | _logger.printTrace( |
| 205 | 'Running font-subset: ${cmd.join(' ' )}, ' |
| 206 | 'using codepoints $codePointsString' , |
| 207 | ); |
| 208 | final Process fontSubsetProcess = await _processManager.start(cmd); |
| 209 | try { |
| 210 | await ProcessUtils.writelnToStdinUnsafe( |
| 211 | stdin: fontSubsetProcess.stdin, |
| 212 | line: codePointsString, |
| 213 | ); |
| 214 | await fontSubsetProcess.stdin.flush(); |
| 215 | await fontSubsetProcess.stdin.close(); |
| 216 | } on Exception { |
| 217 | // handled by checking the exit code. |
| 218 | } |
| 219 | |
| 220 | final int code = await fontSubsetProcess.exitCode; |
| 221 | if (code != 0) { |
| 222 | _logger.printTrace(await utf8.decodeStream(fontSubsetProcess.stdout)); |
| 223 | _logger.printError(await utf8.decodeStream(fontSubsetProcess.stderr)); |
| 224 | throw IconTreeShakerException._('Font subsetting failed with exit code $code.' ); |
| 225 | } |
| 226 | _logger.printStatus(getSubsetSummaryMessage(input, _fs.file(outputPath))); |
| 227 | return true; |
| 228 | } |
| 229 | |
| 230 | @visibleForTesting |
| 231 | String getSubsetSummaryMessage(File inputFont, File outputFont) { |
| 232 | final String fontName = inputFont.basename; |
| 233 | final double inputSize = inputFont.lengthSync().toDouble(); |
| 234 | final double outputSize = outputFont.lengthSync().toDouble(); |
| 235 | final double reductionBytes = inputSize - outputSize; |
| 236 | final String reductionPercentage = (reductionBytes / inputSize * 100).toStringAsFixed(1); |
| 237 | return 'Font asset " $fontName" was tree-shaken, reducing it from ' |
| 238 | ' ${inputSize.ceil()} to ${outputSize.ceil()} bytes ' |
| 239 | '( $reductionPercentage% reduction). Tree-shaking can be disabled ' |
| 240 | 'by providing the --no-tree-shake-icons flag when building your app.' ; |
| 241 | } |
| 242 | |
| 243 | /// Returns a map of { fontFamily: relativePath } pairs. |
| 244 | Future<Map<String, String>> _parseFontJson(String fontManifestData, Set<String> families) async { |
| 245 | final result = <String, String>{}; |
| 246 | final List<Map<String, Object?>> fontList = _getList( |
| 247 | json.decode(fontManifestData), |
| 248 | 'FontManifest.json invalid: expected top level to be a list of objects.' , |
| 249 | ); |
| 250 | |
| 251 | for (final map in fontList) { |
| 252 | final Object? familyKey = map['family' ]; |
| 253 | if (familyKey is! String) { |
| 254 | throw IconTreeShakerException._( |
| 255 | 'FontManifest.json invalid: expected the family value to be a string, ' |
| 256 | 'got: ${map['family' ]}.' , |
| 257 | ); |
| 258 | } |
| 259 | if (!families.contains(familyKey)) { |
| 260 | continue; |
| 261 | } |
| 262 | final List<Map<String, Object?>> fonts = _getList( |
| 263 | map['fonts' ], |
| 264 | 'FontManifest.json invalid: expected "fonts" to be a list of objects.' , |
| 265 | ); |
| 266 | if (fonts.length != 1) { |
| 267 | throw IconTreeShakerException._( |
| 268 | 'This tool cannot process icon fonts with multiple fonts in a ' |
| 269 | 'single family.' , |
| 270 | ); |
| 271 | } |
| 272 | final Object? asset = fonts.first['asset' ]; |
| 273 | if (asset is! String) { |
| 274 | throw IconTreeShakerException._( |
| 275 | 'FontManifest.json invalid: expected "asset" value to be a string, ' |
| 276 | 'got: ${map['assets' ]}.' , |
| 277 | ); |
| 278 | } |
| 279 | result[familyKey] = asset; |
| 280 | } |
| 281 | return result; |
| 282 | } |
| 283 | |
| 284 | Future<Map<String, List<int>>> _findConstants(File dart, File constFinder, File appDill) async { |
| 285 | final cmd = <String>[ |
| 286 | dart.path, |
| 287 | constFinder.path, |
| 288 | '--kernel-file' , |
| 289 | appDill.path, |
| 290 | '--class-library-uri' , |
| 291 | 'package:flutter/src/widgets/icon_data.dart' , |
| 292 | '--class-name' , |
| 293 | 'IconData' , |
| 294 | '--annotation-class-name' , |
| 295 | '_StaticIconProvider' , |
| 296 | '--annotation-class-library-uri' , |
| 297 | 'package:flutter/src/widgets/icon_data.dart' , |
| 298 | ]; |
| 299 | _logger.printTrace('Running command: ${cmd.join(' ' )}' ); |
| 300 | final ProcessResult constFinderProcessResult = await _processManager.run(cmd); |
| 301 | |
| 302 | if (constFinderProcessResult.exitCode != 0) { |
| 303 | throw IconTreeShakerException._('ConstFinder failure: ${constFinderProcessResult.stderr}' ); |
| 304 | } |
| 305 | final Object? constFinderMap = json.decode(constFinderProcessResult.stdout as String); |
| 306 | if (constFinderMap is! Map<String, Object?>) { |
| 307 | throw IconTreeShakerException._( |
| 308 | 'Invalid ConstFinder output: expected a top level JSON object, ' |
| 309 | 'got $constFinderMap.' , |
| 310 | ); |
| 311 | } |
| 312 | final constFinderResult = _ConstFinderResult(constFinderMap); |
| 313 | if (constFinderResult.hasNonConstantLocations) { |
| 314 | _logger.printError( |
| 315 | 'This application cannot tree shake icons fonts. ' |
| 316 | 'It has non-constant instances of IconData at the ' |
| 317 | 'following locations:' , |
| 318 | emphasis: true, |
| 319 | ); |
| 320 | for (final Map<String, Object?> location in constFinderResult.nonConstantLocations) { |
| 321 | _logger.printError( |
| 322 | '- ${location['file' ]}: ${location['line' ]}: ${location['column' ]}' , |
| 323 | indent: 2, |
| 324 | hangingIndent: 4, |
| 325 | ); |
| 326 | } |
| 327 | throwToolExit( |
| 328 | 'Avoid non-constant invocations of IconData or try to ' |
| 329 | 'build again with --no-tree-shake-icons.' , |
| 330 | ); |
| 331 | } |
| 332 | return _parseConstFinderResult(constFinderResult); |
| 333 | } |
| 334 | |
| 335 | Map<String, List<int>> _parseConstFinderResult(_ConstFinderResult constants) { |
| 336 | final result = <String, List<int>>{}; |
| 337 | for (final Map<String, Object?> iconDataMap in constants.constantInstances) { |
| 338 | final Object? package = iconDataMap['fontPackage' ]; |
| 339 | final Object? fontFamily = iconDataMap['fontFamily' ]; |
| 340 | final Object? codePoint = iconDataMap['codePoint' ]; |
| 341 | if ((package ?? '' ) is! String || (fontFamily ?? '' ) is! String || codePoint is! num) { |
| 342 | throw IconTreeShakerException._( |
| 343 | 'Invalid ConstFinder result. Expected "fontPackage" to be a String, ' |
| 344 | '"fontFamily" to be a String, and "codePoint" to be an int, ' |
| 345 | 'got: $iconDataMap.' , |
| 346 | ); |
| 347 | } |
| 348 | if (fontFamily == null) { |
| 349 | _logger.printTrace( |
| 350 | 'Expected to find fontFamily for constant IconData with codepoint: ' |
| 351 | ' $codePoint, but found fontFamily: $fontFamily. This usually means ' |
| 352 | 'you are relying on the system font. Alternatively, font families in ' |
| 353 | 'an IconData class can be provided in the assets section of your ' |
| 354 | 'pubspec.yaml, or you are missing "uses-material-design: true".' , |
| 355 | ); |
| 356 | continue; |
| 357 | } |
| 358 | final family = fontFamily as String; |
| 359 | final key = package == null ? family : 'packages/ $package/ $family' ; |
| 360 | result[key] ??= <int>[]; |
| 361 | result[key]!.add(codePoint.round()); |
| 362 | } |
| 363 | return result; |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | class _ConstFinderResult { |
| 368 | _ConstFinderResult(this.result); |
| 369 | |
| 370 | final Map<String, Object?> result; |
| 371 | |
| 372 | late final List<Map<String, Object?>> constantInstances = _getList( |
| 373 | result['constantInstances' ], |
| 374 | 'Invalid ConstFinder output: Expected "constInstances" to be a list of objects.' , |
| 375 | ); |
| 376 | |
| 377 | late final List<Map<String, Object?>> nonConstantLocations = _getList( |
| 378 | result['nonConstantLocations' ], |
| 379 | 'Invalid ConstFinder output: Expected "nonConstLocations" to be a list of objects' , |
| 380 | ); |
| 381 | |
| 382 | bool get hasNonConstantLocations => nonConstantLocations.isNotEmpty; |
| 383 | } |
| 384 | |
| 385 | /// The font family name, relative path to font file, and list of code points |
| 386 | /// the application is using. |
| 387 | class _IconTreeShakerData { |
| 388 | /// All parameters are required. |
| 389 | const _IconTreeShakerData({ |
| 390 | required this.family, |
| 391 | required this.relativePath, |
| 392 | required this.codePoints, |
| 393 | required this.optionalCodePoints, |
| 394 | }); |
| 395 | |
| 396 | /// The font family name, e.g. "MaterialIcons". |
| 397 | final String family; |
| 398 | |
| 399 | /// The relative path to the font file. |
| 400 | final String relativePath; |
| 401 | |
| 402 | /// The list of code points for the font. |
| 403 | final List<int> codePoints; |
| 404 | |
| 405 | /// The list of code points to be optionally added, if they exist in the |
| 406 | /// input font. Otherwise, the tool will silently omit them. |
| 407 | final List<int> optionalCodePoints; |
| 408 | |
| 409 | @override |
| 410 | String toString() => 'FontSubsetData( $family, $relativePath, $codePoints)' ; |
| 411 | } |
| 412 | |
| 413 | class IconTreeShakerException implements Exception { |
| 414 | IconTreeShakerException._(this.message); |
| 415 | |
| 416 | final String message; |
| 417 | |
| 418 | @override |
| 419 | String toString() => |
| 420 | 'IconTreeShakerException: $message\n\n' |
| 421 | 'To disable icon tree shaking, pass --no-tree-shake-icons to the requested ' |
| 422 | 'flutter build command' ; |
| 423 | } |
| 424 | |