Skip to content

Commit

Permalink
Add loadAppFonts (#66)
Browse files Browse the repository at this point in the history
Co-authored-by: Pascal Welsch <pascal@phntm.xyz>
Co-authored-by: Pascal Welsch <pascal@welsch.dev>
  • Loading branch information
3 people authored Nov 21, 2024
1 parent dbeefc4 commit d8648f4
Show file tree
Hide file tree
Showing 21 changed files with 1,865 additions and 37 deletions.
33 changes: 33 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ jobs:
channel: any
- run: flutter pub get
- run: flutter test
- name: Archive golden test errors
if: failure()
uses: actions/upload-artifact@v4
with:
name: 3-10-failed
path: test/
retention-days: 7

test_channel:
timeout-minutes: 10
Expand All @@ -39,3 +46,29 @@ jobs:
channel: ${{ matrix.version }}
- run: flutter pub get
- run: flutter test
- name: Archive golden test errors
if: failure()
uses: actions/upload-artifact@v4
with:
name: branches-tests-failed
path: test/
retention-days: 7

test_windows:
timeout-minutes: 10
runs-on: windows-latest

steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter pub get
- run: flutter test
- name: Archive golden test errors
if: failure()
uses: actions/upload-artifact@v4
with:
name: branches-tests-failed
path: test/
retention-days: 7
2 changes: 2 additions & 0 deletions lib/spot.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export 'package:checks/context.dart'
show Condition, Context, ContextExtension, Extracted, Rejection, Subject;
export 'package:meta/meta.dart' show useResult;
export 'package:spot/src/act/act.dart' show Act, act;
export 'package:spot/src/screenshot/load_fonts.dart'
show loadAppFonts, loadFont;
export 'package:spot/src/screenshot/screenshot.dart'
show
ElementScreenshotExtension,
Expand Down
21 changes: 21 additions & 0 deletions lib/src/flutter/flutter_sdk.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import 'dart:io';
import 'package:dartx/dartx_io.dart';

/// Returns the Flutter SDK root directory based on the current flutter
/// executable running the tests.
Directory flutterSdkRoot() {
final flutterTesterExe = Platform.executable;
final String flutterRoot;
if (Platform.isWindows) {
flutterRoot = flutterTesterExe.split(r'\bin\cache\')[0];
} else {
flutterRoot = flutterTesterExe.split('/bin/cache/')[0];
}
return Directory(flutterRoot);
}

/// The Flutter executable in the Flutter SDK
String get flutterExe {
final exe = Platform.isWindows ? '.bat' : '';
return flutterSdkRoot().file('bin/flutter$exe').absolute.path;
}
279 changes: 279 additions & 0 deletions lib/src/screenshot/load_fonts.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
import 'dart:convert';
import 'dart:io';

import 'package:dartx/dartx_io.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spot/src/flutter/flutter_sdk.dart';

/// Loads all font from the apps FontManifest and embedded in the Flutter SDK
///
/// ## What is loaded?
/// ### App Fonts (FontManifest)
/// - All fonts defined in the pubspec.yaml
/// - All fonts of dependencies that define fonts in their pubspec.yaml
///
/// ### Embedded Flutter SDK Fonts
/// - Roboto
/// - RobotoCondensed
/// - MaterialIcons
///
/// ## Why load Roboto by default?
///
/// Widget test run with [TargetPlatform.android] by default. [MaterialApp] sets
/// the Roboto fontFamily as default for [TargetPlatform.android] (see
/// [Typography]). Loading the Roboto fontFamily therefore allows showing text
/// in the default scenario of a Flutter app.
/// Fortunately, Robot is part of the Flutter SDK and can be loaded right away.
///
/// ## Custom fonts
///
/// 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).
/// Those fonts are automatically added to the FontManifest.json file during build.
///
/// The [loadAppFonts] function loads all font defined in the FontManifest.json file.
///
/// ## Depending on System fonts
///
/// Some apps do not ship their fonts, but use a system font e.g. "Segoe UI"
/// on [TargetPlatform.windows] or "Apple Color Emoji" on [TargetPlatform.iOS].
///
/// Those system fonts are not loaded by [loadAppFonts], load them individually
/// with [loadFont].
///
/// ## Emojis
///
/// Why are emojis not rendered after calling [loadAppFonts]?
///
/// Emojis are not part of the Roboto font.
/// Each operating system provides their own font that handles
/// emoji glyphs. In Flutter apps, those emoji fonts are automatically loaded
/// by Skia (the rendering engine of Flutter) from the operating system as fallbacks
/// when it encounters an emoji character that is covered by the defined
/// fontFamily or fontFamilyFallback.
///
/// Flutter tests disable the automatic system font loading by Skia. Skia will
/// not search for system fonts. (https://github.com/flutter/engine/blob/a842207f6d90de4fc006ea8f0b649b38d6e104a0/lib/ui/text/font_collection.cc#L148)
///
/// To show emojis in tests, load the system emoji font manually with [loadFont].
/// E.g. "/System/Library/Fonts/Apple Color Emoji.ttc" on macOS.
/// Do not forget to set "Apple Color Emoji" as fontFamilyFallback. Skia will
/// *not* automatically fallback to "Apple Color Emoji" unless it is defined in
/// the TextStyle.
///
/// Because showing emojis in test requires changes to you app code (set fallback)
/// [loadAppFonts] does not automatically load system emoji fonts for you.
Future<void> loadAppFonts() async {
TestWidgetsFlutterBinding.ensureInitialized();

await TestAsyncUtils.guard<void>(() async {
// First we load the Roboto font from the Flutter SDK, which most Android apps use.
// In case the app defines a custom Roboto fontFamily it will be overwritten when
// loading the fonts from the manifest
await _loadMaterialFontsFromSdk();

// Load all fonts defined in the FontManifest.json file
await _loadFontsFromFontManifest();
});
}

/// Loads a fontFamily consisting of multiple font files.
///
/// ```dart
/// debugDefaultTargetPlatformOverride = TargetPlatform.windows;
/// await loadFont('Comic Sans', [
/// r'C:\Windows\Fonts\comic.ttf', // Regular
/// r'C:\Windows\Fonts\comicbd.ttf', // Bold
/// r'C:\Windows\Fonts\comici.ttf', // Italic
/// ]);
///
/// tester.pumpWidget(
/// MaterialApp(
/// home: Center(
/// child: Text(
/// 'Loaded custom Font',
/// style: TextStyle(
/// fontFamily: 'Comic Sans',
/// ),
/// ),
/// ),
/// ),
/// );
/// ```
///
/// Flutter support the following formats: .ttf, .otf, .ttc
///
/// Calling [loadFont] multiple times with the same family will overwrites the
/// previous
///
/// The [family] is optional: '' will extract the family name from the font file.
Future<void> loadFont(String family, List<String> fontPaths) async {
if (fontPaths.isEmpty) {
return;
}

await TestAsyncUtils.guard<void>(() async {
final fontLoader = FontLoader(family);
for (final path in fontPaths) {
try {
final file = File(path);
if (file.existsSync()) {
final Uint8List bytes = file.readAsBytesSync();
fontLoader.addFont(Future.value(bytes.buffer.asByteData()));
} else {
final data = rootBundle.load(path);
fontLoader.addFont(Future.value(data));
}
} catch (e, stack) {
debugPrint("Could not load font $path\n$e\n$stack");
}
}
// the fontLoader is unusable after calling load().
// No need to cache or return it.
await fontLoader.load();
});
}

/// Loads the Roboto/RobotoCondensed/MaterialIcons fonts from the executing Flutter SDK
Future<void> _loadMaterialFontsFromSdk() async {
final root = flutterSdkRoot().absolute.path;

final materialFontsDir =
Directory('$root/bin/cache/artifacts/material_fonts/');

final fontFormats = ['.ttf', '.otf', '.ttc'];
final existingFonts = materialFontsDir
.listSync()
// dartfmt come on,...
.whereType<File>()
.where(
(font) => fontFormats.any((element) => font.path.endsWith(element)),
)
.toList();

final robotoFonts = existingFonts
.where((font) {
final name = font.name.toLowerCase();
return name.startsWith('Roboto-'.toLowerCase());
})
.map((file) => file.path)
.toList();
if (robotoFonts.isEmpty) {
debugPrint("Warning: No Roboto font found in SDK");
}
await loadFont('Roboto', robotoFonts);

final robotoCondensedFonts = existingFonts
.where((font) {
final name = font.name.toLowerCase();
return name.startsWith('RobotoCondensed-'.toLowerCase());
})
.map((file) => file.path)
.toList();
await loadFont('RobotoCondensed', robotoCondensedFonts);

final materialIcons = existingFonts
.where((font) {
final name = font.name.toLowerCase();
return name.startsWith('MaterialIcons-'.toLowerCase());
})
.map((file) => file.path)
.toList();
await loadFont('MaterialIcons', materialIcons);
}

/// Loads the fonts from the FontManifest.json file.
///
/// Fonts defined in an app are accessible via it family name "MyFont"
/// Fonts defined in a package are accessible via "packages/myPackage/MyFont"
///
/// Because each app can also be a package, each font is available with both
/// notations.
/// This allows packages to access their own fonts also via
/// "packages/myPackage/MyFont" like users of the package would.
Future<void> _loadFontsFromFontManifest() async {
// The FontManifest.json file is generated by the Flutter build process
// located in /build/flutter_assets/FontManifest.json and bundled within the app
final binding = TestWidgetsFlutterBinding.instance;
final fontManifestContent =
await binding.runAsync(() => rootBundle.loadString('FontManifest.json'));
final json = jsonDecode(fontManifestContent!);
final fontManifest = _FontManifest.fromJson(json);

for (final item in fontManifest.fontFamilies) {
final packageAsset =
item.assets.firstOrNullWhere((it) => it.startsWith('packages/'));
final packageName = packageAsset?.split('/')[1];

if (packageName == null) {
// font asset in pubspec.yaml references a file relative to the pubspec.yaml
// The font can not be used by other packages
await loadFont(item.family, item.assets);
} else {
// font uses the package notation, which resolves relative to the packages lib/* directory
// asset: packages/<packageName>/<somewhereInsideLib>/MyFont.ttf

// Make it accessible as "MyFont" to be used by the package itself
final fontFamilyName = item.family.split('/').last;
await loadFont(fontFamilyName, item.assets);
// and "packages/<packageName>/MyFont" so that other packages would reference it.
await loadFont('packages/$packageName/$fontFamilyName', item.assets);
}
}
}

/// Parsed representation of the FontManifest.json file
class _FontManifest {
final List<_FontManifestFontFamily> fontFamilies;

/// Represents a Flutter FontManifest
_FontManifest(this.fontFamilies);

/// Parses the FontManifest.json file
///
/// Example:
/// ```json
/// [
/// {
/// "family": "packages/app_font/Montserrat",
/// "fonts": [
/// {
/// "asset": "packages/app_font/fonts/Montserrat-Regular.ttf"
/// }
/// ]
/// }
/// ]
/// ```
factory _FontManifest.fromJson(dynamic json) {
if (json is! List) {
throw const FormatException('FontManifest must begin with a List');
}
final List<_FontManifestFontFamily> fontFamilies = [];
for (final family in json) {
if (family is! Map) continue;
final familyName = family['family'];
if (familyName is! String) continue;
final List<String> assets = [];
final fonts = family['fonts'];
if (fonts is! List) continue;
for (final font in fonts) {
if (font is! Map) continue;
final asset = font['asset'];
if (asset is! String) continue;
// there are other values like weight and style, but those are ignored by Flutter
// https://github.com/flutter/website/issues/3591#issuecomment-521806077
assets.add(asset);
}
fontFamilies.add(_FontManifestFontFamily(familyName, assets));
}
return _FontManifest(fontFamilies);
}
}

class _FontManifestFontFamily {
final String family;
final List<String> assets;

_FontManifestFontFamily(this.family, this.assets);
}
Loading

0 comments on commit d8648f4

Please sign in to comment.