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
5import 'package:meta/meta.dart';
6import 'package:mime/mime.dart' as mime;
7import 'package:process/process.dart';
8
9import '../../artifacts.dart';
10import '../../base/common.dart';
11import '../../base/file_system.dart';
12import '../../base/io.dart';
13import '../../base/logger.dart';
14import '../../base/process.dart';
15import '../../build_info.dart';
16import '../../convert.dart';
17import '../../devfs.dart';
18import '../build_system.dart';
19
20List<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.
29class 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
367class _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.
387class _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
413class 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