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 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
366class _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.
386class _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
412class 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

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com