blob: 8becc63dd74357269c3e344d7ea1b3896dfa396f [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:meta/meta.dart';
import 'package:process/process.dart';
import 'package:mime/mime.dart' as mime;
import '../../artifacts.dart';
import '../../base/common.dart';
import '../../base/file_system.dart';
import '../../base/io.dart';
import '../../base/logger.dart';
import '../../convert.dart';
import '../../devfs.dart';
import '../build_system.dart';
import 'common.dart';
/// The build define controlling whether icon fonts should be stripped down to
/// only the glyphs used by the application.
const String kIconTreeShakerFlag = 'TreeShakeIcons';
/// Whether icon font subsetting is enabled by default.
const bool kIconTreeShakerEnabledDefault = true;
List<Map<String, dynamic>> _getList(dynamic object, String errorMessage) {
if (object is List<dynamic>) {
return object.cast<Map<String, dynamic>>();
}
throw IconTreeShakerException._(errorMessage);
}
/// A class that wraps the functionality of the const finder package and the
/// font subset utility to tree shake unused icons from fonts.
class IconTreeShaker {
/// Creates a wrapper for icon font subsetting.
///
/// The environment parameter must not be null.
///
/// If the `fontManifest` parameter is null, [enabled] will return false since
/// there are no fonts to shake.
///
/// The constructor will validate the environment and print a warning if
/// font subsetting has been requested in a debug build mode.
IconTreeShaker(
this._environment,
DevFSStringContent fontManifest, {
@required ProcessManager processManager,
@required Logger logger,
@required FileSystem fileSystem,
@required Artifacts artifacts,
}) : assert(_environment != null),
assert(processManager != null),
assert(logger != null),
assert(fileSystem != null),
assert(artifacts != null),
_processManager = processManager,
_logger = logger,
_fs = fileSystem,
_artifacts = artifacts,
_fontManifest = fontManifest?.string {
if (_environment.defines[kIconTreeShakerFlag] == 'true' &&
_environment.defines[kBuildMode] == 'debug') {
logger.printError('Font subsetting is not supported in debug mode. The '
'--tree-shake-icons flag will be ignored.');
}
}
/// The MIME types for supported font sets.
static const Set<String> kTtfMimeTypes = <String>{
'font/ttf', // based on internet search
'font/opentype',
'font/otf',
'application/x-font-opentype',
'application/x-font-otf',
'application/x-font-ttf', // based on running locally.
};
/// The [Source] inputs that targets using this should depend on.
///
/// See [Target.inputs].
static const List<Source> inputs = <Source>[
Source.pattern('{FLUTTER_ROOT}/packages/flutter_tools/lib/src/build_system/targets/icon_tree_shaker.dart'),
Source.artifact(Artifact.constFinder),
Source.artifact(Artifact.fontSubset),
];
final Environment _environment;
final String _fontManifest;
Future<void> _iconDataProcessing;
Map<String, _IconTreeShakerData> _iconData;
final ProcessManager _processManager;
final Logger _logger;
final FileSystem _fs;
final Artifacts _artifacts;
/// Whether font subsetting should be used for this [Environment].
bool get enabled => _fontManifest != null
&& _environment.defines[kIconTreeShakerFlag] == 'true'
&& _environment.defines[kBuildMode] != 'debug';
// Fills the [_iconData] map.
Future<void> _getIconData(Environment environment) async {
if (!enabled) {
return;
}
final File appDill = environment.buildDir.childFile('app.dill');
if (!appDill.existsSync()) {
throw IconTreeShakerException._('Expected to find kernel file at ${appDill.path}, but no file found.');
}
final File constFinder = _fs.file(
_artifacts.getArtifactPath(Artifact.constFinder),
);
final File dart = _fs.file(
_artifacts.getArtifactPath(Artifact.engineDartBinary),
);
final Map<String, List<int>> iconData = await _findConstants(
dart,
constFinder,
appDill,
);
final Set<String> familyKeys = iconData.keys.toSet();
final Map<String, String> fonts = await _parseFontJson(
_fontManifest,
familyKeys,
);
if (fonts.length != iconData.length) {
environment.logger.printStatus(
'Expected to find fonts for ${iconData.keys}, but found '
'${fonts.keys}. This usually means you are referring to '
'font families in an IconData class but not including them '
'in the assets section of your pubspec.yaml, are missing '
'the package that would include them, or are missing '
'"uses-material-design: true".',
);
}
final Map<String, _IconTreeShakerData> result = <String, _IconTreeShakerData>{};
for (final MapEntry<String, String> entry in fonts.entries) {
result[entry.value] = _IconTreeShakerData(
family: entry.key,
relativePath: entry.value,
codePoints: iconData[entry.key],
);
}
_iconData = result;
}
/// Calls font-subset, which transforms the [input] font file to a
/// subsetted version at [outputPath].
///
/// All parameters are required.
///
/// If [enabled] is false, or the relative path is not recognized as an icon
/// font used in the Flutter application, this returns false.
/// If the font-subset subprocess fails, it will [throwToolExit].
/// Otherwise, it will return true.
Future<bool> subsetFont({
@required File input,
@required String outputPath,
@required String relativePath,
}) async {
if (!enabled) {
return false;
}
if (input.lengthSync() < 12) {
return false;
}
final String mimeType = mime.lookupMimeType(
input.path,
headerBytes: await input.openRead(0, 12).first,
);
if (!kTtfMimeTypes.contains(mimeType)) {
return false;
}
await (_iconDataProcessing ??= _getIconData(_environment));
assert(_iconData != null);
final _IconTreeShakerData iconTreeShakerData = _iconData[relativePath];
if (iconTreeShakerData == null) {
return false;
}
final File fontSubset = _fs.file(
_artifacts.getArtifactPath(Artifact.fontSubset),
);
if (!fontSubset.existsSync()) {
throw IconTreeShakerException._('The font-subset utility is missing. Run "flutter doctor".');
}
final List<String> cmd = <String>[
fontSubset.path,
outputPath,
input.path,
];
final String codePoints = iconTreeShakerData.codePoints.join(' ');
_logger.printTrace('Running font-subset: ${cmd.join(' ')}, '
'using codepoints $codePoints');
final Process fontSubsetProcess = await _processManager.start(cmd);
try {
fontSubsetProcess.stdin.writeln(codePoints);
await fontSubsetProcess.stdin.flush();
await fontSubsetProcess.stdin.close();
} on Exception {
// handled by checking the exit code.
}
final int code = await fontSubsetProcess.exitCode;
if (code != 0) {
_logger.printTrace(await utf8.decodeStream(fontSubsetProcess.stdout));
_logger.printError(await utf8.decodeStream(fontSubsetProcess.stderr));
throw IconTreeShakerException._('Font subsetting failed with exit code $code.');
}
return true;
}
/// Returns a map of { fontFamily: relativePath } pairs.
Future<Map<String, String>> _parseFontJson(
String fontManifestData,
Set<String> families,
) async {
final Map<String, String> result = <String, String>{};
final List<Map<String, dynamic>> fontList = _getList(
json.decode(fontManifestData),
'FontManifest.json invalid: expected top level to be a list of objects.',
);
for (final Map<String, dynamic> map in fontList) {
if (map['family'] is! String) {
throw IconTreeShakerException._(
'FontManifest.json invalid: expected the family value to be a string, '
'got: ${map['family']}.');
}
final String familyKey = map['family'] as String;
if (!families.contains(familyKey)) {
continue;
}
final List<Map<String, dynamic>> fonts = _getList(
map['fonts'],
'FontManifest.json invalid: expected "fonts" to be a list of objects.',
);
if (fonts.length != 1) {
throw IconTreeShakerException._(
'This tool cannot process icon fonts with multiple fonts in a '
'single family.');
}
if (fonts.first['asset'] is! String) {
throw IconTreeShakerException._(
'FontManifest.json invalid: expected "asset" value to be a string, '
'got: ${map['assets']}.');
}
result[familyKey] = fonts.first['asset'] as String;
}
return result;
}
Future<Map<String, List<int>>> _findConstants(
File dart,
File constFinder,
File appDill,
) async {
final List<String> cmd = <String>[
dart.path,
'--disable-dart-dev',
constFinder.path,
'--kernel-file', appDill.path,
'--class-library-uri', 'package:flutter/src/widgets/icon_data.dart',
'--class-name', 'IconData',
];
_logger.printTrace('Running command: ${cmd.join(' ')}');
final ProcessResult constFinderProcessResult = await _processManager.run(cmd);
if (constFinderProcessResult.exitCode != 0) {
throw IconTreeShakerException._('ConstFinder failure: ${constFinderProcessResult.stderr}');
}
final dynamic jsonDecode = json.decode(constFinderProcessResult.stdout as String);
if (jsonDecode is! Map<String, dynamic>) {
throw IconTreeShakerException._(
'Invalid ConstFinder output: expected a top level JSON object, '
'got $jsonDecode.');
}
final Map<String, dynamic> constFinderMap = jsonDecode as Map<String, dynamic>;
final _ConstFinderResult constFinderResult = _ConstFinderResult(constFinderMap);
if (constFinderResult.hasNonConstantLocations) {
_logger.printError('This application cannot tree shake icons fonts. '
'It has non-constant instances of IconData at the '
'following locations:', emphasis: true);
for (final Map<String, dynamic> location in constFinderResult.nonConstantLocations) {
_logger.printError(
'- ${location['file']}:${location['line']}:${location['column']}',
indent: 2,
hangingIndent: 4,
);
}
throwToolExit('Avoid non-constant invocations of IconData or try to '
'build again with --no-tree-shake-icons.');
}
return _parseConstFinderResult(constFinderResult);
}
Map<String, List<int>> _parseConstFinderResult(_ConstFinderResult constants) {
final Map<String, List<int>> result = <String, List<int>>{};
for (final Map<String, dynamic> iconDataMap in constants.constantInstances) {
if ((iconDataMap['fontPackage'] ?? '') is! String || // Null is ok here.
iconDataMap['fontFamily'] is! String ||
iconDataMap['codePoint'] is! num) {
throw IconTreeShakerException._(
'Invalid ConstFinder result. Expected "fontPackage" to be a String, '
'"fontFamily" to be a String, and "codePoint" to be an int, '
'got: $iconDataMap.');
}
final String package = iconDataMap['fontPackage'] as String;
final String family = iconDataMap['fontFamily'] as String;
final String key = package == null
? family
: 'packages/$package/$family';
result[key] ??= <int>[];
result[key].add((iconDataMap['codePoint'] as num).round());
}
return result;
}
}
class _ConstFinderResult {
_ConstFinderResult(this.result);
final Map<String, dynamic> result;
List<Map<String, dynamic>> _constantInstances;
List<Map<String, dynamic>> get constantInstances {
_constantInstances ??= _getList(
result['constantInstances'],
'Invalid ConstFinder output: Expected "constInstances" to be a list of objects.',
);
return _constantInstances;
}
List<Map<String, dynamic>> _nonConstantLocations;
List<Map<String, dynamic>> get nonConstantLocations {
_nonConstantLocations ??= _getList(
result['nonConstantLocations'],
'Invalid ConstFinder output: Expected "nonConstLocations" to be a list of objects',
);
return _nonConstantLocations;
}
bool get hasNonConstantLocations => nonConstantLocations.isNotEmpty;
}
/// The font family name, relative path to font file, and list of code points
/// the application is using.
class _IconTreeShakerData {
/// All parameters are required.
const _IconTreeShakerData({
@required this.family,
@required this.relativePath,
@required this.codePoints,
}) : assert(family != null),
assert(relativePath != null),
assert(codePoints != null);
/// The font family name, e.g. "MaterialIcons".
final String family;
/// The relative path to the font file.
final String relativePath;
/// The list of code points for the font.
final List<int> codePoints;
@override
String toString() => 'FontSubsetData($family, $relativePath, $codePoints)';
}
class IconTreeShakerException implements Exception {
IconTreeShakerException._(this.message);
final String message;
@override
String toString() => 'IconTreeShakerException: $message\n\n'
'To disable icon tree shaking, pass --no-tree-shake-icons to the requested '
'flutter build command';
}