diff --git a/lib/web_ui/lib/src/engine/dom.dart b/lib/web_ui/lib/src/engine/dom.dart index e982f04707538..8ee402645554b 100644 --- a/lib/web_ui/lib/src/engine/dom.dart +++ b/lib/web_ui/lib/src/engine/dom.dart @@ -8,6 +8,7 @@ import 'dart:typed_data'; import 'package:js/js.dart'; import 'package:js/js_util.dart' as js_util; +import 'package:meta/meta.dart'; /// This file contains static interop classes for interacting with the DOM and /// some helpers. All of the classes in this file are named after their @@ -584,7 +585,16 @@ class DomPerformanceMeasure extends DomPerformanceEntry {} @staticInterop class DomCanvasElement extends DomHTMLElement {} +@visibleForTesting +int debugCanvasCount = 0; + +@visibleForTesting +void debugResetCanvasCount() { + debugCanvasCount = 0; +} + DomCanvasElement createDomCanvasElement({int? width, int? height}) { + debugCanvasCount++; final DomCanvasElement canvas = domWindow.document.createElement('canvas') as DomCanvasElement; if (width != null) { @@ -625,6 +635,7 @@ abstract class DomCanvasImageSource {} class DomCanvasRenderingContext2D {} extension DomCanvasRenderingContext2DExtension on DomCanvasRenderingContext2D { + external DomCanvasElement? get canvas; external Object? get fillStyle; external set fillStyle(Object? style); external String get font; diff --git a/lib/web_ui/lib/src/engine/text/layout_service.dart b/lib/web_ui/lib/src/engine/text/layout_service.dart index b289abc13c5d1..d8360440520a3 100644 --- a/lib/web_ui/lib/src/engine/text/layout_service.dart +++ b/lib/web_ui/lib/src/engine/text/layout_service.dart @@ -16,6 +16,16 @@ import 'paragraph.dart'; import 'ruler.dart'; import 'text_direction.dart'; +/// A single canvas2d context to use for all text measurements. +@visibleForTesting +final DomCanvasRenderingContext2D textContext = + // We don't use this canvas to draw anything, so let's make it as small as + // possible to save memory. + createDomCanvasElement(width: 0, height: 0).context2D; + +/// The last font used in the [textContext]. +String? _lastContextFont; + /// Performs layout on a [CanvasParagraph]. /// /// It uses a [DomCanvasElement] to measure text. @@ -24,9 +34,6 @@ class TextLayoutService { final CanvasParagraph paragraph; - final DomCanvasRenderingContext2D context = - createDomCanvasElement().context2D; - // *** Results of layout *** // // Look at the Paragraph class for documentation of the following properties. @@ -53,7 +60,7 @@ class TextLayoutService { ui.Rect get paintBounds => _paintBounds; ui.Rect _paintBounds = ui.Rect.zero; - late final Spanometer spanometer = Spanometer(paragraph, context); + late final Spanometer spanometer = Spanometer(paragraph); late final LayoutFragmenter layoutFragmenter = LayoutFragmenter(paragraph.plainText, paragraph.spans); @@ -882,10 +889,9 @@ class LineBuilder { /// it's set, the [Spanometer] updates the underlying [context] so that /// subsequent measurements use the correct styles. class Spanometer { - Spanometer(this.paragraph, this.context); + Spanometer(this.paragraph); final CanvasParagraph paragraph; - final DomCanvasRenderingContext2D context; static final RulerHost _rulerHost = RulerHost(); @@ -904,8 +910,6 @@ class Spanometer { _rulers.clear(); } - String _cssFontString = ''; - double? get letterSpacing => currentSpan.style.letterSpacing; TextHeightRuler? _currentRuler; @@ -913,12 +917,24 @@ class Spanometer { ParagraphSpan get currentSpan => _currentSpan!; set currentSpan(ParagraphSpan? span) { + // Update the font string if it's different from the last applied font + // string. + // + // Also, we need to update the font string even if the span isn't changing. + // That's because `textContext` is shared across all spanometers. + if (span != null) { + final String newCssFontString = span.style.cssFontString; + if (_lastContextFont != newCssFontString) { + _lastContextFont = newCssFontString; + textContext.font = newCssFontString; + } + } + if (span == _currentSpan) { return; } _currentSpan = span; - // No need to update css font string when `span` is null. if (span == null) { _currentRuler = null; return; @@ -933,13 +949,6 @@ class Spanometer { _rulers[heightStyle] = ruler; } _currentRuler = ruler; - - // Update the font string if it's different from the previous span. - final String cssFontString = span.style.cssFontString; - if (_cssFontString != cssFontString) { - _cssFontString = cssFontString; - context.font = cssFontString; - } } /// Whether the spanometer is ready to take measurements. @@ -955,7 +964,7 @@ class Spanometer { double get height => _currentRuler!.height; double measureText(String text) { - return measureSubstring(context, text, 0, text.length); + return measureSubstring(textContext, text, 0, text.length); } double measureRange(int start, int end) { @@ -1047,7 +1056,7 @@ class Spanometer { assert(end >= currentSpan.start && end <= currentSpan.end); return measureSubstring( - context, + textContext, paragraph.plainText, start, end, diff --git a/lib/web_ui/test/text/layout_service_plain_test.dart b/lib/web_ui/test/text/layout_service_plain_test.dart index e67291e6ad958..567411d993b4f 100644 --- a/lib/web_ui/test/text/layout_service_plain_test.dart +++ b/lib/web_ui/test/text/layout_service_plain_test.dart @@ -691,4 +691,64 @@ Future testMain() async { l('i', 9, 10, hardBreak: true, width: 10.0, left: 40.0), ]); }); + + test('uses a single minimal canvas', () { + debugResetCanvasCount(); + + plain(ahemStyle, 'Lorem').layout(constrain(double.infinity)); + plain(ahemStyle, 'ipsum dolor').layout(constrain(150.0)); + // Try different styles too. + plain(EngineParagraphStyle(fontWeight: ui.FontWeight.bold), 'sit amet').layout(constrain(300.0)); + + expect(textContext.canvas!.width, isZero); + expect(textContext.canvas!.height, isZero); + // This number is 0 instead of 1 because the canvas is created at the top + // level as a global variable. So by the time this test runs, the canvas + // would have been created already. + // + // So we just make sure that no new canvas is created after the above layout + // calls. + expect(debugCanvasCount, 0); + }); + + test('does not leak styles across spanometers', () { + // This prevents the Ahem font from being forced in all paragraphs. + ui.debugEmulateFlutterTesterEnvironment = false; + + final CanvasParagraph p1 = plain( + EngineParagraphStyle( + fontSize: 20.0, + fontFamily: 'FontFamily1', + ), + 'Lorem', + )..layout(constrain(double.infinity)); + // After the layout, the canvas should have the above style applied. + expect(textContext.font, contains('20px')); + expect(textContext.font, contains('FontFamily1')); + + final CanvasParagraph p2 = plain( + EngineParagraphStyle( + fontSize: 40.0, + fontFamily: 'FontFamily2', + ), + 'ipsum dolor', + )..layout(constrain(double.infinity)); + // After the layout, the canvas should have the above style applied. + expect(textContext.font, contains('40px')); + expect(textContext.font, contains('FontFamily2')); + + p1.getBoxesForRange(0, 2); + // getBoxesForRange performs some text measurements. Let's make sure that it + // applied the correct style. + expect(textContext.font, contains('20px')); + expect(textContext.font, contains('FontFamily1')); + + p2.getBoxesForRange(0, 4); + // getBoxesForRange performs some text measurements. Let's make sure that it + // applied the correct style. + expect(textContext.font, contains('40px')); + expect(textContext.font, contains('FontFamily2')); + + ui.debugEmulateFlutterTesterEnvironment = true; + }); }