|
| 1 | +import 'dart:convert'; |
| 2 | +import 'dart:io'; |
| 3 | + |
| 4 | +import 'package:dartx/dartx_io.dart'; |
| 5 | +import 'package:flutter/material.dart'; |
| 6 | +import 'package:flutter/services.dart'; |
| 7 | +import 'package:flutter_test/flutter_test.dart'; |
| 8 | +import 'package:spot/src/flutter/flutter_sdk.dart'; |
| 9 | + |
| 10 | +/// Loads all font from the apps FontManifest and embedded in the Flutter SDK |
| 11 | +/// |
| 12 | +/// ## What is loaded? |
| 13 | +/// ### App Fonts (FontManifest) |
| 14 | +/// - All fonts defined in the pubspec.yaml |
| 15 | +/// - All fonts of dependencies that define fonts in their pubspec.yaml |
| 16 | +/// |
| 17 | +/// ### Embedded Flutter SDK Fonts |
| 18 | +/// - Roboto |
| 19 | +/// - RobotoCondensed |
| 20 | +/// - MaterialIcons |
| 21 | +/// |
| 22 | +/// ## Why load Roboto by default? |
| 23 | +/// |
| 24 | +/// Widget test run with [TargetPlatform.android] by default. [MaterialApp] sets |
| 25 | +/// the Roboto fontFamily as default for [TargetPlatform.android] (see |
| 26 | +/// [Typography]). Loading the Roboto fontFamily therefore allows showing text |
| 27 | +/// in the default scenario of a Flutter app. |
| 28 | +/// Fortunately, Robot is part of the Flutter SDK and can be loaded right away. |
| 29 | +/// |
| 30 | +/// ## Custom fonts |
| 31 | +/// |
| 32 | +/// Apps that use custom fonts, should declare them in the pubspec.yaml file (https://docs.flutter.dev/cookbook/design/fonts#declare-the-font-in-the-pubspec-yaml-file). |
| 33 | +/// Those fonts are automatically added to the FontManifest.json file during build. |
| 34 | +/// |
| 35 | +/// The [loadAppFonts] function loads all font defined in the FontManifest.json file. |
| 36 | +/// |
| 37 | +/// ## Depending on System fonts |
| 38 | +/// |
| 39 | +/// Some apps do not ship their fonts, but use a system font e.g. "Segoe UI" |
| 40 | +/// on [TargetPlatform.windows] or "Apple Color Emoji" on [TargetPlatform.iOS]. |
| 41 | +/// |
| 42 | +/// Those system fonts are not loaded by [loadAppFonts], load them individually |
| 43 | +/// with [loadFont]. |
| 44 | +/// |
| 45 | +/// ## Emojis |
| 46 | +/// |
| 47 | +/// Why are emojis not rendered after calling [loadAppFonts]? |
| 48 | +/// |
| 49 | +/// Emojis are not part of the Roboto font. |
| 50 | +/// Each operating system provides their own font that handles |
| 51 | +/// emoji glyphs. In Flutter apps, those emoji fonts are automatically loaded |
| 52 | +/// by Skia (the rendering engine of Flutter) from the operating system as fallbacks |
| 53 | +/// when it encounters an emoji character that is covered by the defined |
| 54 | +/// fontFamily or fontFamilyFallback. |
| 55 | +/// |
| 56 | +/// Flutter tests disable the automatic system font loading by Skia. Skia will |
| 57 | +/// not search for system fonts. (https://github.com/flutter/engine/blob/a842207f6d90de4fc006ea8f0b649b38d6e104a0/lib/ui/text/font_collection.cc#L148) |
| 58 | +/// |
| 59 | +/// To show emojis in tests, load the system emoji font manually with [loadFont]. |
| 60 | +/// E.g. "/System/Library/Fonts/Apple Color Emoji.ttc" on macOS. |
| 61 | +/// Do not forget to set "Apple Color Emoji" as fontFamilyFallback. Skia will |
| 62 | +/// *not* automatically fallback to "Apple Color Emoji" unless it is defined in |
| 63 | +/// the TextStyle. |
| 64 | +/// |
| 65 | +/// Because showing emojis in test requires changes to you app code (set fallback) |
| 66 | +/// [loadAppFonts] does not automatically load system emoji fonts for you. |
| 67 | +Future<void> loadAppFonts() async { |
| 68 | + TestWidgetsFlutterBinding.ensureInitialized(); |
| 69 | + |
| 70 | + await TestAsyncUtils.guard<void>(() async { |
| 71 | + // First we load the Roboto font from the Flutter SDK, which most Android apps use. |
| 72 | + // In case the app defines a custom Roboto fontFamily it will be overwritten when |
| 73 | + // loading the fonts from the manifest |
| 74 | + await _loadMaterialFontsFromSdk(); |
| 75 | + |
| 76 | + // Load all fonts defined in the FontManifest.json file |
| 77 | + await _loadFontsFromFontManifest(); |
| 78 | + }); |
| 79 | +} |
| 80 | + |
| 81 | +/// Loads a fontFamily consisting of multiple font files. |
| 82 | +/// |
| 83 | +/// ```dart |
| 84 | +/// debugDefaultTargetPlatformOverride = TargetPlatform.windows; |
| 85 | +/// await loadFont('Comic Sans', [ |
| 86 | +/// r'C:\Windows\Fonts\comic.ttf', // Regular |
| 87 | +/// r'C:\Windows\Fonts\comicbd.ttf', // Bold |
| 88 | +/// r'C:\Windows\Fonts\comici.ttf', // Italic |
| 89 | +/// ]); |
| 90 | +/// |
| 91 | +/// tester.pumpWidget( |
| 92 | +/// MaterialApp( |
| 93 | +/// home: Center( |
| 94 | +/// child: Text( |
| 95 | +/// 'Loaded custom Font', |
| 96 | +/// style: TextStyle( |
| 97 | +/// fontFamily: 'Comic Sans', |
| 98 | +/// ), |
| 99 | +/// ), |
| 100 | +/// ), |
| 101 | +/// ), |
| 102 | +/// ); |
| 103 | +/// ``` |
| 104 | +/// |
| 105 | +/// Flutter support the following formats: .ttf, .otf, .ttc |
| 106 | +/// |
| 107 | +/// Calling [loadFont] multiple times with the same family will overwrites the |
| 108 | +/// previous |
| 109 | +/// |
| 110 | +/// The [family] is optional: '' will extract the family name from the font file. |
| 111 | +Future<void> loadFont(String family, List<String> fontPaths) async { |
| 112 | + if (fontPaths.isEmpty) { |
| 113 | + return; |
| 114 | + } |
| 115 | + |
| 116 | + await TestAsyncUtils.guard<void>(() async { |
| 117 | + final fontLoader = FontLoader(family); |
| 118 | + for (final path in fontPaths) { |
| 119 | + try { |
| 120 | + final file = File(path); |
| 121 | + if (file.existsSync()) { |
| 122 | + final Uint8List bytes = file.readAsBytesSync(); |
| 123 | + fontLoader.addFont(Future.value(bytes.buffer.asByteData())); |
| 124 | + } else { |
| 125 | + final data = rootBundle.load(path); |
| 126 | + fontLoader.addFont(Future.value(data)); |
| 127 | + } |
| 128 | + } catch (e, stack) { |
| 129 | + debugPrint("Could not load font $path\n$e\n$stack"); |
| 130 | + } |
| 131 | + } |
| 132 | + // the fontLoader is unusable after calling load(). |
| 133 | + // No need to cache or return it. |
| 134 | + await fontLoader.load(); |
| 135 | + }); |
| 136 | +} |
| 137 | + |
| 138 | +/// Loads the Roboto/RobotoCondensed/MaterialIcons fonts from the executing Flutter SDK |
| 139 | +Future<void> _loadMaterialFontsFromSdk() async { |
| 140 | + final root = flutterSdkRoot().absolute.path; |
| 141 | + |
| 142 | + final materialFontsDir = |
| 143 | + Directory('$root/bin/cache/artifacts/material_fonts/'); |
| 144 | + |
| 145 | + final fontFormats = ['.ttf', '.otf', '.ttc']; |
| 146 | + final existingFonts = materialFontsDir |
| 147 | + .listSync() |
| 148 | + // dartfmt come on,... |
| 149 | + .whereType<File>() |
| 150 | + .where( |
| 151 | + (font) => fontFormats.any((element) => font.path.endsWith(element)), |
| 152 | + ) |
| 153 | + .toList(); |
| 154 | + |
| 155 | + final robotoFonts = existingFonts |
| 156 | + .where((font) { |
| 157 | + final name = font.name.toLowerCase(); |
| 158 | + return name.startsWith('Roboto-'.toLowerCase()); |
| 159 | + }) |
| 160 | + .map((file) => file.path) |
| 161 | + .toList(); |
| 162 | + if (robotoFonts.isEmpty) { |
| 163 | + debugPrint("Warning: No Roboto font found in SDK"); |
| 164 | + } |
| 165 | + await loadFont('Roboto', robotoFonts); |
| 166 | + |
| 167 | + final robotoCondensedFonts = existingFonts |
| 168 | + .where((font) { |
| 169 | + final name = font.name.toLowerCase(); |
| 170 | + return name.startsWith('RobotoCondensed-'.toLowerCase()); |
| 171 | + }) |
| 172 | + .map((file) => file.path) |
| 173 | + .toList(); |
| 174 | + await loadFont('RobotoCondensed', robotoCondensedFonts); |
| 175 | + |
| 176 | + final materialIcons = existingFonts |
| 177 | + .where((font) { |
| 178 | + final name = font.name.toLowerCase(); |
| 179 | + return name.startsWith('MaterialIcons-'.toLowerCase()); |
| 180 | + }) |
| 181 | + .map((file) => file.path) |
| 182 | + .toList(); |
| 183 | + await loadFont('MaterialIcons', materialIcons); |
| 184 | +} |
| 185 | + |
| 186 | +/// Loads the fonts from the FontManifest.json file. |
| 187 | +/// |
| 188 | +/// Fonts defined in an app are accessible via it family name "MyFont" |
| 189 | +/// Fonts defined in a package are accessible via "packages/myPackage/MyFont" |
| 190 | +/// |
| 191 | +/// Because each app can also be a package, each font is available with both |
| 192 | +/// notations. |
| 193 | +/// This allows packages to access their own fonts also via |
| 194 | +/// "packages/myPackage/MyFont" like users of the package would. |
| 195 | +Future<void> _loadFontsFromFontManifest() async { |
| 196 | + // The FontManifest.json file is generated by the Flutter build process |
| 197 | + // located in /build/flutter_assets/FontManifest.json and bundled within the app |
| 198 | + final binding = TestWidgetsFlutterBinding.instance; |
| 199 | + final fontManifestContent = |
| 200 | + await binding.runAsync(() => rootBundle.loadString('FontManifest.json')); |
| 201 | + final json = jsonDecode(fontManifestContent!); |
| 202 | + final fontManifest = _FontManifest.fromJson(json); |
| 203 | + |
| 204 | + for (final item in fontManifest.fontFamilies) { |
| 205 | + final packageAsset = |
| 206 | + item.assets.firstOrNullWhere((it) => it.startsWith('packages/')); |
| 207 | + final packageName = packageAsset?.split('/')[1]; |
| 208 | + |
| 209 | + if (packageName == null) { |
| 210 | + // font asset in pubspec.yaml references a file relative to the pubspec.yaml |
| 211 | + // The font can not be used by other packages |
| 212 | + await loadFont(item.family, item.assets); |
| 213 | + } else { |
| 214 | + // font uses the package notation, which resolves relative to the packages lib/* directory |
| 215 | + // asset: packages/<packageName>/<somewhereInsideLib>/MyFont.ttf |
| 216 | + |
| 217 | + // Make it accessible as "MyFont" to be used by the package itself |
| 218 | + final fontFamilyName = item.family.split('/').last; |
| 219 | + await loadFont(fontFamilyName, item.assets); |
| 220 | + // and "packages/<packageName>/MyFont" so that other packages would reference it. |
| 221 | + await loadFont('packages/$packageName/$fontFamilyName', item.assets); |
| 222 | + } |
| 223 | + } |
| 224 | +} |
| 225 | + |
| 226 | +/// Parsed representation of the FontManifest.json file |
| 227 | +class _FontManifest { |
| 228 | + final List<_FontManifestFontFamily> fontFamilies; |
| 229 | + |
| 230 | + /// Represents a Flutter FontManifest |
| 231 | + _FontManifest(this.fontFamilies); |
| 232 | + |
| 233 | + /// Parses the FontManifest.json file |
| 234 | + /// |
| 235 | + /// Example: |
| 236 | + /// ```json |
| 237 | + /// [ |
| 238 | + /// { |
| 239 | + /// "family": "packages/app_font/Montserrat", |
| 240 | + /// "fonts": [ |
| 241 | + /// { |
| 242 | + /// "asset": "packages/app_font/fonts/Montserrat-Regular.ttf" |
| 243 | + /// } |
| 244 | + /// ] |
| 245 | + /// } |
| 246 | + /// ] |
| 247 | + /// ``` |
| 248 | + factory _FontManifest.fromJson(dynamic json) { |
| 249 | + if (json is! List) { |
| 250 | + throw const FormatException('FontManifest must begin with a List'); |
| 251 | + } |
| 252 | + final List<_FontManifestFontFamily> fontFamilies = []; |
| 253 | + for (final family in json) { |
| 254 | + if (family is! Map) continue; |
| 255 | + final familyName = family['family']; |
| 256 | + if (familyName is! String) continue; |
| 257 | + final List<String> assets = []; |
| 258 | + final fonts = family['fonts']; |
| 259 | + if (fonts is! List) continue; |
| 260 | + for (final font in fonts) { |
| 261 | + if (font is! Map) continue; |
| 262 | + final asset = font['asset']; |
| 263 | + if (asset is! String) continue; |
| 264 | + // there are other values like weight and style, but those are ignored by Flutter |
| 265 | + // https://github.com/flutter/website/issues/3591#issuecomment-521806077 |
| 266 | + assets.add(asset); |
| 267 | + } |
| 268 | + fontFamilies.add(_FontManifestFontFamily(familyName, assets)); |
| 269 | + } |
| 270 | + return _FontManifest(fontFamilies); |
| 271 | + } |
| 272 | +} |
| 273 | + |
| 274 | +class _FontManifestFontFamily { |
| 275 | + final String family; |
| 276 | + final List<String> assets; |
| 277 | + |
| 278 | + _FontManifestFontFamily(this.family, this.assets); |
| 279 | +} |
0 commit comments