Skip to content

Commit d8648f4

Browse files
robinesspasssy
andauthored
Add loadAppFonts (#66)
Co-authored-by: Pascal Welsch <pascal@phntm.xyz> Co-authored-by: Pascal Welsch <pascal@welsch.dev>
1 parent dbeefc4 commit d8648f4

21 files changed

Lines changed: 1865 additions & 37 deletions

.github/workflows/test.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ jobs:
2323
channel: any
2424
- run: flutter pub get
2525
- run: flutter test
26+
- name: Archive golden test errors
27+
if: failure()
28+
uses: actions/upload-artifact@v4
29+
with:
30+
name: 3-10-failed
31+
path: test/
32+
retention-days: 7
2633

2734
test_channel:
2835
timeout-minutes: 10
@@ -39,3 +46,29 @@ jobs:
3946
channel: ${{ matrix.version }}
4047
- run: flutter pub get
4148
- run: flutter test
49+
- name: Archive golden test errors
50+
if: failure()
51+
uses: actions/upload-artifact@v4
52+
with:
53+
name: branches-tests-failed
54+
path: test/
55+
retention-days: 7
56+
57+
test_windows:
58+
timeout-minutes: 10
59+
runs-on: windows-latest
60+
61+
steps:
62+
- uses: actions/checkout@v3
63+
- uses: subosito/flutter-action@v2
64+
with:
65+
channel: stable
66+
- run: flutter pub get
67+
- run: flutter test
68+
- name: Archive golden test errors
69+
if: failure()
70+
uses: actions/upload-artifact@v4
71+
with:
72+
name: branches-tests-failed
73+
path: test/
74+
retention-days: 7

lib/spot.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export 'package:checks/context.dart'
1717
show Condition, Context, ContextExtension, Extracted, Rejection, Subject;
1818
export 'package:meta/meta.dart' show useResult;
1919
export 'package:spot/src/act/act.dart' show Act, act;
20+
export 'package:spot/src/screenshot/load_fonts.dart'
21+
show loadAppFonts, loadFont;
2022
export 'package:spot/src/screenshot/screenshot.dart'
2123
show
2224
ElementScreenshotExtension,

lib/src/flutter/flutter_sdk.dart

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import 'dart:io';
2+
import 'package:dartx/dartx_io.dart';
3+
4+
/// Returns the Flutter SDK root directory based on the current flutter
5+
/// executable running the tests.
6+
Directory flutterSdkRoot() {
7+
final flutterTesterExe = Platform.executable;
8+
final String flutterRoot;
9+
if (Platform.isWindows) {
10+
flutterRoot = flutterTesterExe.split(r'\bin\cache\')[0];
11+
} else {
12+
flutterRoot = flutterTesterExe.split('/bin/cache/')[0];
13+
}
14+
return Directory(flutterRoot);
15+
}
16+
17+
/// The Flutter executable in the Flutter SDK
18+
String get flutterExe {
19+
final exe = Platform.isWindows ? '.bat' : '';
20+
return flutterSdkRoot().file('bin/flutter$exe').absolute.path;
21+
}

lib/src/screenshot/load_fonts.dart

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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

Comments
 (0)