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 Set<String> 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 List<Source> 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 Map<String, _IconTreeShakerData> result = <String, _IconTreeShakerData>{}; |
133 | const int 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 List<int> optionalCodePoints = |
144 | _targetPlatform == TargetPlatform.web_javascript ? <int>[kSpacePoint] : <int>[]; |
145 | result[entry.value] = _IconTreeShakerData( |
146 | family: entry.key, |
147 | relativePath: entry.value, |
148 | codePoints: codePoints, |
149 | optionalCodePoints: optionalCodePoints, |
150 | ); |
151 | } |
152 | _iconData = result; |
153 | } |
154 | |
155 | /// Calls font-subset, which transforms the [input] font file to a |
156 | /// subsetted version at [outputPath]. |
157 | /// |
158 | /// If [enabled] is false, or the relative path is not recognized as an icon |
159 | /// font used in the Flutter application, this returns false. |
160 | /// If the font-subset subprocess fails, it will [throwToolExit]. |
161 | /// Otherwise, it will return true. |
162 | Future<bool> subsetFont({ |
163 | required File input, |
164 | required String outputPath, |
165 | required String relativePath, |
166 | }) async { |
167 | if (!enabled) { |
168 | return false; |
169 | } |
170 | if (input.lengthSync() < 12) { |
171 | return false; |
172 | } |
173 | final String? mimeType = mime.lookupMimeType( |
174 | input.path, |
175 | headerBytes: await input.openRead(0, 12).first, |
176 | ); |
177 | if (!kTtfMimeTypes.contains(mimeType)) { |
178 | return false; |
179 | } |
180 | await (_iconDataProcessing ??= _getIconData(_environment)); |
181 | assert(_iconData != null); |
182 | |
183 | final _IconTreeShakerData? iconTreeShakerData = _iconData![relativePath]; |
184 | if (iconTreeShakerData == null) { |
185 | return false; |
186 | } |
187 | |
188 | final File fontSubset = _fs.file(_artifacts.getArtifactPath(Artifact.fontSubset)); |
189 | if (!fontSubset.existsSync()) { |
190 | throw IconTreeShakerException._('The font-subset utility is missing. Run "flutter doctor".' ); |
191 | } |
192 | |
193 | final List<String> cmd = <String>[fontSubset.path, outputPath, input.path]; |
194 | final Iterable<String> requiredCodePointStrings = iconTreeShakerData.codePoints.map( |
195 | (int codePoint) => codePoint.toString(), |
196 | ); |
197 | final Iterable<String> optionalCodePointStrings = iconTreeShakerData.optionalCodePoints.map( |
198 | (int codePoint) => 'optional: $codePoint' , |
199 | ); |
200 | final String codePointsString = requiredCodePointStrings |
201 | .followedBy(optionalCodePointStrings) |
202 | .join(' ' ); |
203 | _logger.printTrace( |
204 | 'Running font-subset: ${cmd.join(' ' )}, ' |
205 | 'using codepoints $codePointsString' , |
206 | ); |
207 | final Process fontSubsetProcess = await _processManager.start(cmd); |
208 | try { |
209 | await ProcessUtils.writelnToStdinUnsafe( |
210 | stdin: fontSubsetProcess.stdin, |
211 | line: codePointsString, |
212 | ); |
213 | await fontSubsetProcess.stdin.flush(); |
214 | await fontSubsetProcess.stdin.close(); |
215 | } on Exception { |
216 | // handled by checking the exit code. |
217 | } |
218 | |
219 | final int code = await fontSubsetProcess.exitCode; |
220 | if (code != 0) { |
221 | _logger.printTrace(await utf8.decodeStream(fontSubsetProcess.stdout)); |
222 | _logger.printError(await utf8.decodeStream(fontSubsetProcess.stderr)); |
223 | throw IconTreeShakerException._('Font subsetting failed with exit code $code.' ); |
224 | } |
225 | _logger.printStatus(getSubsetSummaryMessage(input, _fs.file(outputPath))); |
226 | return true; |
227 | } |
228 | |
229 | @visibleForTesting |
230 | String getSubsetSummaryMessage(File inputFont, File outputFont) { |
231 | final String fontName = inputFont.basename; |
232 | final double inputSize = inputFont.lengthSync().toDouble(); |
233 | final double outputSize = outputFont.lengthSync().toDouble(); |
234 | final double reductionBytes = inputSize - outputSize; |
235 | final String reductionPercentage = (reductionBytes / inputSize * 100).toStringAsFixed(1); |
236 | return 'Font asset " $fontName" was tree-shaken, reducing it from ' |
237 | ' ${inputSize.ceil()} to ${outputSize.ceil()} bytes ' |
238 | '( $reductionPercentage% reduction). Tree-shaking can be disabled ' |
239 | 'by providing the --no-tree-shake-icons flag when building your app.' ; |
240 | } |
241 | |
242 | /// Returns a map of { fontFamily: relativePath } pairs. |
243 | Future<Map<String, String>> _parseFontJson(String fontManifestData, Set<String> families) async { |
244 | final Map<String, String> result = <String, String>{}; |
245 | final List<Map<String, Object?>> fontList = _getList( |
246 | json.decode(fontManifestData), |
247 | 'FontManifest.json invalid: expected top level to be a list of objects.' , |
248 | ); |
249 | |
250 | for (final Map<String, Object?> map in fontList) { |
251 | final Object? familyKey = map['family' ]; |
252 | if (familyKey is! String) { |
253 | throw IconTreeShakerException._( |
254 | 'FontManifest.json invalid: expected the family value to be a string, ' |
255 | 'got: ${map['family' ]}.' , |
256 | ); |
257 | } |
258 | if (!families.contains(familyKey)) { |
259 | continue; |
260 | } |
261 | final List<Map<String, Object?>> fonts = _getList( |
262 | map['fonts' ], |
263 | 'FontManifest.json invalid: expected "fonts" to be a list of objects.' , |
264 | ); |
265 | if (fonts.length != 1) { |
266 | throw IconTreeShakerException._( |
267 | 'This tool cannot process icon fonts with multiple fonts in a ' |
268 | 'single family.' , |
269 | ); |
270 | } |
271 | final Object? asset = fonts.first['asset' ]; |
272 | if (asset is! String) { |
273 | throw IconTreeShakerException._( |
274 | 'FontManifest.json invalid: expected "asset" value to be a string, ' |
275 | 'got: ${map['assets' ]}.' , |
276 | ); |
277 | } |
278 | result[familyKey] = asset; |
279 | } |
280 | return result; |
281 | } |
282 | |
283 | Future<Map<String, List<int>>> _findConstants(File dart, File constFinder, File appDill) async { |
284 | final List<String> cmd = <String>[ |
285 | dart.path, |
286 | constFinder.path, |
287 | '--kernel-file' , |
288 | appDill.path, |
289 | '--class-library-uri' , |
290 | 'package:flutter/src/widgets/icon_data.dart' , |
291 | '--class-name' , |
292 | 'IconData' , |
293 | '--annotation-class-name' , |
294 | '_StaticIconProvider' , |
295 | '--annotation-class-library-uri' , |
296 | 'package:flutter/src/widgets/icon_data.dart' , |
297 | ]; |
298 | _logger.printTrace('Running command: ${cmd.join(' ' )}' ); |
299 | final ProcessResult constFinderProcessResult = await _processManager.run(cmd); |
300 | |
301 | if (constFinderProcessResult.exitCode != 0) { |
302 | throw IconTreeShakerException._('ConstFinder failure: ${constFinderProcessResult.stderr}' ); |
303 | } |
304 | final Object? constFinderMap = json.decode(constFinderProcessResult.stdout as String); |
305 | if (constFinderMap is! Map<String, Object?>) { |
306 | throw IconTreeShakerException._( |
307 | 'Invalid ConstFinder output: expected a top level JSON object, ' |
308 | 'got $constFinderMap.' , |
309 | ); |
310 | } |
311 | final _ConstFinderResult constFinderResult = _ConstFinderResult(constFinderMap); |
312 | if (constFinderResult.hasNonConstantLocations) { |
313 | _logger.printError( |
314 | 'This application cannot tree shake icons fonts. ' |
315 | 'It has non-constant instances of IconData at the ' |
316 | 'following locations:' , |
317 | emphasis: true, |
318 | ); |
319 | for (final Map<String, Object?> location in constFinderResult.nonConstantLocations) { |
320 | _logger.printError( |
321 | '- ${location['file' ]}: ${location['line' ]}: ${location['column' ]}' , |
322 | indent: 2, |
323 | hangingIndent: 4, |
324 | ); |
325 | } |
326 | throwToolExit( |
327 | 'Avoid non-constant invocations of IconData or try to ' |
328 | 'build again with --no-tree-shake-icons.' , |
329 | ); |
330 | } |
331 | return _parseConstFinderResult(constFinderResult); |
332 | } |
333 | |
334 | Map<String, List<int>> _parseConstFinderResult(_ConstFinderResult constants) { |
335 | final Map<String, List<int>> result = <String, List<int>>{}; |
336 | for (final Map<String, Object?> iconDataMap in constants.constantInstances) { |
337 | final Object? package = iconDataMap['fontPackage' ]; |
338 | final Object? fontFamily = iconDataMap['fontFamily' ]; |
339 | final Object? codePoint = iconDataMap['codePoint' ]; |
340 | if ((package ?? '' ) is! String || (fontFamily ?? '' ) is! String || codePoint is! num) { |
341 | throw IconTreeShakerException._( |
342 | 'Invalid ConstFinder result. Expected "fontPackage" to be a String, ' |
343 | '"fontFamily" to be a String, and "codePoint" to be an int, ' |
344 | 'got: $iconDataMap.' , |
345 | ); |
346 | } |
347 | if (fontFamily == null) { |
348 | _logger.printTrace( |
349 | 'Expected to find fontFamily for constant IconData with codepoint: ' |
350 | ' $codePoint, but found fontFamily: $fontFamily. This usually means ' |
351 | 'you are relying on the system font. Alternatively, font families in ' |
352 | 'an IconData class can be provided in the assets section of your ' |
353 | 'pubspec.yaml, or you are missing "uses-material-design: true".' , |
354 | ); |
355 | continue; |
356 | } |
357 | final String family = fontFamily as String; |
358 | final String key = package == null ? family : 'packages/ $package/ $family' ; |
359 | result[key] ??= <int>[]; |
360 | result[key]!.add(codePoint.round()); |
361 | } |
362 | return result; |
363 | } |
364 | } |
365 | |
366 | class _ConstFinderResult { |
367 | _ConstFinderResult(this.result); |
368 | |
369 | final Map<String, Object?> result; |
370 | |
371 | late final List<Map<String, Object?>> constantInstances = _getList( |
372 | result['constantInstances' ], |
373 | 'Invalid ConstFinder output: Expected "constInstances" to be a list of objects.' , |
374 | ); |
375 | |
376 | late final List<Map<String, Object?>> nonConstantLocations = _getList( |
377 | result['nonConstantLocations' ], |
378 | 'Invalid ConstFinder output: Expected "nonConstLocations" to be a list of objects' , |
379 | ); |
380 | |
381 | bool get hasNonConstantLocations => nonConstantLocations.isNotEmpty; |
382 | } |
383 | |
384 | /// The font family name, relative path to font file, and list of code points |
385 | /// the application is using. |
386 | class _IconTreeShakerData { |
387 | /// All parameters are required. |
388 | const _IconTreeShakerData({ |
389 | required this.family, |
390 | required this.relativePath, |
391 | required this.codePoints, |
392 | required this.optionalCodePoints, |
393 | }); |
394 | |
395 | /// The font family name, e.g. "MaterialIcons". |
396 | final String family; |
397 | |
398 | /// The relative path to the font file. |
399 | final String relativePath; |
400 | |
401 | /// The list of code points for the font. |
402 | final List<int> codePoints; |
403 | |
404 | /// The list of code points to be optionally added, if they exist in the |
405 | /// input font. Otherwise, the tool will silently omit them. |
406 | final List<int> optionalCodePoints; |
407 | |
408 | @override |
409 | String toString() => 'FontSubsetData( $family, $relativePath, $codePoints)' ; |
410 | } |
411 | |
412 | class IconTreeShakerException implements Exception { |
413 | IconTreeShakerException._(this.message); |
414 | |
415 | final String message; |
416 | |
417 | @override |
418 | String toString() => |
419 | 'IconTreeShakerException: $message\n\n' |
420 | 'To disable icon tree shaking, pass --no-tree-shake-icons to the requested ' |
421 | 'flutter build command' ; |
422 | } |
423 | |