From 5b9cd44b1ad4e14f83c97713401a275303232a1c Mon Sep 17 00:00:00 2001 From: Harry Terkelsen Date: Thu, 21 Jan 2021 12:58:33 -0800 Subject: [PATCH] Automatically download Noto fonts as backup fonts in CanvasKit mode (#23728) * Revert "Revert "[CanvasKit] Automatically fall back to Noto fonts (#23096)" (#23357)" This reverts commit f9f4d0168dc6e03324844db942a4bca717ebb2e0. * WIP * Use an Interval Tree to store the unicode ranges for the Noto Fonts * Update licenses * Remove debug print statements * Respond to comments * Fix analysis error * Add tests * Respond to comments * Fix test * Update goldens lock * Skip screenshot test on Safari * Skip CanvasKit tests on iOS Safari * Move CanvasKit initialization so it doesn't run on iOS Safari --- ci/licenses_golden/licenses_flutter | 2 + lib/web_ui/dev/goldens_lock.yaml | 2 +- lib/web_ui/dev/test_runner.dart | 15 +- lib/web_ui/lib/src/engine.dart | 2 + .../src/engine/canvaskit/canvaskit_api.dart | 3 +- .../src/engine/canvaskit/font_fallbacks.dart | 684 ++++++++++++++++++ .../lib/src/engine/canvaskit/fonts.dart | 89 ++- .../src/engine/canvaskit/initialization.dart | 5 +- .../src/engine/canvaskit/interval_tree.dart | 114 +++ lib/web_ui/lib/src/engine/canvaskit/text.dart | 115 ++- lib/web_ui/lib/src/engine/util.dart | 84 +-- .../canvaskit/fallback_fonts_golden_test.dart | 155 ++++ .../test/canvaskit/interval_tree_test.dart | 85 +++ 13 files changed, 1243 insertions(+), 112 deletions(-) create mode 100644 lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart create mode 100644 lib/web_ui/lib/src/engine/canvaskit/interval_tree.dart create mode 100644 lib/web_ui/test/canvaskit/fallback_fonts_golden_test.dart create mode 100644 lib/web_ui/test/canvaskit/interval_tree_test.dart diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index 0763f58c4..2cf7f3024 100644 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -434,10 +434,12 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/canvaskit_canvas.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/color_filter.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/fonts.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/image_filter.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/initialization.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/interval_tree.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/layer.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/layer_scene_builder.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/layer_tree.dart diff --git a/lib/web_ui/dev/goldens_lock.yaml b/lib/web_ui/dev/goldens_lock.yaml index c975dc3d2..ac002b153 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: 999507db8c924635a605325252702bad661e2ad2 +revision: bdb442c42588b25c657779c78523822e349742d5 diff --git a/lib/web_ui/dev/test_runner.dart b/lib/web_ui/dev/test_runner.dart index 40ef74757..842a5318b 100644 --- a/lib/web_ui/dev/test_runner.dart +++ b/lib/web_ui/dev/test_runner.dart @@ -234,7 +234,7 @@ class TestCommand extends Command with ArgUtils { Future runIntegrationTests() async { // Parse additional arguments specific for integration testing. IntegrationTestsArgumentParser.instance.parseOptions(argResults); - if(!_testPreparationReady) { + if (!_testPreparationReady) { await _prepare(); } return IntegrationTestsManager( @@ -522,9 +522,12 @@ class TestCommand extends Command with ArgUtils { } // All files under test/golden_tests are considered golden tests. - final bool isUnderGoldenTestsDirectory = path.split(testFilePath.relativeToWebUi).contains('golden_tests'); + final bool isUnderGoldenTestsDirectory = + path.split(testFilePath.relativeToWebUi).contains('golden_tests'); // Any file whose name ends with "_golden_test.dart" is run as a golden test. - final bool isGoldenTestFile = path.basename(testFilePath.relativeToWebUi).endsWith('_golden_test.dart'); + final bool isGoldenTestFile = path + .basename(testFilePath.relativeToWebUi) + .endsWith('_golden_test.dart'); if (isUnderGoldenTestsDirectory || isGoldenTestFile) { screenshotTestFiles.add(testFilePath); } else { @@ -767,7 +770,11 @@ class TestCommand extends Command with ArgUtils { } } -const List _kTestFonts = ['ahem.ttf', 'Roboto-Regular.ttf']; +const List _kTestFonts = [ + 'ahem.ttf', + 'Roboto-Regular.ttf', + 'NotoNaskhArabic-Regular.ttf', +]; void _copyTestFontsIntoWebUi() { final String fontsPath = path.join( diff --git a/lib/web_ui/lib/src/engine.dart b/lib/web_ui/lib/src/engine.dart index e4460f411..726e3b31d 100644 --- a/lib/web_ui/lib/src/engine.dart +++ b/lib/web_ui/lib/src/engine.dart @@ -32,9 +32,11 @@ part 'engine/canvaskit/canvaskit_api.dart'; part 'engine/canvaskit/color_filter.dart'; part 'engine/canvaskit/embedded_views.dart'; part 'engine/canvaskit/fonts.dart'; +part 'engine/canvaskit/font_fallbacks.dart'; part 'engine/canvaskit/image.dart'; part 'engine/canvaskit/image_filter.dart'; part 'engine/canvaskit/initialization.dart'; +part 'engine/canvaskit/interval_tree.dart'; part 'engine/canvaskit/layer.dart'; part 'engine/canvaskit/layer_scene_builder.dart'; part 'engine/canvaskit/layer_tree.dart'; 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 d74f68982..880f8b776 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart @@ -1177,7 +1177,8 @@ class SkPath { /// Serializes the path into a list of commands. /// - /// The list can be used to create a new [SkPath] using [CanvasKit.Path.MakeFromCmds]. + /// The list can be used to create a new [SkPath] using + /// [CanvasKit.Path.MakeFromCmds]. external List toCmds(); external void delete(); diff --git a/lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart b/lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart new file mode 100644 index 000000000..5c6ad00aa --- /dev/null +++ b/lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart @@ -0,0 +1,684 @@ +// 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. + +// @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; + +final Set codeUnitsWithNoKnownFont = {}; + +Future _findFontsForMissingCodeunits(List codeunits) async { + _ensureNotoFontTreeCreated(); + + // 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))) { + return; + } + Set<_NotoFont> fonts = <_NotoFont>{}; + Set coveredCodeUnits = {}; + Set missingCodeUnits = {}; + for (int codeunit in codeunits) { + List<_NotoFont> fontsForUnit = _notoTree!.intersections(codeunit); + fonts.addAll(fontsForUnit); + if (fontsForUnit.isNotEmpty) { + coveredCodeUnits.add(codeunit); + } else { + missingCodeUnits.add(codeunit); + } + } + + fonts = _findMinimumFontsForCodeunits(coveredCodeUnits, fonts); + + for (_NotoFont font in fonts) { + await font.ensureResolved(); + } + + Set<_ResolvedNotoSubset> resolvedFonts = <_ResolvedNotoSubset>{}; + 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)); + } + } + + for (_ResolvedNotoSubset resolvedFont in resolvedFonts) { + notoDownloadQueue.add(resolvedFont); + } + + if (missingCodeUnits.isNotEmpty && !notoDownloadQueue.isPending) { + if (!_registeredSymbolsAndEmoji) { + _registerSymbolsAndEmoji(); + } else { + if (!notoDownloadQueue.isPending) { + html.window.console.log( + '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); + } + } + } +} + +/// Parse the CSS file for a font and make a list of resolved subsets. +/// +/// A CSS file from Google Fonts looks like this: +/// +/// /* [0] */ +/// @font-face { +/// font-family: 'Noto Sans KR'; +/// font-style: normal; +/// font-weight: 400; +/// src: url(https://fonts.gstatic.com/s/notosanskr/v13/PbykFmXiEBPT4ITbgNA5Cgm20xz64px_1hVWr0wuPNGmlQNMEfD4.0.woff2) format('woff2'); +/// unicode-range: U+f9ca-fa0b, U+ff03-ff05, U+ff07, U+ff0a-ff0b, U+ff0d-ff19, U+ff1b, U+ff1d, U+ff20-ff5b, U+ff5d, U+ffe0-ffe3, U+ffe5-ffe6; +/// } +/// /* [1] */ +/// @font-face { +/// font-family: 'Noto Sans KR'; +/// font-style: normal; +/// font-weight: 400; +/// src: url(https://fonts.gstatic.com/s/notosanskr/v13/PbykFmXiEBPT4ITbgNA5Cgm20xz64px_1hVWr0wuPNGmlQNMEfD4.1.woff2) format('woff2'); +/// unicode-range: U+f92f-f980, U+f982-f9c9; +/// } +/// /* [2] */ +/// @font-face { +/// font-family: 'Noto Sans KR'; +/// font-style: normal; +/// font-weight: 400; +/// src: url(https://fonts.gstatic.com/s/notosanskr/v13/PbykFmXiEBPT4ITbgNA5Cgm20xz64px_1hVWr0wuPNGmlQNMEfD4.2.woff2) format('woff2'); +/// unicode-range: U+d723-d728, U+d72a-d733, U+d735-d748, U+d74a-d74f, U+d752-d753, U+d755-d757, U+d75a-d75f, U+d762-d764, U+d766-d768, U+d76a-d76b, U+d76d-d76f, U+d771-d787, U+d789-d78b, U+d78d-d78f, U+d791-d797, U+d79a, U+d79c, U+d79e-d7a3, U+f900-f909, U+f90b-f92e; +/// } +_ResolvedNotoFont? _makeResolvedNotoFontFromCss(String css, String name) { + List<_ResolvedNotoSubset> subsets = <_ResolvedNotoSubset>[]; + bool resolvingFontFace = false; + String? fontFaceUrl; + List? fontFaceUnicodeRanges; + for (final String line in LineSplitter.split(css)) { + // Search for the beginning of a @font-face. + if (!resolvingFontFace) { + if (line == '@font-face {') { + resolvingFontFace = true; + } else { + continue; + } + } else { + // We are resolving a @font-face, read out the url and ranges. + if (line.startsWith(' src:')) { + int urlStart = line.indexOf('url('); + if (urlStart == -1) { + html.window.console.warn('Unable to resolve Noto font URL: $line'); + return null; + } + int urlEnd = line.indexOf(')'); + fontFaceUrl = line.substring(urlStart + 4, urlEnd); + } else if (line.startsWith(' unicode-range:')) { + fontFaceUnicodeRanges = []; + String rangeString = line.substring(17, line.length - 1); + List rawRanges = rangeString.split(', '); + for (final String rawRange in rawRanges) { + List startEnd = rawRange.split('-'); + if (startEnd.length == 1) { + String singleRange = startEnd.single; + assert(singleRange.startsWith('U+')); + int rangeValue = int.parse(singleRange.substring(2), radix: 16); + fontFaceUnicodeRanges.add(CodeunitRange(rangeValue, rangeValue)); + } else { + assert(startEnd.length == 2); + String startRange = startEnd[0]; + String endRange = startEnd[1]; + assert(startRange.startsWith('U+')); + int startValue = int.parse(startRange.substring(2), radix: 16); + int endValue = int.parse(endRange, radix: 16); + fontFaceUnicodeRanges.add(CodeunitRange(startValue, endValue)); + } + } + } else if (line == '}') { + if (fontFaceUrl == null || fontFaceUnicodeRanges == null) { + html.window.console.warn('Unable to parse Google Fonts CSS: $css'); + return null; + } + subsets + .add(_ResolvedNotoSubset(fontFaceUrl, name, fontFaceUnicodeRanges)); + resolvingFontFace = false; + } else { + continue; + } + } + } + + if (resolvingFontFace) { + html.window.console.warn('Unable to parse Google Fonts CSS: $css'); + return null; + } + + Map<_ResolvedNotoSubset, List> rangesMap = + <_ResolvedNotoSubset, List>{}; + for (_ResolvedNotoSubset subset in subsets) { + for (CodeunitRange range in subset.ranges) { + rangesMap.putIfAbsent(subset, () => []).add(range); + } + } + + IntervalTree<_ResolvedNotoSubset> tree = + IntervalTree<_ResolvedNotoSubset>.createFromRanges(rangesMap); + + return _ResolvedNotoFont(name, subsets, tree); +} + +/// In the case where none of the known Noto Fonts cover a set of code units, +/// 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) { + return; + } + _registeredSymbolsAndEmoji = true; + const String symbolsUrl = + 'https://fonts.googleapis.com/css2?family=Noto+Sans+Symbols'; + const String emojiUrl = + 'https://fonts.googleapis.com/css2?family=Noto+Color+Emoji+Compat'; + + String symbolsCss = + await notoDownloadQueue.downloader.downloadAsString(symbolsUrl); + String emojiCss = + await notoDownloadQueue.downloader.downloadAsString(emojiUrl); + + String? extractUrlFromCss(String css) { + for (final String line in LineSplitter.split(css)) { + if (line.startsWith(' src:')) { + int urlStart = line.indexOf('url('); + if (urlStart == -1) { + html.window.console.warn('Unable to resolve Noto font URL: $line'); + return null; + } + int urlEnd = line.indexOf(')'); + return line.substring(urlStart + 4, urlEnd); + } + } + html.window.console.warn('Unable to determine URL for Noto font'); + return null; + } + + String? symbolsFontUrl = extractUrlFromCss(symbolsCss); + String? emojiFontUrl = extractUrlFromCss(emojiCss); + + if (symbolsFontUrl == null || emojiFontUrl == null) { + html.window.console + .warn('Error parsing CSS for Noto Emoji and Symbols font.'); + } + + notoDownloadQueue.add(_ResolvedNotoSubset( + symbolsFontUrl!, 'Noto Sans Symbols', const [])); + notoDownloadQueue.add(_ResolvedNotoSubset( + emojiFontUrl!, 'Noto Color Emoji Compat', const [])); +} + +/// Finds the minimum set of fonts which covers all of the [codeunits]. +/// +/// 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 +/// 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; + + 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) { + for (var font in fonts) { + int codeunitsCovered = 0; + for (int codeunit in unmatchedCodeunits) { + if (font.matchesCodeunit(codeunit)) { + codeunitsCovered++; + } + } + if (codeunitsCovered > maxCodeunitsCovered) { + bestFonts.clear(); + bestFonts.add(font); + maxCodeunitsCovered = codeunitsCovered; + } else if (codeunitsCovered == maxCodeunitsCovered) { + bestFonts.add(font); + } + } + assert(bestFonts.isNotEmpty); + // 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; + if (bestFonts.length > 1) { + if (bestFonts.every((font) => _cjkFonts.contains(font))) { + if (language == 'zh-Hans' || + language == 'zh-CN' || + language == 'zh-SG' || + language == 'zh-MY') { + if (bestFonts.contains(_notoSansSC)) { + bestFont = _notoSansSC; + } + } else if (language == 'zh-Hant' || + language == 'zh-TW' || + language == 'zh-MO') { + if (bestFonts.contains(_notoSansTC)) { + bestFont = _notoSansTC; + } + } else if (language == 'zh-HK') { + if (bestFonts.contains(_notoSansHK)) { + bestFont = _notoSansHK; + } + } else if (language == 'ja') { + if (bestFonts.contains(_notoSansJP)) { + bestFont = _notoSansJP; + } + } + } + } + unmatchedCodeunits + .removeWhere((codeunit) => bestFont.matchesCodeunit(codeunit)); + minimumFonts.add(bestFont); + } + 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 { + final String name; + final List unicodeRanges; + + 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; + } + + String get googleFontsCssUrl => + 'https://fonts.googleapis.com/css2?family=${name.replaceAll(' ', '+')}'; + + Future ensureResolved() async { + if (resolvedFont == null) { + if (_decodingCompleter == null) { + _decodingCompleter = Completer(); + String googleFontCss = await notoDownloadQueue.downloader + .downloadAsString(googleFontsCssUrl); + final _ResolvedNotoFont? googleFont = + _makeResolvedNotoFontFromCss(googleFontCss, name); + resolvedFont = googleFont; + _decodingCompleter!.complete(); + } else { + await _decodingCompleter!.future; + } + } + } +} + +class CodeunitRange { + final int start; + final int end; + + const CodeunitRange(this.start, this.end); + + bool contains(int codeUnit) { + return start <= codeUnit && codeUnit <= end; + } + + @override + bool operator ==(dynamic other) { + if (other is! CodeunitRange) { + return false; + } + CodeunitRange range = other; + return range.start == start && range.end == end; + } + + @override + int get hashCode => ui.hashValues(start, end); + + @override + String toString() => '[$start, $end]'; +} + +class _ResolvedNotoFont { + final String name; + final List<_ResolvedNotoSubset> subsets; + final IntervalTree<_ResolvedNotoSubset> tree; + + const _ResolvedNotoFont(this.name, this.subsets, this.tree); +} + +class _ResolvedNotoSubset { + final String url; + final String family; + final List ranges; + + _ResolvedNotoSubset(this.url, this.family, this.ranges); +} + +_NotoFont _notoSansSC = _NotoFont('Noto Sans SC', [ + CodeunitRange(12288, 12591), + CodeunitRange(12800, 13311), + CodeunitRange(19968, 40959), + CodeunitRange(65072, 65135), + CodeunitRange(65280, 65519), +]); + +_NotoFont _notoSansTC = _NotoFont('Noto Sans TC', [ + CodeunitRange(12288, 12351), + CodeunitRange(12549, 12585), + CodeunitRange(19968, 40959), +]); + +_NotoFont _notoSansHK = _NotoFont('Noto Sans HK', [ + CodeunitRange(12288, 12351), + CodeunitRange(12549, 12585), + CodeunitRange(19968, 40959), +]); + +_NotoFont _notoSansJP = _NotoFont('Noto Sans JP', [ + CodeunitRange(12288, 12543), + CodeunitRange(19968, 40959), + CodeunitRange(65280, 65519), +]); + +List<_NotoFont> _cjkFonts = <_NotoFont>[ + _notoSansSC, + _notoSansTC, + _notoSansHK, + _notoSansJP, +]; + +List<_NotoFont> _notoFonts = <_NotoFont>[ + _notoSansSC, + _notoSansTC, + _notoSansHK, + _notoSansJP, + _NotoFont('Noto Naskh Arabic UI', [ + CodeunitRange(1536, 1791), + CodeunitRange(8204, 8206), + CodeunitRange(8208, 8209), + CodeunitRange(8271, 8271), + CodeunitRange(11841, 11841), + CodeunitRange(64336, 65023), + CodeunitRange(65132, 65276), + ]), + _NotoFont('Noto Sans Armenian', [ + CodeunitRange(1328, 1424), + CodeunitRange(64275, 64279), + ]), + _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', [ + CodeunitRange(4096, 4255), + CodeunitRange(8204, 8205), + CodeunitRange(9676, 9676), + ]), + _NotoFont('Noto Sans Egyptian Hieroglyphs', [ + CodeunitRange(77824, 78894), + ]), + _NotoFont('Noto Sans Ethiopic', [ + CodeunitRange(4608, 5017), + CodeunitRange(11648, 11742), + CodeunitRange(43777, 43822), + ]), + _NotoFont('Noto Sans Georgian', [ + CodeunitRange(1417, 1417), + CodeunitRange(4256, 4351), + CodeunitRange(11520, 11567), + ]), + _NotoFont('Noto Sans Gujarati UI', [ + CodeunitRange(2404, 2405), + CodeunitRange(2688, 2815), + CodeunitRange(8204, 8205), + CodeunitRange(8377, 8377), + CodeunitRange(9676, 9676), + CodeunitRange(43056, 43065), + ]), + _NotoFont('Noto Sans Gurmukhi UI', [ + CodeunitRange(2404, 2405), + CodeunitRange(2561, 2677), + CodeunitRange(8204, 8205), + CodeunitRange(8377, 8377), + CodeunitRange(9676, 9676), + CodeunitRange(9772, 9772), + CodeunitRange(43056, 43065), + ]), + _NotoFont('Noto Sans Hebrew', [ + CodeunitRange(1424, 1535), + CodeunitRange(8362, 8362), + CodeunitRange(9676, 9676), + CodeunitRange(64285, 64335), + ]), + _NotoFont('Noto Sans Devanagari UI', [ + CodeunitRange(2304, 2431), + CodeunitRange(7376, 7414), + CodeunitRange(7416, 7417), + CodeunitRange(8204, 9205), + CodeunitRange(8360, 8360), + CodeunitRange(8377, 8377), + CodeunitRange(9676, 9676), + CodeunitRange(43056, 43065), + CodeunitRange(43232, 43259), + ]), + _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', [ + CodeunitRange(6016, 6143), + CodeunitRange(8204, 8204), + CodeunitRange(9676, 9676), + ]), + _NotoFont('Noto Sans KR', [ + CodeunitRange(12593, 12686), + CodeunitRange(12800, 12828), + CodeunitRange(12896, 12923), + CodeunitRange(44032, 55215), + ]), + _NotoFont('Noto Sans Lao UI', [ + CodeunitRange(3713, 3807), + CodeunitRange(9676, 9676), + ]), + _NotoFont('Noto Sans Malayalam UI', [ + CodeunitRange(775, 775), + CodeunitRange(803, 803), + CodeunitRange(2404, 2405), + CodeunitRange(3330, 3455), + CodeunitRange(8204, 8205), + CodeunitRange(8377, 8377), + CodeunitRange(9676, 9676), + ]), + _NotoFont('Noto Sans Sinhala', [ + CodeunitRange(2404, 2405), + CodeunitRange(3458, 3572), + CodeunitRange(8204, 8205), + CodeunitRange(9676, 9676), + ]), + _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', [ + CodeunitRange(2385, 2386), + CodeunitRange(2404, 2405), + CodeunitRange(3072, 3199), + CodeunitRange(7386, 7386), + CodeunitRange(8204, 8205), + CodeunitRange(9676, 9676), + ]), + _NotoFont('Noto Sans Thai UI', [ + CodeunitRange(3585, 3675), + CodeunitRange(8204, 8205), + CodeunitRange(9676, 9676), + ]), + _NotoFont('Noto Sans', [ + CodeunitRange(0, 255), + CodeunitRange(305, 305), + CodeunitRange(338, 339), + CodeunitRange(699, 700), + CodeunitRange(710, 710), + CodeunitRange(730, 730), + CodeunitRange(732, 732), + CodeunitRange(8192, 8303), + CodeunitRange(8308, 8308), + CodeunitRange(8364, 8364), + CodeunitRange(8482, 8482), + CodeunitRange(8593, 8593), + CodeunitRange(8595, 8595), + CodeunitRange(8722, 8722), + CodeunitRange(8725, 8725), + CodeunitRange(65279, 65279), + CodeunitRange(65533, 65533), + CodeunitRange(1024, 1119), + CodeunitRange(1168, 1169), + CodeunitRange(1200, 1201), + CodeunitRange(8470, 8470), + CodeunitRange(1120, 1327), + CodeunitRange(7296, 7304), + CodeunitRange(8372, 8372), + CodeunitRange(11744, 11775), + CodeunitRange(42560, 42655), + CodeunitRange(65070, 65071), + CodeunitRange(880, 1023), + CodeunitRange(7936, 8191), + CodeunitRange(256, 591), + CodeunitRange(601, 601), + CodeunitRange(7680, 7935), + CodeunitRange(8224, 8224), + CodeunitRange(8352, 8363), + CodeunitRange(8365, 8399), + CodeunitRange(8467, 8467), + CodeunitRange(11360, 11391), + CodeunitRange(42784, 43007), + CodeunitRange(258, 259), + CodeunitRange(272, 273), + CodeunitRange(296, 297), + CodeunitRange(360, 361), + CodeunitRange(416, 417), + CodeunitRange(431, 432), + CodeunitRange(7840, 7929), + CodeunitRange(8363, 8363), + ]), +]; + +class FallbackFontDownloadQueue { + NotoDownloader downloader = NotoDownloader(); + + final Set<_ResolvedNotoSubset> downloadedSubsets = <_ResolvedNotoSubset>{}; + final Set<_ResolvedNotoSubset> pendingSubsets = <_ResolvedNotoSubset>{}; + + bool get isPending => pendingSubsets.isNotEmpty; + + void add(_ResolvedNotoSubset subset) { + if (downloadedSubsets.contains(subset) || pendingSubsets.contains(subset)) { + return; + } + bool firstInBatch = pendingSubsets.isEmpty; + pendingSubsets.add(subset); + if (firstInBatch) { + Timer.run(startDownloads); + } + } + + Future startDownloads() async { + List> downloads = []; + for (_ResolvedNotoSubset subset in pendingSubsets) { + downloads.add(Future(() async { + ByteBuffer buffer; + try { + buffer = await downloader.downloadAsBytes(subset.url); + } catch (e) { + 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(); + } + })); + } + + await Future.wait(downloads); + if (pendingSubsets.isNotEmpty) { + await startDownloads(); + } + } +} + +class NotoDownloader { + /// Downloads the [url] and returns it as a [ByteBuffer]. + /// + /// Override this for testing. + Future downloadAsBytes(String url) { + return html.window.fetch(url).then((dynamic fetchResult) => fetchResult + .arrayBuffer() + .then((dynamic x) => x as ByteBuffer)); + } + + /// Downloads the [url] and returns is as a [String]. + /// + /// Override this for testing. + Future downloadAsString(String url) { + return html.window.fetch(url).then((dynamic response) => + response.text().then((dynamic x) => x as String)); + } +} + +/// 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 2a5589d61..d3692a887 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/fonts.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/fonts.dart @@ -22,15 +22,38 @@ class SkiaFontCollection { /// Fonts which have been registered and loaded. final List<_RegisteredFont> _registeredFonts = <_RegisteredFont>[]; - final Set registeredFamilies = {}; + /// Fallback fonts which have been registered and loaded. + final List<_RegisteredFont> _registeredFallbackFonts = <_RegisteredFont>[]; + + final Map> familyToTypefaceMap = + >{}; + + final List globalFontFallbacks = []; + + final Map _fontFallbackCounts = {}; Future ensureFontsLoaded() async { await _loadFonts(); + if (fontProvider != null) { + fontProvider!.delete(); + fontProvider = null; + } fontProvider = canvasKit.TypefaceFontProvider.Make(); + familyToTypefaceMap.clear(); for (var font in _registeredFonts) { - fontProvider.registerFont(font.bytes, font.flutterFamily); + fontProvider!.registerFont(font.bytes, font.family); + familyToTypefaceMap + .putIfAbsent(font.family, () => []) + .add(font.typeface); + } + + for (var font in _registeredFallbackFonts) { + fontProvider!.registerFont(font.bytes, font.family); + familyToTypefaceMap + .putIfAbsent(font.family, () => []) + .add(font.typeface); } } @@ -51,24 +74,16 @@ class SkiaFontCollection { } Future loadFontFromList(Uint8List list, {String? fontFamily}) async { - String? actualFamily = _readActualFamilyName(list); - - if (actualFamily == null) { + if (fontFamily == null) { + fontFamily = _readActualFamilyName(list); if (fontFamily == null) { html.window.console .warn('Failed to read font family name. Aborting font load.'); return; } - actualFamily = fontFamily; } - if (fontFamily == null) { - fontFamily = actualFamily; - } - - registeredFamilies.add(fontFamily); - - _registeredFonts.add(_RegisteredFont(list, fontFamily, actualFamily)); + _registeredFonts.add(_RegisteredFont(list, fontFamily)); await ensureFontsLoaded(); } @@ -94,12 +109,16 @@ class SkiaFontCollection { 'There was a problem trying to load FontManifest.json'); } + bool registeredRoboto = false; + for (Map fontFamily in fontManifest.cast>()) { final String family = fontFamily['family']!; final List fontAssets = fontFamily['fonts']; - registeredFamilies.add(family); + if (family == 'Roboto') { + registeredRoboto = true; + } for (dynamic fontAssetItem in fontAssets) { final Map fontAsset = fontAssetItem; @@ -112,7 +131,7 @@ class SkiaFontCollection { /// We need a default fallback font for CanvasKit, in order to /// avoid crashing while laying out text with an unregistered font. We chose /// Roboto to match Android. - if (!registeredFamilies.contains('Roboto')) { + if (!registeredRoboto) { // Download Roboto and add it to the font buffers. _unloadedFonts.add(_registerFont(_robotoUrl, 'Roboto')); } @@ -129,15 +148,16 @@ class SkiaFontCollection { } final Uint8List bytes = buffer.asUint8List(); - String? actualFamily = _readActualFamilyName(bytes); - - if (actualFamily == null) { - html.window.console.warn('Failed to determine the actual name of the ' - 'font $family at $url. Defaulting to $family.'); - actualFamily = family; - } + return _RegisteredFont(bytes, family); + } - return _RegisteredFont(bytes, family, actualFamily); + 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) { @@ -154,20 +174,31 @@ class SkiaFontCollection { .then((dynamic x) => x as ByteBuffer); } + /// Resets the fallback fonts. Used for tests. + void debugResetFallbackFonts() { + _registeredFallbackFonts.clear(); + globalFontFallbacks.clear(); + _fontFallbackCounts.clear(); + } + SkFontMgr? skFontMgr; - late TypefaceFontProvider fontProvider; + TypefaceFontProvider? fontProvider; } /// Represents a font that has been registered. class _RegisteredFont { - /// The font family that the font was declared to have by Flutter. - final String flutterFamily; + /// The font family name for this font. + final String family; /// The byte data for this font. final Uint8List bytes; - /// The font family that was parsed from the font's bytes. - final String actualFamily; + /// The [SkTypeface] created from this font's [bytes]. + /// + /// This is used to determine which code points are supported by this font. + final SkTypeface typeface; - _RegisteredFont(this.bytes, this.flutterFamily, this.actualFamily); + _RegisteredFont(this.bytes, this.family) + : this.typeface = + canvasKit.FontMgr.RefDefault().MakeTypefaceFromData(bytes); } diff --git a/lib/web_ui/lib/src/engine/canvaskit/initialization.dart b/lib/web_ui/lib/src/engine/canvaskit/initialization.dart index 308c7e61e..da7204a95 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/initialization.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/initialization.dart @@ -40,7 +40,10 @@ const bool _autoDetect = const bool _useSkia = bool.fromEnvironment('FLUTTER_WEB_USE_SKIA', defaultValue: false); -// If set to true, forces CPU-only rendering (i.e. no WebGL). +/// If set to true, forces CPU-only rendering (i.e. no WebGL). +/// +/// This is mainly used for testing or for apps that want to ensure they +/// run on devices which don't support WebGL. const bool canvasKitForceCpuOnly = bool.fromEnvironment( 'FLUTTER_WEB_CANVASKIT_FORCE_CPU_ONLY', defaultValue: false); diff --git a/lib/web_ui/lib/src/engine/canvaskit/interval_tree.dart b/lib/web_ui/lib/src/engine/canvaskit/interval_tree.dart new file mode 100644 index 000000000..88a097e65 --- /dev/null +++ b/lib/web_ui/lib/src/engine/canvaskit/interval_tree.dart @@ -0,0 +1,114 @@ +// 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. + +// @dart = 2.12 +part of engine; + +/// A tree which stores a set of intervals that can be queried for intersection. +class IntervalTree { + /// The root node of the interval tree. + final IntervalTreeNode root; + + IntervalTree._(this.root); + + /// Creates an interval tree from a mapping of [T] values to a list of ranges. + /// + /// 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) { + // Get a list of all the ranges ordered by start index. + List> intervals = >[]; + rangesMap.forEach((T key, List rangeList) { + for (CodeunitRange range in rangeList) { + intervals.add(IntervalTreeNode(key, range.start, range.end)); + } + }); + + intervals + .sort((IntervalTreeNode a, IntervalTreeNode b) => a.low - b.low); + + // Make a balanced binary search tree from the nodes sorted by low value. + IntervalTreeNode? _makeBalancedTree(List> nodes) { + if (nodes.length == 0) { + return null; + } + if (nodes.length == 1) { + return nodes.single; + } + int mid = nodes.length ~/ 2; + IntervalTreeNode root = nodes[mid]; + root.left = _makeBalancedTree(nodes.sublist(0, mid)); + root.right = _makeBalancedTree(nodes.sublist(mid + 1)); + return root; + } + + // Given a node, computes the highest `high` point of all of the subnodes. + // + // As a side effect, this also computes the high point of all subnodes. + void _computeHigh(IntervalTreeNode root) { + if (root.left == null && root.right == null) { + root.computedHigh = root.high; + } else if (root.left == null) { + _computeHigh(root.right!); + root.computedHigh = math.max(root.high, root.right!.computedHigh); + } else if (root.right == null) { + _computeHigh(root.left!); + root.computedHigh = math.max(root.high, root.left!.computedHigh); + } else { + _computeHigh(root.right!); + _computeHigh(root.left!); + root.computedHigh = math.max( + root.high, + math.max( + root.left!.computedHigh, + root.right!.computedHigh, + )); + } + } + + IntervalTreeNode root = _makeBalancedTree(intervals)!; + _computeHigh(root); + + return IntervalTree._(root); + } + + /// Returns the list of objects which have been associated with intervals that + /// intersect with [x]. + List intersections(int x) { + List results = []; + root.searchForPoint(x, results); + return results; + } +} + +class IntervalTreeNode { + final T value; + final int low; + final int high; + int computedHigh; + + IntervalTreeNode? left; + IntervalTreeNode? right; + + IntervalTreeNode(this.value, this.low, this.high) : computedHigh = high; + + bool contains(int x) { + return low <= x && x <= high; + } + + // 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)) { + result.add(value); + } + if (x < low) { + return; + } + right?.searchForPoint(x, result); + } +} diff --git a/lib/web_ui/lib/src/engine/canvaskit/text.dart b/lib/web_ui/lib/src/engine/canvaskit/text.dart index 41d1215a7..589ca9a46 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/text.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/text.dart @@ -62,11 +62,7 @@ class CkParagraphStyle implements ui.ParagraphStyle { skTextStyle.fontSize = fontSize; } - if (fontFamily == null || - !skiaFontCollection.registeredFamilies.contains(fontFamily)) { - fontFamily = 'Roboto'; - } - skTextStyle.fontFamilies = [fontFamily]; + skTextStyle.fontFamilies = _getEffectiveFontFamilies(fontFamily); return skTextStyle; } @@ -74,20 +70,8 @@ class CkParagraphStyle implements ui.ParagraphStyle { static SkStrutStyleProperties toSkStrutStyleProperties(ui.StrutStyle value) { EngineStrutStyle style = value as EngineStrutStyle; final SkStrutStyleProperties skStrutStyle = SkStrutStyleProperties(); - if (style._fontFamily != null) { - String fontFamily = style._fontFamily!; - if (!skiaFontCollection.registeredFamilies.contains(fontFamily)) { - fontFamily = 'Roboto'; - } - final List fontFamilies = [fontFamily]; - if (style._fontFamilyFallback != null) { - fontFamilies.addAll(style._fontFamilyFallback!); - } - skStrutStyle.fontFamilies = fontFamilies; - } else { - // If no strut font family is given, default to Roboto. - skStrutStyle.fontFamilies = ['Roboto']; - } + skStrutStyle.fontFamilies = + _getEffectiveFontFamilies(style._fontFamily, style._fontFamilyFallback); if (style._fontSize != null) { skStrutStyle.fontSize = style._fontSize; @@ -279,18 +263,8 @@ class CkTextStyle implements ui.TextStyle { properties.locale = locale.toLanguageTag(); } - if (fontFamily == null || - !skiaFontCollection.registeredFamilies.contains(fontFamily)) { - fontFamily = 'Roboto'; - } - - List fontFamilies = [fontFamily]; - if (fontFamilyFallback != null && - !fontFamilyFallback.every((font) => fontFamily == font)) { - fontFamilies.addAll(fontFamilyFallback); - } - - properties.fontFamilies = fontFamilies; + properties.fontFamilies = + _getEffectiveFontFamilies(fontFamily, fontFamilyFallback); if (fontWeight != null || fontStyle != null) { properties.fontStyle = toSkFontStyle(fontWeight, fontStyle); @@ -564,7 +538,6 @@ class CkParagraph extends ManagedSkiaObject @override void layout(ui.ParagraphConstraints constraints) { - assert(constraints.width != null); // ignore: unnecessary_null_comparison _lastLayoutConstraints = constraints; // TODO(het): CanvasKit throws an exception when laid out with @@ -667,8 +640,64 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { return properties; } + /// Determines if the given [text] contains any code points which are not + /// supported by the current set of fonts. + void _ensureFontsSupportText(String text) { + // TODO(hterkelsen): Make this faster for the common case where the text + // is supported by the given fonts. + + // If the text is ASCII, then skip this check. + bool isAscii = true; + for (int i = 0; i < text.length; i++) { + if (text.codeUnitAt(i) >= 160) { + isAscii = false; + break; + } + } + if (isAscii) { + return; + } + CkTextStyle style = _peekStyle(); + List fontFamilies = + _getEffectiveFontFamilies(style.fontFamily, style.fontFamilyFallback); + List typefaces = []; + for (var font in fontFamilies) { + List? typefacesForFamily = + skiaFontCollection.familyToTypefaceMap[font]; + if (typefacesForFamily != null) { + typefaces.addAll(typefacesForFamily); + } + } + List codeUnitsSupported = List.filled(text.length, false); + for (SkTypeface typeface in typefaces) { + SkFont font = SkFont(typeface); + Uint8List glyphs = font.getGlyphIDs(text); + assert(glyphs.length == codeUnitsSupported.length); + for (int i = 0; i < glyphs.length; i++) { + codeUnitsSupported[i] |= + glyphs[i] != 0 || _isControlCode(text.codeUnitAt(i)); + } + } + + if (codeUnitsSupported.any((x) => !x)) { + List missingCodeUnits = []; + for (int i = 0; i < codeUnitsSupported.length; i++) { + if (!codeUnitsSupported[i]) { + missingCodeUnits.add(text.codeUnitAt(i)); + } + } + _findFontsForMissingCodeunits(missingCodeUnits); + } + } + + /// Returns [true] if [codepoint] is a Unicode control code. + bool _isControlCode(int codepoint) { + return codepoint < 32 || (codepoint > 127 && codepoint < 160); + } + @override void addText(String text) { + _ensureFontsSupportText(text); _commands.add(_ParagraphCommand.addText(text)); _paragraphBuilder.addText(text); } @@ -719,8 +748,10 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { _styleStack.add(skStyle); _commands.add(_ParagraphCommand.pushStyle(ckStyle)); if (skStyle.foreground != null || skStyle.background != null) { - final SkPaint foreground = skStyle.foreground?.skiaObject ?? _defaultTextStylePaint; - final SkPaint background = skStyle.background?.skiaObject ?? _defaultTextStylePaint; + final SkPaint foreground = + skStyle.foreground?.skiaObject ?? _defaultTextStylePaint; + final SkPaint background = + skStyle.background?.skiaObject ?? _defaultTextStylePaint; _paragraphBuilder.pushPaintStyle( skStyle.skTextStyle, foreground, background); } else { @@ -779,3 +810,19 @@ enum _ParagraphCommandType { pushStyle, addPlaceholder, } + +List _getEffectiveFontFamilies(String? fontFamily, + [List? fontFamilyFallback]) { + List fontFamilies = []; + if (fontFamily == null) { + fontFamilies.add('Roboto'); + } else { + fontFamilies.add(fontFamily); + } + if (fontFamilyFallback != null && + !fontFamilyFallback.every((font) => fontFamily == font)) { + fontFamilies.addAll(fontFamilyFallback); + } + fontFamilies.addAll(skiaFontCollection.globalFontFallbacks); + return fontFamilies; +} diff --git a/lib/web_ui/lib/src/engine/util.dart b/lib/web_ui/lib/src/engine/util.dart index ada70cd6a..e422d6661 100644 --- a/lib/web_ui/lib/src/engine/util.dart +++ b/lib/web_ui/lib/src/engine/util.dart @@ -597,45 +597,45 @@ int clampInt(int value, int min, int max) { } ui.Rect computeBoundingRectangleFromMatrix(Matrix4 transform, ui.Rect rect) { - final Float32List m = transform.storage; - // Apply perspective transform to all 4 corners. Can't use left,top, bottom, - // right since for example rotating 45 degrees would yield inaccurate size. - double x = rect.left; - double y = rect.top; - double wp = 1.0 / ((m[3] * x) + (m[7] * y) + m[15]); - double xp = ((m[0] * x) + (m[4] * y) + m[12]) * wp; - double yp = ((m[1] * x) + (m[5] * y) + m[13]) * wp; - double minX = xp, maxX = xp; - double minY =yp, maxY = yp; - x = rect.right; - y = rect.bottom; - wp = 1.0 / ((m[3] * x) + (m[7] * y) + m[15]); - xp = ((m[0] * x) + (m[4] * y) + m[12]) * wp; - yp = ((m[1] * x) + (m[5] * y) + m[13]) * wp; - - minX = math.min(minX, xp); - maxX = math.max(maxX, xp); - minY = math.min(minY, yp); - maxY = math.max(maxY, yp); - - x = rect.left; - y = rect.bottom; - wp = 1.0 / ((m[3] * x) + (m[7] * y) + m[15]); - xp = ((m[0] * x) + (m[4] * y) + m[12]) * wp; - yp = ((m[1] * x) + (m[5] * y) + m[13]) * wp; - minX = math.min(minX, xp); - maxX = math.max(maxX, xp); - minY = math.min(minY, yp); - maxY = math.max(maxY, yp); - - x = rect.right; - y = rect.top; - wp = 1.0 / ((m[3] * x) + (m[7] * y) + m[15]); - xp = ((m[0] * x) + (m[4] * y) + m[12]) * wp; - yp = ((m[1] * x) + (m[5] * y) + m[13]) * wp; - minX = math.min(minX, xp); - maxX = math.max(maxX, xp); - minY = math.min(minY, yp); - maxY = math.max(maxY, yp); - return ui.Rect.fromLTWH(minX, minY, maxX-minX, maxY-minY); - } + final Float32List m = transform.storage; + // Apply perspective transform to all 4 corners. Can't use left,top, bottom, + // right since for example rotating 45 degrees would yield inaccurate size. + double x = rect.left; + double y = rect.top; + double wp = 1.0 / ((m[3] * x) + (m[7] * y) + m[15]); + double xp = ((m[0] * x) + (m[4] * y) + m[12]) * wp; + double yp = ((m[1] * x) + (m[5] * y) + m[13]) * wp; + double minX = xp, maxX = xp; + double minY = yp, maxY = yp; + x = rect.right; + y = rect.bottom; + wp = 1.0 / ((m[3] * x) + (m[7] * y) + m[15]); + xp = ((m[0] * x) + (m[4] * y) + m[12]) * wp; + yp = ((m[1] * x) + (m[5] * y) + m[13]) * wp; + + minX = math.min(minX, xp); + maxX = math.max(maxX, xp); + minY = math.min(minY, yp); + maxY = math.max(maxY, yp); + + x = rect.left; + y = rect.bottom; + wp = 1.0 / ((m[3] * x) + (m[7] * y) + m[15]); + xp = ((m[0] * x) + (m[4] * y) + m[12]) * wp; + yp = ((m[1] * x) + (m[5] * y) + m[13]) * wp; + minX = math.min(minX, xp); + maxX = math.max(maxX, xp); + minY = math.min(minY, yp); + maxY = math.max(maxY, yp); + + x = rect.right; + y = rect.top; + wp = 1.0 / ((m[3] * x) + (m[7] * y) + m[15]); + xp = ((m[0] * x) + (m[4] * y) + m[12]) * wp; + yp = ((m[1] * x) + (m[5] * y) + m[13]) * wp; + minX = math.min(minX, xp); + maxX = math.max(maxX, xp); + minY = math.min(minY, yp); + maxY = math.max(maxY, yp); + return ui.Rect.fromLTWH(minX, minY, maxX - minX, maxY - minY); +} diff --git a/lib/web_ui/test/canvaskit/fallback_fonts_golden_test.dart b/lib/web_ui/test/canvaskit/fallback_fonts_golden_test.dart new file mode 100644 index 000000000..7c6a591f9 --- /dev/null +++ b/lib/web_ui/test/canvaskit/fallback_fonts_golden_test.dart @@ -0,0 +1,155 @@ +// 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. + +// @dart = 2.12 +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:ui/ui.dart' as ui; +import 'package:ui/src/engine.dart'; + +import 'package:test/test.dart'; +import 'package:test/bootstrap/browser.dart'; +import 'package:web_engine_tester/golden_tester.dart'; + +import 'common.dart'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +const ui.Rect kDefaultRegion = const ui.Rect.fromLTRB(0, 0, 500, 250); + +Future matchPictureGolden(String goldenFile, CkPicture picture, + {ui.Rect region = kDefaultRegion, bool write = false}) async { + final EnginePlatformDispatcher dispatcher = + ui.window.platformDispatcher as EnginePlatformDispatcher; + final LayerSceneBuilder sb = LayerSceneBuilder(); + sb.pushOffset(0, 0); + sb.addPicture(ui.Offset.zero, picture); + dispatcher.rasterizer!.draw(sb.build().layerTree); + await matchGoldenFile(goldenFile, + region: region, maxDiffRatePercent: 0.0, write: write); +} + +void testMain() { + group('Font fallbacks', () { + setUpCanvasKitTest(); + + /// Used to save and restore [ui.window.onPlatformMessage] after each test. + ui.PlatformMessageCallback? savedCallback; + + setUp(() { + notoDownloadQueue.downloader = TestDownloader(); + TestDownloader.mockDownloads.clear(); + savedCallback = ui.window.onPlatformMessage; + skiaFontCollection.debugResetFallbackFonts(); + }); + + tearDown(() { + ui.window.onPlatformMessage = savedCallback; + }); + + test('will download Noto Naskh Arabic if Arabic text is added', () async { + final Completer fontChangeCompleter = Completer(); + // Intercept the system font change message. + ui.window.onPlatformMessage = (String name, ByteData? data, + ui.PlatformMessageResponseCallback? callback) { + if (name == 'flutter/system') { + const JSONMessageCodec codec = JSONMessageCodec(); + final dynamic message = codec.decodeMessage(data); + if (message is Map) { + if (message['type'] == 'fontsChange') { + fontChangeCompleter.complete(); + } + } + } + if (savedCallback != null) { + savedCallback!(name, data, callback); + } + }; + + TestDownloader.mockDownloads[ + 'https://fonts.googleapis.com/css2?family=Noto+Naskh+Arabic+UI'] = + ''' +/* arabic */ +@font-face { + font-family: 'Noto Naskh Arabic UI'; + font-style: normal; + font-weight: 400; + src: url(packages/ui/assets/NotoNaskhArabic-Regular.ttf) format('ttf'); + unicode-range: U+0600-06FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE80-FEFC; +} +'''; + + expect(skiaFontCollection.globalFontFallbacks, isEmpty); + + // Creating this paragraph should cause us to start to download the + // fallback font. + CkParagraphBuilder pb = CkParagraphBuilder( + CkParagraphStyle(), + ); + pb.addText('مرحبا'); + + await fontChangeCompleter.future; + + expect(skiaFontCollection.globalFontFallbacks, + contains('Noto Naskh Arabic UI 0')); + + final CkPictureRecorder recorder = CkPictureRecorder(); + final CkCanvas canvas = recorder.beginRecording(kDefaultRegion); + + pb = CkParagraphBuilder( + CkParagraphStyle( + fontSize: 32, + ), + ); + pb.addText('مرحبا'); + final CkParagraph paragraph = pb.build(); + paragraph.layout(ui.ParagraphConstraints(width: 1000)); + + canvas.drawParagraph(paragraph, ui.Offset(200, 120)); + + await matchPictureGolden( + 'canvaskit_font_fallback_arabic.png', recorder.endRecording()); + // TODO: https://github.com/flutter/flutter/issues/60040 + // TODO: https://github.com/flutter/flutter/issues/71520 + }, skip: isIosSafari || isFirefox); + + test('will gracefully fail if we cannot parse the Google Fonts CSS', + () async { + TestDownloader.mockDownloads[ + 'https://fonts.googleapis.com/css2?family=Noto+Naskh+Arabic+UI'] = + 'invalid CSS... this should cause our parser to fail'; + + expect(skiaFontCollection.globalFontFallbacks, isEmpty); + + // Creating this paragraph should cause us to start to download the + // fallback font. + CkParagraphBuilder pb = CkParagraphBuilder( + CkParagraphStyle(), + ); + pb.addText('مرحبا'); + + // Flush microtasks and test that we didn't start any downloads. + await Future.delayed(Duration.zero); + + expect(notoDownloadQueue.isPending, isFalse); + expect(skiaFontCollection.globalFontFallbacks, isEmpty); + }); + // TODO: https://github.com/flutter/flutter/issues/60040 + }, skip: isIosSafari); +} + +class TestDownloader extends NotoDownloader { + static final Map mockDownloads = {}; + @override + Future downloadAsString(String url) async { + if (mockDownloads.containsKey(url)) { + return mockDownloads[url]!; + } else { + return ''; + } + } +} diff --git a/lib/web_ui/test/canvaskit/interval_tree_test.dart b/lib/web_ui/test/canvaskit/interval_tree_test.dart new file mode 100644 index 000000000..42b089ca6 --- /dev/null +++ b/lib/web_ui/test/canvaskit/interval_tree_test.dart @@ -0,0 +1,85 @@ +// 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. + +// @dart = 2.12 + +import 'package:ui/src/engine.dart'; + +import 'package:test/test.dart'; +import 'package:test/bootstrap/browser.dart'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +void testMain() { + group('$IntervalTree', () { + test('is balanced', () { + var ranges = >{ + 'A': [CodeunitRange(0, 5), CodeunitRange(6, 10)], + 'B': [CodeunitRange(4, 6)], + }; + + // Should create a balanced 3-node tree with a root with a left and right + // child. + var tree = IntervalTree.createFromRanges(ranges); + var root = tree.root; + expect(root.left, isNotNull); + expect(root.right, isNotNull); + expect(root.left!.left, isNull); + expect(root.left!.right, isNull); + expect(root.right!.left, isNull); + expect(root.right!.right, isNull); + + // Should create a balanced 15-node tree (4 layers deep). + var ranges2 = >{ + 'A': [ + CodeunitRange(1, 1), + CodeunitRange(2, 2), + CodeunitRange(3, 3), + CodeunitRange(4, 4), + CodeunitRange(5, 5), + CodeunitRange(6, 6), + CodeunitRange(7, 7), + CodeunitRange(8, 8), + CodeunitRange(9, 9), + CodeunitRange(10, 10), + CodeunitRange(11, 11), + CodeunitRange(12, 12), + CodeunitRange(13, 13), + CodeunitRange(14, 14), + CodeunitRange(15, 15), + ], + }; + + // Should create a balanced 3-node tree with a root with a left and right + // child. + var tree2 = IntervalTree.createFromRanges(ranges2); + var root2 = tree2.root; + + expect(root2.left!.left!.left, isNotNull); + expect(root2.left!.left!.right, isNotNull); + expect(root2.left!.right!.left, isNotNull); + expect(root2.left!.right!.right, isNotNull); + expect(root2.right!.left!.left, isNotNull); + expect(root2.right!.left!.right, isNotNull); + expect(root2.right!.right!.left, isNotNull); + expect(root2.right!.right!.right, isNotNull); + }); + + test('finds values whose intervals overlap with a given point', () { + var ranges = >{ + 'A': [CodeunitRange(0, 5), CodeunitRange(7, 10)], + 'B': [CodeunitRange(4, 6)], + }; + var tree = IntervalTree.createFromRanges(ranges); + + expect(tree.intersections(1), ['A']); + expect(tree.intersections(4), ['A', 'B']); + expect(tree.intersections(6), ['B']); + expect(tree.intersections(7), ['A']); + expect(tree.intersections(11), []); + }); + }); +} -- GitLab