diff --git a/DEPS b/DEPS index caf5e7815a8d6..dfe3fb854405a 100644 --- a/DEPS +++ b/DEPS @@ -35,7 +35,7 @@ vars = { # Dart is: https://github.com/dart-lang/sdk/blob/master/DEPS. # You can use //tools/dart/create_updated_flutter_deps.py to produce # updated revision list of existing dependencies. - 'dart_revision': 'cc15e7439ae2c596b2ca0c5c032e294a20843e30', + 'dart_revision': 'efa74fd7dbec036b70ed5537f79a0ba994cf0e75', # WARNING: DO NOT EDIT MANUALLY # The lines between blank lines above and below are generated by a script. See create_updated_flutter_deps.py diff --git a/ci/licenses_golden/licenses_third_party b/ci/licenses_golden/licenses_third_party index 4ee685de4e308..23c83b952dc9a 100644 --- a/ci/licenses_golden/licenses_third_party +++ b/ci/licenses_golden/licenses_third_party @@ -1,4 +1,4 @@ -Signature: bbf49eb830f03b288bb2a45383c3fe1a +Signature: 37e8574abeb6fbc0daec535681619bdf UNUSED LICENSES: diff --git a/lib/ui/painting.dart b/lib/ui/painting.dart index eb507943b8ad1..95b742f180e43 100644 --- a/lib/ui/painting.dart +++ b/lib/ui/painting.dart @@ -1939,12 +1939,13 @@ class Codec extends NativeFieldWrapperClass2 { final Completer completer = Completer.sync(); final String? error = _getNextFrame((_Image? image, int durationMilliseconds) { if (image == null) { - throw Exception('Codec failed to produce an image, possibly due to invalid image data.'); + completer.completeError(Exception('Codec failed to produce an image, possibly due to invalid image data.')); + } else { + completer.complete(FrameInfo._( + image: Image._(image), + duration: Duration(milliseconds: durationMilliseconds), + )); } - completer.complete(FrameInfo._( - image: Image._(image), - duration: Duration(milliseconds: durationMilliseconds), - )); }); if (error != null) { throw Exception(error); diff --git a/lib/web_ui/dev/goldens_lock.yaml b/lib/web_ui/dev/goldens_lock.yaml index 7e6a15eedcff3..33e7d061a808e 100644 --- a/lib/web_ui/dev/goldens_lock.yaml +++ b/lib/web_ui/dev/goldens_lock.yaml @@ -1,2 +1,2 @@ repository: https://github.com/flutter/goldens.git -revision: 99caeb1bcb8b7a856a78bd8d55816cc97db56112 +revision: ec80c8042759905a5215ab1cd87ad280e8ef3cd7 diff --git a/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart b/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart index 880f8b776a1bc..5742bc1a92f20 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart @@ -742,7 +742,7 @@ class SkImage { Float32List? matrix, // 3x3 matrix ); external Uint8List readPixels(int srcX, int srcY, SkImageInfo imageInfo); - external SkData encodeToData(); + external Uint8List? encodeToBytes(); external bool isAliasOf(SkImage other); external bool isDeleted(); } @@ -1643,6 +1643,8 @@ class SkTypeface {} class SkFont { external SkFont(SkTypeface typeface); external Uint8List getGlyphIDs(String text); + external void getGlyphBounds( + List glyphs, SkPaint? paint, Uint8List? output); } @JS() diff --git a/lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart b/lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart index 98d902a9d866f..f4ea4a20407f1 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart @@ -5,48 +5,108 @@ // @dart = 2.12 part of engine; -/// Whether or not "Noto Sans Symbols" and "Noto Color Emoji" fonts have been -/// downloaded. We download these as fallbacks when no other font covers the -/// given code units. -bool _registeredSymbolsAndEmoji = false; +/// Global static font fallback data. +class FontFallbackData { + static FontFallbackData get instance => _instance; + static FontFallbackData _instance = FontFallbackData(); -final Set codeUnitsWithNoKnownFont = {}; + /// Resets the fallback font data. + /// + /// After calling this method fallback fonts will be loaded from scratch. + /// + /// Used for tests. + static void debugReset() { + _instance = FontFallbackData(); + } + + /// Whether or not "Noto Sans Symbols" and "Noto Color Emoji" fonts have been + /// downloaded. We download these as fallbacks when no other font covers the + /// given code units. + bool registeredSymbolsAndEmoji = false; + + /// Code units that no known font has a glyph for. + final Set codeUnitsWithNoKnownFont = {}; + + /// Code units which are known to be covered by at least one fallback font. + final Set knownCoveredCodeUnits = {}; + + /// Index of all font families by code unit range. + final IntervalTree notoTree = createNotoFontTree(); + + static IntervalTree createNotoFontTree() { + Map> ranges = + >{}; + + for (NotoFont font in _notoFonts) { + // TODO(yjbanov): instead of mutating the font tree during reset, it's + // better to construct an immutable tree of resolved fonts + // pointing back to the original NotoFont objects. Then + // resetting the tree would be a matter of reconstructing + // the new resolved tree. + font.reset(); + for (CodeunitRange range in font.approximateUnicodeRanges) { + ranges.putIfAbsent(font, () => []).add(range); + } + } + + return IntervalTree.createFromRanges(ranges); + } -Future _findFontsForMissingCodeunits(List codeunits) async { - _ensureNotoFontTreeCreated(); + /// Fallback fonts which have been registered and loaded. + final List<_RegisteredFont> registeredFallbackFonts = <_RegisteredFont>[]; + + final List globalFontFallbacks = ['Roboto']; + + final Map fontFallbackCounts = {}; + + void registerFallbackFont(String family, Uint8List bytes) { + fontFallbackCounts.putIfAbsent(family, () => 0); + int fontFallbackTag = fontFallbackCounts[family]!; + fontFallbackCounts[family] = fontFallbackCounts[family]! + 1; + String countedFamily = '$family $fontFallbackTag'; + registeredFallbackFonts.add(_RegisteredFont(bytes, countedFamily)); + globalFontFallbacks.add(countedFamily); + } +} + +Future findFontsForMissingCodeunits(List codeUnits) async { + final FontFallbackData data = FontFallbackData.instance; // If all of the code units are known to have no Noto Font which covers them, // then just give up. We have already logged a warning. - if (codeunits.every((u) => codeUnitsWithNoKnownFont.contains(u))) { + if (codeUnits.every((u) => data.codeUnitsWithNoKnownFont.contains(u))) { return; } - Set<_NotoFont> fonts = <_NotoFont>{}; + Set fonts = {}; Set coveredCodeUnits = {}; Set missingCodeUnits = {}; - for (int codeunit in codeunits) { - List<_NotoFont> fontsForUnit = _notoTree!.intersections(codeunit); + for (int codeUnit in codeUnits) { + List fontsForUnit = data.notoTree.intersections(codeUnit); fonts.addAll(fontsForUnit); if (fontsForUnit.isNotEmpty) { - coveredCodeUnits.add(codeunit); + coveredCodeUnits.add(codeUnit); } else { - missingCodeUnits.add(codeunit); + missingCodeUnits.add(codeUnit); } } - fonts = _findMinimumFontsForCodeunits(coveredCodeUnits, fonts); - - for (_NotoFont font in fonts) { + for (NotoFont font in fonts) { await font.ensureResolved(); } + // The call to `findMinimumFontsForCodeUnits` will remove all code units that + // were matched by `fonts` from `unmatchedCodeUnits`. + final Set unmatchedCodeUnits = Set.from(coveredCodeUnits); + fonts = findMinimumFontsForCodeUnits(unmatchedCodeUnits, fonts); + Set<_ResolvedNotoSubset> resolvedFonts = <_ResolvedNotoSubset>{}; - for (int codeunit in coveredCodeUnits) { - for (_NotoFont font in fonts) { + for (int codeUnit in coveredCodeUnits) { + for (NotoFont font in fonts) { if (font.resolvedFont == null) { // We failed to resolve the font earlier. continue; } - resolvedFonts.addAll(font.resolvedFont!.tree.intersections(codeunit)); + resolvedFonts.addAll(font.resolvedFont!.tree.intersections(codeUnit)); } } @@ -54,8 +114,12 @@ Future _findFontsForMissingCodeunits(List codeunits) async { notoDownloadQueue.add(resolvedFont); } - if (missingCodeUnits.isNotEmpty && !notoDownloadQueue.isPending) { - if (!_registeredSymbolsAndEmoji) { + // We looked through the Noto font tree and didn't find any font families + // covering some code units, or we did find a font family, but when we + // downloaded the fonts we found that they actually didn't cover them. So + // we try looking them up in the symbols and emojis fonts. + if (missingCodeUnits.isNotEmpty || unmatchedCodeUnits.isNotEmpty) { + if (!data.registeredSymbolsAndEmoji) { _registerSymbolsAndEmoji(); } else { if (!notoDownloadQueue.isPending) { @@ -63,7 +127,7 @@ Future _findFontsForMissingCodeunits(List codeunits) async { 'Could not find a set of Noto fonts to display all missing ' 'characters. Please add a font asset for the missing characters.' ' See: https://flutter.dev/docs/cookbook/design/fonts'); - codeUnitsWithNoKnownFont.addAll(missingCodeUnits); + data.codeUnitsWithNoKnownFont.addAll(missingCodeUnits); } } } @@ -168,6 +232,11 @@ _ResolvedNotoFont? _makeResolvedNotoFontFromCss(String css, String name) { } } + if (rangesMap.isEmpty) { + html.window.console.warn('Parsed Google Fonts CSS was empty: $css'); + return null; + } + IntervalTree<_ResolvedNotoSubset> tree = IntervalTree<_ResolvedNotoSubset>.createFromRanges(rangesMap); @@ -178,10 +247,11 @@ _ResolvedNotoFont? _makeResolvedNotoFontFromCss(String css, String name) { /// try the Symbols and Emoji fonts. We don't know the exact range of code units /// that are covered by these fonts, so we download them and hope for the best. Future _registerSymbolsAndEmoji() async { - if (_registeredSymbolsAndEmoji) { + final FontFallbackData data = FontFallbackData.instance; + if (data.registeredSymbolsAndEmoji) { return; } - _registeredSymbolsAndEmoji = true; + data.registeredSymbolsAndEmoji = true; const String symbolsUrl = 'https://fonts.googleapis.com/css2?family=Noto+Sans+Symbols'; const String emojiUrl = @@ -226,43 +296,50 @@ Future _registerSymbolsAndEmoji() async { } } -/// Finds the minimum set of fonts which covers all of the [codeunits]. +/// Finds the minimum set of fonts which covers all of the [codeUnits]. +/// +/// Removes all code units covered by [fonts] from [codeUnits]. The code +/// units remaining in the [codeUnits] set after calling this function do not +/// have a font that covers them and can be omitted next time to avoid +/// searching for fonts unnecessarily. /// /// Since set cover is NP-complete, we approximate using a greedy algorithm -/// which finds the font which covers the most codeunits. If multiple CJK -/// fonts match the same number of codeunits, we choose one based on the user's +/// which finds the font which covers the most code units. If multiple CJK +/// fonts match the same number of code units, we choose one based on the user's /// locale. -Set<_NotoFont> _findMinimumFontsForCodeunits( - Iterable codeunits, Set<_NotoFont> fonts) { - List unmatchedCodeunits = List.from(codeunits); - Set<_NotoFont> minimumFonts = <_NotoFont>{}; - List<_NotoFont> bestFonts = <_NotoFont>[]; - int maxCodeunitsCovered = 0; +Set findMinimumFontsForCodeUnits( + Set codeUnits, Set fonts) { + assert(fonts.isNotEmpty || codeUnits.isEmpty); + Set minimumFonts = {}; + List bestFonts = []; String language = html.window.navigator.language; - // This is guaranteed to terminate because [codeunits] is a list of fonts - // which we've already determined are covered by [fonts]. - while (unmatchedCodeunits.isNotEmpty) { + while (codeUnits.isNotEmpty) { + int maxCodeUnitsCovered = 0; + bestFonts.clear(); for (var font in fonts) { - int codeunitsCovered = 0; - for (int codeunit in unmatchedCodeunits) { - if (font.matchesCodeunit(codeunit)) { - codeunitsCovered++; + int codeUnitsCovered = 0; + for (int codeUnit in codeUnits) { + if (font.resolvedFont?.tree.containsDeep(codeUnit) == true) { + codeUnitsCovered++; } } - if (codeunitsCovered > maxCodeunitsCovered) { + if (codeUnitsCovered > maxCodeUnitsCovered) { bestFonts.clear(); bestFonts.add(font); - maxCodeunitsCovered = codeunitsCovered; - } else if (codeunitsCovered == maxCodeunitsCovered) { + maxCodeUnitsCovered = codeUnitsCovered; + } else if (codeUnitsCovered == maxCodeUnitsCovered) { bestFonts.add(font); } } - assert(bestFonts.isNotEmpty); + if (maxCodeUnitsCovered == 0) { + // Fonts cannot cover remaining unmatched characters. + break; + } // If the list of best fonts are all CJK fonts, choose the best one based // on locale. Otherwise just choose the first font. - _NotoFont bestFont = bestFonts.first; + NotoFont bestFont = bestFonts.first; if (bestFonts.length > 1) { if (bestFonts.every((font) => _cjkFonts.contains(font))) { if (language == 'zh-Hans' || @@ -289,48 +366,22 @@ Set<_NotoFont> _findMinimumFontsForCodeunits( } } } - unmatchedCodeunits - .removeWhere((codeunit) => bestFont.matchesCodeunit(codeunit)); - minimumFonts.add(bestFont); + codeUnits.removeWhere((codeUnit) { + return bestFont.resolvedFont!.tree.containsDeep(codeUnit); + }); + minimumFonts.addAll(bestFonts); } return minimumFonts; } -void _ensureNotoFontTreeCreated() { - if (_notoTree != null) { - return; - } - - Map<_NotoFont, List> ranges = - <_NotoFont, List>{}; - - for (_NotoFont font in _notoFonts) { - for (CodeunitRange range in font.unicodeRanges) { - ranges.putIfAbsent(font, () => []).add(range); - } - } - - _notoTree = IntervalTree<_NotoFont>.createFromRanges(ranges); -} - -class _NotoFont { +class NotoFont { final String name; - final List unicodeRanges; + final List approximateUnicodeRanges; Completer? _decodingCompleter; - _ResolvedNotoFont? resolvedFont; - _NotoFont(this.name, this.unicodeRanges); - - bool matchesCodeunit(int codeunit) { - for (CodeunitRange range in unicodeRanges) { - if (range.contains(codeunit)) { - return true; - } - } - return false; - } + NotoFont(this.name, this.approximateUnicodeRanges); String get googleFontsCssUrl => 'https://fonts.googleapis.com/css2?family=${name.replaceAll(' ', '+')}'; @@ -350,6 +401,11 @@ class _NotoFont { } } } + + void reset() { + resolvedFont = null; + _decodingCompleter = null; + } } class CodeunitRange { @@ -397,7 +453,7 @@ class _ResolvedNotoSubset { String toString() => '_ResolvedNotoSubset($family, $url)'; } -_NotoFont _notoSansSC = _NotoFont('Noto Sans SC', [ +NotoFont _notoSansSC = NotoFont('Noto Sans SC', [ CodeunitRange(12288, 12591), CodeunitRange(12800, 13311), CodeunitRange(19968, 40959), @@ -405,37 +461,37 @@ _NotoFont _notoSansSC = _NotoFont('Noto Sans SC', [ CodeunitRange(65280, 65519), ]); -_NotoFont _notoSansTC = _NotoFont('Noto Sans TC', [ +NotoFont _notoSansTC = NotoFont('Noto Sans TC', [ CodeunitRange(12288, 12351), CodeunitRange(12549, 12585), CodeunitRange(19968, 40959), ]); -_NotoFont _notoSansHK = _NotoFont('Noto Sans HK', [ +NotoFont _notoSansHK = NotoFont('Noto Sans HK', [ CodeunitRange(12288, 12351), CodeunitRange(12549, 12585), CodeunitRange(19968, 40959), ]); -_NotoFont _notoSansJP = _NotoFont('Noto Sans JP', [ +NotoFont _notoSansJP = NotoFont('Noto Sans JP', [ CodeunitRange(12288, 12543), CodeunitRange(19968, 40959), CodeunitRange(65280, 65519), ]); -List<_NotoFont> _cjkFonts = <_NotoFont>[ +List _cjkFonts = [ _notoSansSC, _notoSansTC, _notoSansHK, _notoSansJP, ]; -List<_NotoFont> _notoFonts = <_NotoFont>[ +List _notoFonts = [ _notoSansSC, _notoSansTC, _notoSansHK, _notoSansJP, - _NotoFont('Noto Naskh Arabic UI', [ + NotoFont('Noto Naskh Arabic UI', [ CodeunitRange(1536, 1791), CodeunitRange(8204, 8206), CodeunitRange(8208, 8209), @@ -444,36 +500,36 @@ List<_NotoFont> _notoFonts = <_NotoFont>[ CodeunitRange(64336, 65023), CodeunitRange(65132, 65276), ]), - _NotoFont('Noto Sans Armenian', [ + NotoFont('Noto Sans Armenian', [ CodeunitRange(1328, 1424), CodeunitRange(64275, 64279), ]), - _NotoFont('Noto Sans Bengali UI', [ + NotoFont('Noto Sans Bengali UI', [ CodeunitRange(2404, 2405), CodeunitRange(2433, 2555), CodeunitRange(8204, 8205), CodeunitRange(8377, 8377), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans Myanmar UI', [ + NotoFont('Noto Sans Myanmar UI', [ CodeunitRange(4096, 4255), CodeunitRange(8204, 8205), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans Egyptian Hieroglyphs', [ + NotoFont('Noto Sans Egyptian Hieroglyphs', [ CodeunitRange(77824, 78894), ]), - _NotoFont('Noto Sans Ethiopic', [ + NotoFont('Noto Sans Ethiopic', [ CodeunitRange(4608, 5017), CodeunitRange(11648, 11742), CodeunitRange(43777, 43822), ]), - _NotoFont('Noto Sans Georgian', [ + NotoFont('Noto Sans Georgian', [ CodeunitRange(1417, 1417), CodeunitRange(4256, 4351), CodeunitRange(11520, 11567), ]), - _NotoFont('Noto Sans Gujarati UI', [ + NotoFont('Noto Sans Gujarati UI', [ CodeunitRange(2404, 2405), CodeunitRange(2688, 2815), CodeunitRange(8204, 8205), @@ -481,7 +537,7 @@ List<_NotoFont> _notoFonts = <_NotoFont>[ CodeunitRange(9676, 9676), CodeunitRange(43056, 43065), ]), - _NotoFont('Noto Sans Gurmukhi UI', [ + NotoFont('Noto Sans Gurmukhi UI', [ CodeunitRange(2404, 2405), CodeunitRange(2561, 2677), CodeunitRange(8204, 8205), @@ -490,46 +546,46 @@ List<_NotoFont> _notoFonts = <_NotoFont>[ CodeunitRange(9772, 9772), CodeunitRange(43056, 43065), ]), - _NotoFont('Noto Sans Hebrew', [ + NotoFont('Noto Sans Hebrew', [ CodeunitRange(1424, 1535), CodeunitRange(8362, 8362), CodeunitRange(9676, 9676), CodeunitRange(64285, 64335), ]), - _NotoFont('Noto Sans Devanagari UI', [ + NotoFont('Noto Sans Devanagari UI', [ CodeunitRange(2304, 2431), CodeunitRange(7376, 7414), CodeunitRange(7416, 7417), - CodeunitRange(8204, 9205), + CodeunitRange(8204, 8205), CodeunitRange(8360, 8360), CodeunitRange(8377, 8377), CodeunitRange(9676, 9676), CodeunitRange(43056, 43065), CodeunitRange(43232, 43259), ]), - _NotoFont('Noto Sans Kannada UI', [ + NotoFont('Noto Sans Kannada UI', [ CodeunitRange(2404, 2405), CodeunitRange(3202, 3314), CodeunitRange(8204, 8205), CodeunitRange(8377, 8377), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans Khmer UI', [ + NotoFont('Noto Sans Khmer UI', [ CodeunitRange(6016, 6143), CodeunitRange(8204, 8204), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans KR', [ + NotoFont('Noto Sans KR', [ CodeunitRange(12593, 12686), CodeunitRange(12800, 12828), CodeunitRange(12896, 12923), CodeunitRange(44032, 55215), ]), - _NotoFont('Noto Sans Lao UI', [ + NotoFont('Noto Sans Lao UI', [ CodeunitRange(3713, 3807), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans Malayalam UI', [ + NotoFont('Noto Sans Malayalam UI', [ CodeunitRange(775, 775), CodeunitRange(803, 803), CodeunitRange(2404, 2405), @@ -538,20 +594,20 @@ List<_NotoFont> _notoFonts = <_NotoFont>[ CodeunitRange(8377, 8377), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans Sinhala', [ + NotoFont('Noto Sans Sinhala', [ CodeunitRange(2404, 2405), CodeunitRange(3458, 3572), CodeunitRange(8204, 8205), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans Tamil UI', [ + NotoFont('Noto Sans Tamil UI', [ CodeunitRange(2404, 2405), CodeunitRange(2946, 3066), CodeunitRange(8204, 8205), CodeunitRange(8377, 8377), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans Telugu UI', [ + NotoFont('Noto Sans Telugu UI', [ CodeunitRange(2385, 2386), CodeunitRange(2404, 2405), CodeunitRange(3072, 3199), @@ -559,12 +615,12 @@ List<_NotoFont> _notoFonts = <_NotoFont>[ CodeunitRange(8204, 8205), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans Thai UI', [ + NotoFont('Noto Sans Thai UI', [ CodeunitRange(3585, 3675), CodeunitRange(8204, 8205), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans', [ + NotoFont('Noto Sans', [ CodeunitRange(0, 255), CodeunitRange(305, 305), CodeunitRange(338, 339), @@ -618,48 +674,88 @@ class FallbackFontDownloadQueue { NotoDownloader downloader = NotoDownloader(); final Set<_ResolvedNotoSubset> downloadedSubsets = <_ResolvedNotoSubset>{}; - final Set<_ResolvedNotoSubset> pendingSubsets = <_ResolvedNotoSubset>{}; + final Map pendingSubsets = + {}; - bool get isPending => pendingSubsets.isNotEmpty; + bool get isPending => pendingSubsets.isNotEmpty || _fontsLoading != null; + + Future? _fontsLoading; + bool get debugIsLoadingFonts => _fontsLoading != null; + + Future debugWhenIdle() async { + if (assertionsEnabled) { + await Future.delayed(Duration.zero); + while (isPending) { + if (_fontsLoading != null) { + await _fontsLoading; + } + if (pendingSubsets.isNotEmpty) { + await Future.delayed(const Duration(milliseconds: 100)); + if (pendingSubsets.isEmpty) { + await Future.delayed(const Duration(milliseconds: 100)); + } + } + } + } else { + throw UnimplementedError(); + } + } void add(_ResolvedNotoSubset subset) { - if (downloadedSubsets.contains(subset) || pendingSubsets.contains(subset)) { + if (downloadedSubsets.contains(subset) || + pendingSubsets.containsKey(subset.url)) { return; } bool firstInBatch = pendingSubsets.isEmpty; - pendingSubsets.add(subset); + pendingSubsets[subset.url] = subset; if (firstInBatch) { Timer.run(startDownloads); } } Future startDownloads() async { - List> downloads = []; - for (_ResolvedNotoSubset subset in pendingSubsets) { - downloads.add(Future(() async { + final Map> downloads = >{}; + final Map downloadedData = {}; + for (_ResolvedNotoSubset subset in pendingSubsets.values) { + downloads[subset.url] = Future(() async { ByteBuffer buffer; try { - buffer = await downloader.downloadAsBytes(subset.url); + buffer = await downloader.downloadAsBytes(subset.url, + debugDescription: subset.family); } catch (e) { + pendingSubsets.remove(subset.url); html.window.console .warn('Failed to load font ${subset.family} at ${subset.url}'); html.window.console.warn(e); return; } - - final Uint8List bytes = buffer.asUint8List(); - skiaFontCollection.registerFallbackFont(subset.family, bytes); - - pendingSubsets.remove(subset); downloadedSubsets.add(subset); - if (pendingSubsets.isEmpty) { - await skiaFontCollection.ensureFontsLoaded(); - sendFontChangeMessage(); + downloadedData[subset.url] = buffer.asUint8List(); + }); + } + + await Future.wait(downloads.values); + + // Register fallback fonts in a predictable order. Otherwise, the fonts + // change their precedence depending on the download order causing + // visual differences between app reloads. + final List downloadOrder = + (downloadedData.keys.toList()..sort()).reversed.toList(); + for (String url in downloadOrder) { + final _ResolvedNotoSubset subset = pendingSubsets.remove(url)!; + final Uint8List bytes = downloadedData[url]!; + FontFallbackData.instance.registerFallbackFont(subset.family, bytes); + if (pendingSubsets.isEmpty) { + _fontsLoading = skiaFontCollection.ensureFontsLoaded(); + try { + await _fontsLoading; + } finally { + _fontsLoading = null; } - })); + sendFontChangeMessage(); + } } - await Future.wait(downloads); if (pendingSubsets.isNotEmpty) { await startDownloads(); } @@ -667,6 +763,7 @@ class FallbackFontDownloadQueue { } class NotoDownloader { + int get debugActiveDownloadCount => _debugActiveDownloadCount; int _debugActiveDownloadCount = 0; /// Returns a future that resolves when there are no pending downloads. @@ -695,7 +792,7 @@ class NotoDownloader { /// Downloads the [url] and returns it as a [ByteBuffer]. /// /// Override this for testing. - Future downloadAsBytes(String url) { + Future downloadAsBytes(String url, {String? debugDescription}) { if (assertionsEnabled) { _debugActiveDownloadCount += 1; } @@ -713,7 +810,7 @@ class NotoDownloader { /// Downloads the [url] and returns is as a [String]. /// /// Override this for testing. - Future downloadAsString(String url) { + Future downloadAsString(String url, {String? debugDescription}) { if (assertionsEnabled) { _debugActiveDownloadCount += 1; } @@ -728,7 +825,4 @@ class NotoDownloader { } } -/// The Noto font interval tree. -IntervalTree<_NotoFont>? _notoTree; - FallbackFontDownloadQueue notoDownloadQueue = FallbackFontDownloadQueue(); diff --git a/lib/web_ui/lib/src/engine/canvaskit/fonts.dart b/lib/web_ui/lib/src/engine/canvaskit/fonts.dart index 4b7b169cc58a3..0465928d7f4e8 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/fonts.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/fonts.dart @@ -22,16 +22,9 @@ class SkiaFontCollection { /// Fonts which have been registered and loaded. final List<_RegisteredFont> _registeredFonts = <_RegisteredFont>[]; - /// Fallback fonts which have been registered and loaded. - final List<_RegisteredFont> _registeredFallbackFonts = <_RegisteredFont>[]; - final Map> familyToTypefaceMap = >{}; - final List globalFontFallbacks = ['Roboto']; - - final Map _fontFallbackCounts = {}; - Future ensureFontsLoaded() async { await _loadFonts(); @@ -49,7 +42,7 @@ class SkiaFontCollection { .add(font.typeface); } - for (var font in _registeredFallbackFonts) { + for (var font in FontFallbackData.instance.registeredFallbackFonts) { fontProvider!.registerFont(font.bytes, font.family); familyToTypefaceMap .putIfAbsent(font.family, () => []) @@ -151,15 +144,6 @@ class SkiaFontCollection { return _RegisteredFont(bytes, family); } - void registerFallbackFont(String family, Uint8List bytes) { - _fontFallbackCounts.putIfAbsent(family, () => 0); - int fontFallbackTag = _fontFallbackCounts[family]!; - _fontFallbackCounts[family] = _fontFallbackCounts[family]! + 1; - String countedFamily = '$family $fontFallbackTag'; - _registeredFallbackFonts.add(_RegisteredFont(bytes, countedFamily)); - globalFontFallbacks.add(countedFamily); - } - String? _readActualFamilyName(Uint8List bytes) { final SkFontMgr tmpFontMgr = canvasKit.FontMgr.FromData([bytes])!; String? actualFamily = tmpFontMgr.getFamilyName(0); @@ -174,14 +158,6 @@ class SkiaFontCollection { .then((dynamic x) => x as ByteBuffer); } - /// Resets the fallback fonts. Used for tests. - void debugResetFallbackFonts() { - _registeredFallbackFonts.clear(); - globalFontFallbacks.clear(); - globalFontFallbacks.add('Roboto'); - _fontFallbackCounts.clear(); - } - SkFontMgr? skFontMgr; TypefaceFontProvider? fontProvider; } @@ -201,5 +177,9 @@ class _RegisteredFont { _RegisteredFont(this.bytes, this.family) : this.typeface = - canvasKit.FontMgr.RefDefault().MakeTypefaceFromData(bytes); + canvasKit.FontMgr.RefDefault().MakeTypefaceFromData(bytes) { + // This is a hack which causes Skia to cache the decoded font. + SkFont skFont = SkFont(typeface); + skFont.getGlyphBounds([0], null, null); + } } diff --git a/lib/web_ui/lib/src/engine/canvaskit/image.dart b/lib/web_ui/lib/src/engine/canvaskit/image.dart index 658dca082aec1..e0c9bcc76c519 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/image.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/image.dart @@ -172,13 +172,18 @@ class CkImage implements ui.Image, StackTraceDebugger { // IMPORTANT: the alphaType, colorType, and colorSpace passed to // _encodeImage and to canvasKit.MakeImage must be the same. Otherwise // Skia will misinterpret the pixels and corrupt the image. - final ByteData originalBytes = _encodeImage( + final ByteData? originalBytes = _encodeImage( skImage: skImage, format: ui.ImageByteFormat.rawRgba, alphaType: canvasKit.AlphaType.Premul, colorType: canvasKit.ColorType.RGBA_8888, colorSpace: SkColorSpaceSRGB, ); + if (originalBytes == null) { + html.window.console.warn('Unable to encode image to bytes. We will not ' + 'be able to resurrect it once it has been garbage collected.'); + return; + } final int originalWidth = skImage.width(); final int originalHeight = skImage.height(); box = SkiaObjectBox.resurrectable(this, skImage, () { @@ -276,23 +281,28 @@ class CkImage implements ui.Image, StackTraceDebugger { ui.ImageByteFormat format = ui.ImageByteFormat.rawRgba, }) { assert(_debugCheckIsNotDisposed()); - return Future.value(_encodeImage( + ByteData? data = _encodeImage( skImage: skImage, format: format, alphaType: canvasKit.AlphaType.Premul, colorType: canvasKit.ColorType.RGBA_8888, colorSpace: SkColorSpaceSRGB, - )); + ); + if (data == null) { + return Future.error('Failed to encode the image into bytes.'); + } else { + return Future.value(data); + } } - static ByteData _encodeImage({ + static ByteData? _encodeImage({ required SkImage skImage, required ui.ImageByteFormat format, required SkAlphaType alphaType, required SkColorType colorType, required ColorSpace colorSpace, }) { - Uint8List bytes; + Uint8List? bytes; if (format == ui.ImageByteFormat.rawRgba) { final SkImageInfo imageInfo = SkImageInfo( @@ -304,13 +314,10 @@ class CkImage implements ui.Image, StackTraceDebugger { ); bytes = skImage.readPixels(0, 0, imageInfo); } else { - final SkData skData = skImage.encodeToData(); //defaults to PNG 100% - // make a copy that we can return - bytes = Uint8List.fromList(canvasKit.getDataBytes(skData)); - skData.delete(); + bytes = skImage.encodeToBytes(); //defaults to PNG 100% } - return bytes.buffer.asByteData(0, bytes.length); + return bytes?.buffer.asByteData(0, bytes.length); } @override diff --git a/lib/web_ui/lib/src/engine/canvaskit/initialization.dart b/lib/web_ui/lib/src/engine/canvaskit/initialization.dart index da7204a953b86..50369938d65d2 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/initialization.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/initialization.dart @@ -75,10 +75,12 @@ const bool canvasKitForceCpuOnly = bool.fromEnvironment( /// NPM, update this URL to `https://unpkg.com/canvaskit-wasm@0.34.0/bin/`. const String canvasKitBaseUrl = String.fromEnvironment( 'FLUTTER_WEB_CANVASKIT_URL', - defaultValue: 'https://unpkg.com/canvaskit-wasm@0.22.0/bin/', + defaultValue: 'https://unpkg.com/canvaskit-wasm@0.24.0/bin/', ); -final String canvasKitBuildUrl = canvasKitBaseUrl + (kProfileMode ? 'profiling/' : ''); -final String canvasKitJavaScriptBindingsUrl = canvasKitBuildUrl + 'canvaskit.js'; +final String canvasKitBuildUrl = + canvasKitBaseUrl + (kProfileMode ? 'profiling/' : ''); +final String canvasKitJavaScriptBindingsUrl = + canvasKitBuildUrl + 'canvaskit.js'; String canvasKitWasmModuleUrl(String file) => canvasKitBuildUrl + file; /// Initialize CanvasKit. @@ -89,8 +91,10 @@ Future initializeCanvasKit() { late StreamSubscription loadSubscription; loadSubscription = domRenderer.canvasKitScript!.onLoad.listen((_) { loadSubscription.cancel(); - final CanvasKitInitPromise canvasKitInitPromise = CanvasKitInit(CanvasKitInitOptions( - locateFile: js.allowInterop((String file, String unusedBase) => canvasKitWasmModuleUrl(file)), + final CanvasKitInitPromise canvasKitInitPromise = + CanvasKitInit(CanvasKitInitOptions( + locateFile: js.allowInterop( + (String file, String unusedBase) => canvasKitWasmModuleUrl(file)), )); canvasKitInitPromise.then(js.allowInterop((CanvasKit ck) { canvasKit = ck; diff --git a/lib/web_ui/lib/src/engine/canvaskit/interval_tree.dart b/lib/web_ui/lib/src/engine/canvaskit/interval_tree.dart index 88a097e65369a..b42e504004815 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/interval_tree.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/interval_tree.dart @@ -17,13 +17,15 @@ class IntervalTree { /// When the interval tree is queried, it will return a list of [T]s which /// have a range which contains the point. factory IntervalTree.createFromRanges(Map> rangesMap) { + assert(rangesMap.isNotEmpty); // Get a list of all the ranges ordered by start index. - List> intervals = >[]; + final List> intervals = >[]; rangesMap.forEach((T key, List rangeList) { for (CodeunitRange range in rangeList) { intervals.add(IntervalTreeNode(key, range.start, range.end)); } }); + assert(intervals.isNotEmpty); intervals .sort((IntervalTreeNode a, IntervalTreeNode b) => a.low - b.low); @@ -80,6 +82,11 @@ class IntervalTree { root.searchForPoint(x, results); return results; } + + /// Whether this tree contains at least one interval that includes [x]. + bool containsDeep(int x) { + return root.containsDeep(x); + } } class IntervalTreeNode { @@ -93,17 +100,52 @@ class IntervalTreeNode { IntervalTreeNode(this.value, this.low, this.high) : computedHigh = high; - bool contains(int x) { + Iterable enumerateAllElements() sync* { + if (left != null) { + yield* left!.enumerateAllElements(); + } + yield value; + if (right != null) { + yield* right!.enumerateAllElements(); + } + } + + /// Whether this node contains [x]. + /// + /// Does not recursively check whether child nodes contain [x]. + bool containsShallow(int x) { return low <= x && x <= high; } + /// Whether this sub-tree contains [x]. + /// + /// Recursively checks whether child nodes contain [x]. + bool containsDeep(int x) { + if (x > computedHigh) { + // x is above the highest possible value stored in this subtree. + // Don't bother checking intervals. + return false; + } + if (this.containsShallow(x)) { + return true; + } + if (left?.containsDeep(x) == true) { + return true; + } + if (x < low) { + // The right tree can't possible contain x. Don't bother checking. + return false; + } + return right?.containsDeep(x) == true; + } + // Searches the tree rooted at this node for all T containing [x]. void searchForPoint(int x, List result) { if (x > computedHigh) { return; } left?.searchForPoint(x, result); - if (this.contains(x)) { + if (this.containsShallow(x)) { result.add(value); } if (x < low) { diff --git a/lib/web_ui/lib/src/engine/canvaskit/text.dart b/lib/web_ui/lib/src/engine/canvaskit/text.dart index a220f9b92d4fe..f7c50e8b0452e 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/text.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/text.dart @@ -674,6 +674,26 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { // TODO(hterkelsen): Make this faster for the common case where the text // is supported by the given fonts. + // A list of unique code units in the text. + final List codeUnits = text.runes.toSet().toList(); + + // First, check if every code unit in the text is known to be covered by one + // of our global fallback fonts. We cache the set of code units covered by + // the global fallback fonts since this set is growing monotonically over + // the lifetime of the app. + if (_checkIfGlobalFallbacksSupport(codeUnits)) { + return; + } + + // Next, check if all of the remaining code units are ones which are known + // to have no global font fallback. This means we know of no font we can + // download which will cover the remaining code units. In this case we can + // just skip the checks below, since we know there's nothing we can do to + // cover the code units. + if (_checkIfNoFallbackFontSupports(codeUnits)) { + return; + } + // If the text is ASCII, then skip this check. bool isAscii = true; for (int i = 0; i < text.length; i++) { @@ -685,8 +705,15 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { if (isAscii) { return; } + CkTextStyle style = _peekStyle(); - List fontFamilies = style.effectiveFontFamilies; + List fontFamilies = []; + if (style.fontFamily != null) { + fontFamilies.add(style.fontFamily!); + } + if (style.fontFamilyFallback != null) { + fontFamilies.addAll(style.fontFamilyFallback!); + } List typefaces = []; for (var font in fontFamilies) { List? typefacesForFamily = @@ -695,11 +722,11 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { typefaces.addAll(typefacesForFamily); } } - List codeUnits = text.runes.toList(); List codeUnitsSupported = List.filled(codeUnits.length, false); + String testString = String.fromCharCodes(codeUnits); for (SkTypeface typeface in typefaces) { SkFont font = SkFont(typeface); - Uint8List glyphs = font.getGlyphIDs(text); + Uint8List glyphs = font.getGlyphIDs(testString); assert(glyphs.length == codeUnitsSupported.length); for (int i = 0; i < glyphs.length; i++) { codeUnitsSupported[i] |= glyphs[i] != 0 || _isControlCode(codeUnits[i]); @@ -713,7 +740,7 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { missingCodeUnits.add(codeUnits[i]); } } - _findFontsForMissingCodeunits(missingCodeUnits); + findFontsForMissingCodeunits(missingCodeUnits); } } @@ -722,6 +749,89 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { return codepoint < 32 || (codepoint > 127 && codepoint < 160); } + /// Returns `true` if every code unit in [codeUnits] is covered by a global + /// fallback font. + /// + /// Calling this method has 2 side effects: + /// 1. Updating the cache of known covered code units in the + /// [FontFallbackData] instance. + /// 2. Removing known covered code units from [codeUnits]. When the list + /// is used again in [_ensureFontsSupportText] + bool _checkIfGlobalFallbacksSupport(List codeUnits) { + final FontFallbackData fallbackData = FontFallbackData.instance; + codeUnits.removeWhere((int codeUnit) => + fallbackData.knownCoveredCodeUnits.contains(codeUnit)); + if (codeUnits.isEmpty) { + return true; + } + + // We don't know if the remaining code units are covered by our fallback + // fonts. Check them and update the cache. + List codeUnitsSupported = List.filled(codeUnits.length, false); + String testString = String.fromCharCodes(codeUnits); + + for (String font in fallbackData.globalFontFallbacks) { + List? typefacesForFamily = + skiaFontCollection.familyToTypefaceMap[font]; + if (typefacesForFamily == null) { + html.window.console.warn('A fallback font was registered but we ' + 'cannot retrieve the typeface for it.'); + continue; + } + for (SkTypeface typeface in typefacesForFamily) { + SkFont font = SkFont(typeface); + Uint8List glyphs = font.getGlyphIDs(testString); + assert(glyphs.length == codeUnitsSupported.length); + for (int i = 0; i < glyphs.length; i++) { + bool codeUnitSupported = glyphs[i] != 0; + if (codeUnitSupported) { + fallbackData.knownCoveredCodeUnits.add(codeUnits[i]); + } + codeUnitsSupported[i] |= + codeUnitSupported || _isControlCode(codeUnits[i]); + } + } + + // Once we've checked every typeface for this family, check to see if + // every code unit has been covered in order to avoid unnecessary checks. + bool keepGoing = false; + for (bool supported in codeUnitsSupported) { + if (!supported) { + keepGoing = true; + break; + } + } + + if (!keepGoing) { + // Every code unit is supported, clear [codeUnits] and return `true`. + codeUnits.clear(); + return true; + } + } + + // If we reached here, then there are some code units which aren't covered + // by the global fallback fonts. Remove the ones which were covered and + // return false. + for (int i = codeUnits.length - 1; i >= 0; i--) { + if (codeUnitsSupported[i]) { + codeUnits.removeAt(i); + } + } + return false; + } + + /// Returns `true` if every code unit in [codeUnits] is known to not have any + /// fallback font which can cover it. + /// + /// This method has a side effect of removing every code unit from [codeUnits] + /// which is known not to have a fallback font which covers it. + bool _checkIfNoFallbackFontSupports(List codeUnits) { + final FontFallbackData fallbackData = FontFallbackData.instance; + codeUnits.removeWhere((int codeUnit) => + fallbackData.codeUnitsWithNoKnownFont.contains(codeUnit)); + return codeUnits.isEmpty; + } + @override void addText(String text) { _ensureFontsSupportText(text); @@ -866,6 +976,6 @@ List _getEffectiveFontFamilies(String? fontFamily, !fontFamilyFallback.every((font) => fontFamily == font)) { fontFamilies.addAll(fontFamilyFallback); } - fontFamilies.addAll(skiaFontCollection.globalFontFallbacks); + fontFamilies.addAll(FontFallbackData.instance.globalFontFallbacks); return fontFamilies; } diff --git a/lib/web_ui/test/canvaskit/canvas_golden_test.dart b/lib/web_ui/test/canvaskit/canvas_golden_test.dart index 3688f67e0171b..d17341be34c48 100644 --- a/lib/web_ui/test/canvaskit/canvas_golden_test.dart +++ b/lib/web_ui/test/canvaskit/canvas_golden_test.dart @@ -35,6 +35,16 @@ void testMain() { group('CkCanvas', () { setUpCanvasKitTest(); + setUp(() { + expect(notoDownloadQueue.downloader.debugActiveDownloadCount, 0); + expect(notoDownloadQueue.isPending, false); + }); + + tearDown(() { + expect(notoDownloadQueue.downloader.debugActiveDownloadCount, 0); + expect(notoDownloadQueue.isPending, false); + }); + test('renders using non-recording canvas if weak refs are supported', () async { expect(browserSupportsFinalizationRegistry, isTrue, @@ -416,9 +426,6 @@ void testMain() { }); test('text styles - old style figures', () async { - // TODO(yjbanov): we should not need to reset the fallbacks, see - // https://github.com/flutter/flutter/issues/74741 - skiaFontCollection.debugResetFallbackFonts(); await testTextStyle( 'old style figures', paragraphFontFamily: 'Roboto', @@ -430,9 +437,6 @@ void testMain() { }); test('text styles - stylistic set 1', () async { - // TODO(yjbanov): we should not need to reset the fallbacks, see - // https://github.com/flutter/flutter/issues/74741 - skiaFontCollection.debugResetFallbackFonts(); await testTextStyle( 'stylistic set 1', paragraphFontFamily: 'Roboto', @@ -444,9 +448,6 @@ void testMain() { }); test('text styles - stylistic set 2', () async { - // TODO(yjbanov): we should not need to reset the fallbacks, see - // https://github.com/flutter/flutter/issues/74741 - skiaFontCollection.debugResetFallbackFonts(); await testTextStyle( 'stylistic set 2', paragraphFontFamily: 'Roboto', @@ -493,6 +494,29 @@ void testMain() { ); }); + test('text style - characters from multiple fallback fonts', () async { + await testTextStyle( + 'multi-font characters', + // This character is claimed by multiple fonts. This test makes sure + // we can find a font supporting it. + outerText: '欢', + innerText: '', + ); + }); + + test('text style - symbols', () async { + // One of the CJK fonts loaded in one of the tests above also contains + // some of these symbols. To make sure the test produces predictable + // results we reset the fallback data forcing the engine to reload + // fallbacks, which for this test will only load Noto Symbols. + FontFallbackData.debugReset(); + await testTextStyle( + 'symbols', + outerText: '← ↑ → ↓ ', + innerText: '', + ); + }); + test('text style - foreground/background/color do not leak across paragraphs', () async { const double testWidth = 440; const double middle = testWidth / 2; @@ -580,11 +604,146 @@ void testMain() { region: ui.Rect.fromLTRB(0, 0, testWidth, 850), ); }); + + test('sample Chinese text', () async { + await testSampleText( + 'chinese', + '也称乱数假文或者哑元文本, ' + '是印刷及排版领域所常用的虚拟文字。' + '由于曾经一台匿名的打印机刻意打乱了' + '一盒印刷字体从而造出一本字体样品书', + ); + }); + + test('sample Armenian text', () async { + await testSampleText( + 'armenian', + 'տպագրության և տպագրական արդյունաբերության համար նախատեսված մոդելային տեքստ է', + ); + }); + + test('sample Albanian text', () async { + await testSampleText( + 'albanian', + 'është një tekst shabllon i industrisë së printimit dhe shtypshkronjave Lorem Ipsum ka qenë teksti shabllon', + ); + }); + + test('sample Arabic text', () async { + await testSampleText( + 'arabic', + 'هناك حقيقة مثبتة منذ زمن طويل وهي أن المحتوى المقروء لصفحة ما سيلهي', + textDirection: ui.TextDirection.rtl, + ); + }); + + test('sample Bulgarian text', () async { + await testSampleText( + 'bulgarian', + 'е елементарен примерен текст използван в печатарската и типографската индустрия', + ); + }); + + test('sample Catalan text', () async { + await testSampleText( + 'catalan', + 'és un text de farciment usat per la indústria de la tipografia i la impremta', + ); + }); + + test('sample English text', () async { + await testSampleText( + 'english', + 'Lorem Ipsum is simply dummy text of the printing and typesetting industry', + ); + }); + + test('sample Greek text', () async { + await testSampleText( + 'greek', + 'είναι απλά ένα κείμενο χωρίς νόημα για τους επαγγελματίες της τυπογραφίας και στοιχειοθεσίας', + ); + }); + + test('sample Hebrew text', () async { + await testSampleText( + 'hebrew', + 'זוהי עובדה מבוססת שדעתו של הקורא תהיה מוסחת על ידי טקטס קריא כאשר הוא יביט בפריסתו', + textDirection: ui.TextDirection.rtl, + ); + }); + + test('sample Hindi text', () async { + await testSampleText( + 'hindi', + 'छपाई और अक्षर योजन उद्योग का एक साधारण डमी पाठ है सन १५०० के बाद से अभी तक इस उद्योग का मानक डमी पाठ मन गया जब एक अज्ञात मुद्रक ने नमूना लेकर एक नमूना किताब बनाई', + ); + }); + + test('sample Thai text', () async { + await testSampleText( + 'thai', + 'คือ เนื้อหาจำลองแบบเรียบๆ ที่ใช้กันในธุรกิจงานพิมพ์หรืองานเรียงพิมพ์ มันได้กลายมาเป็นเนื้อหาจำลองมาตรฐานของธุรกิจดังกล่าวมาตั้งแต่ศตวรรษที่', + ); + }); + + test('sample Georgian text', () async { + await testSampleText( + 'georgian', + 'საბეჭდი და ტიპოგრაფიული ინდუსტრიის უშინაარსო ტექსტია. იგი სტანდარტად', + ); + }); + + // We've seen text break when we load many fonts simultaneously. This test + // combines text in multiple languages into one long paragraph to make sure + // we can handle it. + test('sample multilingual text', () async { + await testSampleText( + 'multilingual', + '也称乱数假文或者哑元文本, 是印刷及排版领域所常用的虚拟文字。 ' + 'տպագրության և տպագրական արդյունաբերության համար ' + 'është një tekst shabllon i industrisë së printimit ' + ' زمن طويل وهي أن المحتوى المقروء لصفحة ما سيلهي ' + 'е елементарен примерен текст използван в печатарската ' + 'és un text de farciment usat per la indústria de la ' + 'Lorem Ipsum is simply dummy text of the printing ' + 'είναι απλά ένα κείμενο χωρίς νόημα για τους επαγγελματίες ' + ' זוהי עובדה מבוססת שדעתו של הקורא תהיה מוסחת על ידי טקטס קריא ' + 'छपाई और अक्षर योजन उद्योग का एक साधारण डमी पाठ है सन ' + 'คือ เนื้อหาจำลองแบบเรียบๆ ที่ใช้กันในธุรกิจงานพิมพ์หรืองานเรียงพิมพ์ ' + 'საბეჭდი და ტიპოგრაფიული ინდუსტრიის უშინაარსო ტექსტია ', + ); + }); // TODO: https://github.com/flutter/flutter/issues/60040 // TODO: https://github.com/flutter/flutter/issues/71520 }, skip: isIosSafari || isFirefox); } +Future testSampleText(String language, String text, { ui.TextDirection textDirection = ui.TextDirection.ltr, bool write = false }) async { + FontFallbackData.debugReset(); + const double testWidth = 300; + double paragraphHeight = 0; + final CkPicture picture = await generatePictureWhenFontsStable(() { + final CkPictureRecorder recorder = CkPictureRecorder(); + final CkCanvas canvas = recorder.beginRecording(ui.Rect.largest); + final CkParagraphBuilder paragraphBuilder = CkParagraphBuilder(CkParagraphStyle( + textDirection: textDirection, + )); + paragraphBuilder.addText(text); + final CkParagraph paragraph = paragraphBuilder.build(); + paragraph.layout(ui.ParagraphConstraints(width: testWidth - 20)); + canvas.drawParagraph(paragraph, const ui.Offset(10, 10)); + paragraphHeight = paragraph.height; + return recorder.endRecording(); + }); + await matchPictureGolden( + 'canvaskit_sample_text_$language.png', + picture, + region: ui.Rect.fromLTRB(0, 0, testWidth, paragraphHeight + 20), + write: write, + ); +} + typedef ParagraphFactory = CkParagraph Function(); void drawTestPicture(CkCanvas canvas) { @@ -1067,15 +1226,30 @@ Future testTextStyle( } // Render once to trigger font downloads. - renderPicture(); - // Wait for fonts to finish loading. - await notoDownloadQueue.downloader.debugWhenIdle(); - // Render again for actual screenshotting. - final CkPicture picture = renderPicture(); + CkPicture picture = await generatePictureWhenFontsStable(renderPicture); await matchPictureGolden( 'canvaskit_text_styles_${name.replaceAll(' ', '_')}.png', picture, region: region, write: write, ); + expect(notoDownloadQueue.debugIsLoadingFonts, isFalse); + expect(notoDownloadQueue.pendingSubsets, isEmpty); + expect(notoDownloadQueue.downloader.debugActiveDownloadCount, 0); +} + +typedef PictureGenerator = CkPicture Function(); + +Future generatePictureWhenFontsStable(PictureGenerator generator) async { + CkPicture picture = generator(); + // Font downloading begins asynchronously so we inject a timer before checking the download queue. + await Future.delayed(Duration.zero); + while (notoDownloadQueue.isPending || notoDownloadQueue.downloader.debugActiveDownloadCount > 0) { + await notoDownloadQueue.debugWhenIdle(); + await notoDownloadQueue.downloader.debugWhenIdle(); + picture = generator(); + // Dummy timer for the same reason as above. + await Future.delayed(Duration.zero); + } + return picture; } diff --git a/lib/web_ui/test/canvaskit/canvaskit_api_test.dart b/lib/web_ui/test/canvaskit/canvaskit_api_test.dart index dea114a7b0282..2bc13f045ab8f 100644 --- a/lib/web_ui/test/canvaskit/canvaskit_api_test.dart +++ b/lib/web_ui/test/canvaskit/canvaskit_api_test.dart @@ -1313,7 +1313,7 @@ void _paragraphTests() { expect(paragraph.getIdeographicBaseline(), within(distance: 0.5, from: 28)); expect(paragraph.getLongestLine(), 50); expect(paragraph.getMaxIntrinsicWidth(), 50); - expect(paragraph.getMinIntrinsicWidth(), 0); + expect(paragraph.getMinIntrinsicWidth(), 50); expect(paragraph.getMaxWidth(), 55); expect(paragraph.getRectsForRange(1, 3, canvasKit.RectHeightStyle.Tight, canvasKit.RectWidthStyle.Max), []); expect(paragraph.getRectsForPlaceholders(), hasLength(1)); diff --git a/lib/web_ui/test/canvaskit/fallback_fonts_golden_test.dart b/lib/web_ui/test/canvaskit/fallback_fonts_golden_test.dart index cc2fd0d1cff55..0c49dcc8d88bd 100644 --- a/lib/web_ui/test/canvaskit/fallback_fonts_golden_test.dart +++ b/lib/web_ui/test/canvaskit/fallback_fonts_golden_test.dart @@ -4,6 +4,7 @@ // @dart = 2.12 import 'dart:async'; +import 'dart:math' as math; import 'dart:typed_data'; import 'package:ui/ui.dart' as ui; @@ -44,7 +45,7 @@ void testMain() { notoDownloadQueue.downloader = TestDownloader(); TestDownloader.mockDownloads.clear(); savedCallback = ui.window.onPlatformMessage; - skiaFontCollection.debugResetFallbackFonts(); + FontFallbackData.debugReset(); }); tearDown(() { @@ -52,7 +53,7 @@ void testMain() { }); test('Roboto is always a fallback font', () { - expect(skiaFontCollection.globalFontFallbacks, contains('Roboto')); + expect(FontFallbackData.instance.globalFontFallbacks, contains('Roboto')); }); test('will download Noto Naskh Arabic if Arabic text is added', () async { @@ -87,7 +88,7 @@ void testMain() { } '''; - expect(skiaFontCollection.globalFontFallbacks, ['Roboto']); + expect(FontFallbackData.instance.globalFontFallbacks, ['Roboto']); // Creating this paragraph should cause us to start to download the // fallback font. @@ -98,7 +99,7 @@ void testMain() { await fontChangeCompleter.future; - expect(skiaFontCollection.globalFontFallbacks, + expect(FontFallbackData.instance.globalFontFallbacks, contains('Noto Naskh Arabic UI 0')); final CkPictureRecorder recorder = CkPictureRecorder(); @@ -151,7 +152,7 @@ void testMain() { } '''; - expect(skiaFontCollection.globalFontFallbacks, ['Roboto']); + expect(FontFallbackData.instance.globalFontFallbacks, ['Roboto']); // Creating this paragraph should cause us to start to download the // fallback font. @@ -162,7 +163,7 @@ void testMain() { await fontChangeCompleter.future; - expect(skiaFontCollection.globalFontFallbacks, + expect(FontFallbackData.instance.globalFontFallbacks, contains('Noto Color Emoji Compat 0')); final CkPictureRecorder recorder = CkPictureRecorder(); @@ -191,7 +192,7 @@ void testMain() { 'https://fonts.googleapis.com/css2?family=Noto+Naskh+Arabic+UI'] = 'invalid CSS... this should cause our parser to fail'; - expect(skiaFontCollection.globalFontFallbacks, ['Roboto']); + expect(FontFallbackData.instance.globalFontFallbacks, ['Roboto']); // Creating this paragraph should cause us to start to download the // fallback font. @@ -204,7 +205,117 @@ void testMain() { await Future.delayed(Duration.zero); expect(notoDownloadQueue.isPending, isFalse); - expect(skiaFontCollection.globalFontFallbacks, ['Roboto']); + expect(FontFallbackData.instance.globalFontFallbacks, ['Roboto']); + }); + + // Regression test for https://github.com/flutter/flutter/issues/75836 + // When we had this bug our font fallback resolution logic would end up in an + // infinite loop and this test would freeze and time out. + test('Can find fonts for two adjacent unmatched code units from different fonts', () async { + final LoggingDownloader loggingDownloader = LoggingDownloader(NotoDownloader()); + notoDownloadQueue.downloader = loggingDownloader; + // Try rendering text that requires fallback fonts, initially before the fonts are loaded. + + CkParagraphBuilder(CkParagraphStyle()).addText('ヽಠ'); + await notoDownloadQueue.downloader.debugWhenIdle(); + expect( + loggingDownloader.log, + [ + 'https://fonts.googleapis.com/css2?family=Noto+Sans+SC', + 'https://fonts.googleapis.com/css2?family=Noto+Sans+JP', + 'https://fonts.googleapis.com/css2?family=Noto+Sans+Kannada+UI', + 'Noto Sans SC', + 'Noto Sans JP', + 'Noto Sans Kannada UI', + ], + ); + + // Do the same thing but this time with loaded fonts. + loggingDownloader.log.clear(); + CkParagraphBuilder(CkParagraphStyle()).addText('ヽಠ'); + await notoDownloadQueue.downloader.debugWhenIdle(); + expect(loggingDownloader.log, isEmpty); + }); + + test('findMinimumFontsForCodeunits for all supported code units', () async { + final LoggingDownloader loggingDownloader = LoggingDownloader(NotoDownloader()); + notoDownloadQueue.downloader = loggingDownloader; + + // Collect all supported code units from all fallback fonts in the Noto + // font tree. + final Set testedFonts = {}; + final Set supportedUniqueCodeUnits = {}; + final IntervalTree notoTree = FontFallbackData.instance.notoTree; + for (NotoFont font in notoTree.root.enumerateAllElements()) { + testedFonts.add(font.name); + for (CodeunitRange range in font.approximateUnicodeRanges) { + for (int codeUnit = range.start; codeUnit < range.end; codeUnit += 1) { + supportedUniqueCodeUnits.add(codeUnit); + } + } + } + + expect(supportedUniqueCodeUnits.length, greaterThan(10000)); // sanity check + expect(testedFonts, unorderedEquals({ + 'Noto Sans', + 'Noto Sans Malayalam UI', + 'Noto Sans Armenian', + 'Noto Sans Georgian', + 'Noto Sans Hebrew', + 'Noto Naskh Arabic UI', + 'Noto Sans Devanagari UI', + 'Noto Sans Telugu UI', + 'Noto Sans Tamil UI', + 'Noto Sans Kannada UI', + 'Noto Sans Sinhala', + 'Noto Sans Gurmukhi UI', + 'Noto Sans Gujarati UI', + 'Noto Sans Bengali UI', + 'Noto Sans Thai UI', + 'Noto Sans Lao UI', + 'Noto Sans Myanmar UI', + 'Noto Sans Ethiopic', + 'Noto Sans Khmer UI', + 'Noto Sans SC', + 'Noto Sans JP', + 'Noto Sans TC', + 'Noto Sans HK', + 'Noto Sans KR', + 'Noto Sans Egyptian Hieroglyphs', + })); + + // Construct random paragraphs out of supported code units. + final math.Random random = math.Random(0); + final List supportedCodeUnits = supportedUniqueCodeUnits.toList()..shuffle(random); + const int paragraphLength = 3; + + for (int batchStart = 0; batchStart < supportedCodeUnits.length; batchStart += paragraphLength) { + final int batchEnd = math.min(batchStart + paragraphLength, supportedCodeUnits.length); + final Set codeUnits = {}; + for (int i = batchStart; i < batchEnd; i += 1) { + codeUnits.add(supportedCodeUnits[i]); + } + final Set fonts = {}; + for (int codeUnit in codeUnits) { + List fontsForUnit = notoTree.intersections(codeUnit); + + // All code units are extracted from the same tree, so there must + // be at least one font supporting each code unit + expect(fontsForUnit, isNotEmpty); + fonts.addAll(fontsForUnit); + } + + try { + findMinimumFontsForCodeUnits(codeUnits, fonts); + } catch (e) { + print( + 'findMinimumFontsForCodeunits failed:\n' + ' Code units: ${codeUnits.join(', ')}\n' + ' Fonts: ${fonts.map((f) => f.name).join(', ')}', + ); + rethrow; + } + } }); // TODO: https://github.com/flutter/flutter/issues/60040 }, skip: isIosSafari); @@ -213,7 +324,7 @@ void testMain() { class TestDownloader extends NotoDownloader { static final Map mockDownloads = {}; @override - Future downloadAsString(String url) async { + Future downloadAsString(String url, {String? debugDescription}) async { if (mockDownloads.containsKey(url)) { return mockDownloads[url]!; } else { @@ -221,3 +332,31 @@ class TestDownloader extends NotoDownloader { } } } + +class LoggingDownloader implements NotoDownloader { + final List log = []; + + LoggingDownloader(this.delegate); + + final NotoDownloader delegate; + + @override + Future debugWhenIdle() { + return delegate.debugWhenIdle(); + } + + @override + Future downloadAsBytes(String url, {String? debugDescription}) { + log.add(debugDescription ?? url); + return delegate.downloadAsBytes(url); + } + + @override + Future downloadAsString(String url, {String? debugDescription}) { + log.add(debugDescription ?? url); + return delegate.downloadAsString(url); + } + + @override + int get debugActiveDownloadCount => delegate.debugActiveDownloadCount; +} diff --git a/testing/dart/codec_test.dart b/testing/dart/codec_test.dart index 961f54f4ace6d..649aba4562eab 100644 --- a/testing/dart/codec_test.dart +++ b/testing/dart/codec_test.dart @@ -34,6 +34,18 @@ void main() { ); }); + test('getNextFrame fails with invalid data', () async { + Uint8List data = await _getSkiaResource('flutter_logo.jpg').readAsBytes(); + data = Uint8List.view(data.buffer, 0, 4000); + final ui.Codec codec = await ui.instantiateImageCodec(data); + try { + await codec.getNextFrame(); + fail('exception not thrown'); + } catch(e) { + expect(e, exceptionWithMessage('Codec failed')); + } + }); + test('nextFrame', () async { final Uint8List data = await _getSkiaResource('test640x479.gif').readAsBytes(); final ui.Codec codec = await ui.instantiateImageCodec(data);