blob: ac3fb7abd3d648b6bf436c310eb14ae975918045 [file] [log] [blame]
// Copyright 2013 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 'dart:async';
import 'dart:convert';
import 'dart:html' as html;
import 'dart:js_util' as js_util;
import 'dart:typed_data';
import '../assets.dart';
import '../browser_detection.dart';
import '../util.dart';
import 'layout_service.dart';
const String ahemFontFamily = 'Ahem';
const String ahemFontUrl = '/assets/fonts/ahem.ttf';
const String robotoFontFamily = 'Roboto';
const String robotoTestFontUrl = '/assets/fonts/Roboto-Regular.ttf';
/// This class is responsible for registering and loading fonts.
///
/// Once an asset manager has been set in the framework, call
/// [registerFonts] with it to register fonts declared in the
/// font manifest. If test fonts are enabled, then call
/// [registerTestFonts] as well.
class FontCollection {
FontManager? _assetFontManager;
FontManager? _testFontManager;
/// Reads the font manifest using the [assetManager] and registers all of the
/// fonts declared within.
Future<void> registerFonts(AssetManager assetManager) async {
ByteData byteData;
try {
byteData = await assetManager.load('FontManifest.json');
} on AssetManagerException catch (e) {
if (e.httpStatus == 404) {
printWarning('Font manifest does not exist at `${e.url}` – ignoring.');
return;
} else {
rethrow;
}
}
final List<dynamic>? fontManifest =
json.decode(utf8.decode(byteData.buffer.asUint8List())) as List<dynamic>?;
if (fontManifest == null) {
throw AssertionError(
'There was a problem trying to load FontManifest.json');
}
if (supportsFontLoadingApi) {
_assetFontManager = FontManager();
} else {
_assetFontManager = _PolyfillFontManager();
}
for (final Map<String, dynamic> fontFamily
in fontManifest.cast<Map<String, dynamic>>()) {
final String? family = fontFamily.tryString('family');
final List<Map<String, dynamic>> fontAssets = fontFamily.castList<Map<String, dynamic>>('fonts');
for (final Map<String, dynamic> fontAsset in fontAssets) {
final String asset = fontAsset.readString('asset');
final Map<String, String> descriptors = <String, String>{};
for (final String descriptor in fontAsset.keys) {
if (descriptor != 'asset') {
descriptors[descriptor] = '${fontAsset[descriptor]}';
}
}
_assetFontManager!.registerAsset(
family!, 'url(${assetManager.getAssetUrl(asset)})', descriptors);
}
}
}
Future<void> loadFontFromList(Uint8List list, {required String fontFamily}) {
return _assetFontManager!._loadFontFaceBytes(fontFamily, list);
}
/// Registers fonts that are used by tests.
void debugRegisterTestFonts() {
_testFontManager = FontManager();
_testFontManager!.registerAsset(
ahemFontFamily, 'url($ahemFontUrl)', const <String, String>{});
_testFontManager!.registerAsset(robotoFontFamily,
'url($robotoTestFontUrl)', const <String, String>{});
}
/// Returns a [Future] that completes when the registered fonts are loaded
/// and ready to be used.
Future<void> ensureFontsLoaded() async {
await _assetFontManager?.ensureFontsLoaded();
await _testFontManager?.ensureFontsLoaded();
}
/// Unregister all fonts that have been registered.
void clear() {
_assetFontManager = null;
_testFontManager = null;
if (supportsFontsClearApi) {
html.document.fonts!.clear();
}
}
}
/// Manages a collection of fonts and ensures they are loaded.
class FontManager {
final List<Future<void>> _fontLoadingFutures = <Future<void>>[];
// Regular expression to detect a string with no punctuations.
// For example font family 'Ahem!' does not fall into this category
// so the family name will be wrapped in quotes.
static final RegExp notPunctuation =
RegExp(r'[a-z0-9\s]+', caseSensitive: false);
// Regular expression to detect tokens starting with a digit.
// For example font family 'Goudy Bookletter 1911' falls into this
// category.
static final RegExp startWithDigit = RegExp(r'\b\d');
factory FontManager() {
if (supportsFontLoadingApi) {
return FontManager._();
} else {
return _PolyfillFontManager();
}
}
FontManager._();
/// Registers assets to Flutter Web Engine.
///
/// Browsers and browsers versions differ significantly on how a valid font
/// family name should be formatted. Notable issues are:
///
/// Safari 12 and Firefox crash if you create a [html.FontFace] with a font
/// family that is not correct CSS syntax. Font family names with invalid
/// characters are accepted accepted on these browsers, when wrapped it in
/// quotes.
///
/// Additionally, for Safari 12 to work [html.FontFace] name should be
/// loaded correctly on the first try.
///
/// A font in Chrome is not usable other than inside a '<p>' tag, if a
/// [html.FontFace] is loaded wrapped with quotes. Unlike Safari 12 if a
/// valid version of the font is also loaded afterwards it will show
/// that font normally.
///
/// In Safari 13 the [html.FontFace] should be loaded with unquoted family
/// names.
///
/// In order to avoid all these browser compatibility issues this method:
/// * Detects the family names that might cause a conflict.
/// * Loads it with the quotes.
/// * Loads it again without the quotes.
/// * For all the other family names [html.FontFace] is loaded only once.
///
/// See also:
///
/// * https://developer.mozilla.org/en-US/docs/Web/CSS/font-family#Valid_family_names
/// * https://drafts.csswg.org/css-fonts-3/#font-family-prop
void registerAsset(
String family,
String asset,
Map<String, String> descriptors,
) {
if (startWithDigit.hasMatch(family) ||
notPunctuation.stringMatch(family) != family) {
// Load a font family name with special characters once here wrapped in
// quotes.
_loadFontFace('\'$family\'', asset, descriptors);
}
// Load all fonts, without quoted family names.
_loadFontFace(family, asset, descriptors);
}
void _loadFontFace(
String family,
String asset,
Map<String, String> descriptors,
) {
// try/catch because `new FontFace` can crash with an improper font family.
try {
final html.FontFace fontFace = html.FontFace(family, asset, descriptors);
_fontLoadingFutures.add(fontFace.load().then((_) {
// We could do:
// ```
// html.document.fonts!.add(fontFace);
// ```
// But dart:html expects the return value to be non-null, and Firefox
// returns null. This causes the app to crash in Firefox with a null
// check exception.
//
// TODO(mdebbar): Revert this once the dart:html type is fixed.
// https://github.com/dart-lang/sdk/issues/45676
// ignore: implicit_dynamic_function
js_util.callMethod(html.document.fonts!, 'add', <dynamic>[fontFace]);
}, onError: (dynamic e) {
printWarning('Error while trying to load font family "$family":\n$e');
}));
} catch (e) {
printWarning('Error while loading font family "$family":\n$e');
}
}
// Loads a font from bytes, surfacing errors through the future.
Future<void> _loadFontFaceBytes(String family, Uint8List list) {
// Since these fonts are loaded by user code, surface the error
// through the returned future.
final html.FontFace fontFace = html.FontFace(family, list);
return fontFace.load().then((_) {
html.document.fonts!.add(fontFace);
// There might be paragraph measurements for this new font before it is
// loaded. They were measured using fallback font, so we should clear the
// cache.
Spanometer.clearRulersCache();
}, onError: (dynamic exception) {
// Failures here will throw an html.DomException which confusingly
// does not implement Exception or Error. Rethrow an Exception so it can
// be caught in user code without depending on dart:html or requiring a
// catch block without "on".
throw Exception(exception.toString());
});
}
/// Returns a [Future] that completes when all fonts that have been
/// registered with this font manager have been loaded and are ready to use.
Future<void> ensureFontsLoaded() {
return Future.wait(_fontLoadingFutures);
}
}
/// A font manager that works without using the CSS Font Loading API.
///
/// The CSS Font Loading API is not implemented in IE 11 or Edge. To tell if a
/// font is loaded, we continuously measure some text using that font until the
/// width changes.
class _PolyfillFontManager extends FontManager {
_PolyfillFontManager() : super._();
/// A String containing characters whose width varies greatly between fonts.
static const String _testString = 'giItT1WQy@!-/#';
static const Duration _fontLoadTimeout = Duration(seconds: 2);
static const Duration _fontLoadRetryDuration = Duration(milliseconds: 50);
@override
void registerAsset(
String family,
String asset,
Map<String, String> descriptors,
) {
final html.ParagraphElement paragraph = html.ParagraphElement();
paragraph.style.position = 'absolute';
paragraph.style.visibility = 'hidden';
paragraph.style.fontSize = '72px';
final String fallbackFontName =
browserEngine == BrowserEngine.ie11 ? 'Times New Roman' : 'sans-serif';
paragraph.style.fontFamily = fallbackFontName;
if (descriptors['style'] != null) {
paragraph.style.fontStyle = descriptors['style'];
}
if (descriptors['weight'] != null) {
paragraph.style.fontWeight = descriptors['weight'];
}
paragraph.text = _testString;
html.document.body!.append(paragraph);
final int sansSerifWidth = paragraph.offsetWidth;
paragraph.style.fontFamily = "'$family', $fallbackFontName";
final Completer<void> completer = Completer<void>();
late DateTime _fontLoadStart;
void _watchWidth() {
if (paragraph.offsetWidth != sansSerifWidth) {
paragraph.remove();
completer.complete();
} else {
if (DateTime.now().difference(_fontLoadStart) > _fontLoadTimeout) {
// Let application waiting for fonts continue with fallback.
completer.complete();
// Throw unhandled exception for logging.
throw Exception('Timed out trying to load font: $family');
} else {
Timer(_fontLoadRetryDuration, _watchWidth);
}
}
}
final Map<String, String?> fontStyleMap = <String, String?>{};
fontStyleMap['font-family'] = "'$family'";
fontStyleMap['src'] = asset;
if (descriptors['style'] != null) {
fontStyleMap['font-style'] = descriptors['style'];
}
if (descriptors['weight'] != null) {
fontStyleMap['font-weight'] = descriptors['weight'];
}
final String fontFaceDeclaration = fontStyleMap.keys
.map((String name) => '$name: ${fontStyleMap[name]};')
.join(' ');
final html.StyleElement fontLoadStyle = html.StyleElement();
fontLoadStyle.type = 'text/css';
fontLoadStyle.innerHtml = '@font-face { $fontFaceDeclaration }';
html.document.head!.append(fontLoadStyle);
// If this is an icon font, then when it loads it won't change the
// width of our test string. So we just have to hope it loads before the
// layout phase.
if (family.toLowerCase().contains('icon')) {
paragraph.remove();
return;
}
_fontLoadStart = DateTime.now();
_watchWidth();
_fontLoadingFutures.add(completer.future);
}
}
final bool supportsFontLoadingApi =
js_util.hasProperty(html.window, 'FontFace');
final bool supportsFontsClearApi =
js_util.hasProperty(html.document, 'fonts') &&
js_util.hasProperty(html.document.fonts!, 'clear');