diff --git a/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart b/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart index 309679a0839a5..07a150db343b5 100644 --- a/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart +++ b/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart @@ -157,37 +157,15 @@ class CanvasParagraph implements ui.Paragraph { cssStyle ..position = 'absolute' // Prevent the browser from doing any line breaks in the paragraph. We want - // to insert our own
breaks based on layout results. + // to have full control of the paragraph layout. ..whiteSpace = 'pre'; - if (width > longestLine) { - // In this case, we set the width so that the CSS text-align property - // works correctly. - // When `longestLine` is >= `paragraph.width` that means the DOM element - // will automatically size itself to fit the longest line, so there's no - // need to set an explicit width. - cssStyle.width = '${width}px'; - } - - if (paragraphStyle.maxLines != null || paragraphStyle.ellipsis != null) { - cssStyle - ..overflowY = 'hidden' - ..height = '${height}px'; - } - // 2. Append all spans to the paragraph. - FlatTextSpan? span; - - html.HtmlElement element = rootElement; + html.HtmlElement? lastSpanElement; final List lines = computeLineMetrics(); for (int i = 0; i < lines.length; i++) { - // Insert a
element before each line except the first line. - if (i > 0) { - element.append(html.document.createElement('br')); - } - final EngineLineMetrics line = lines[i]; final List boxes = line.boxes; final StringBuffer buffer = StringBuffer(); @@ -195,47 +173,28 @@ class CanvasParagraph implements ui.Paragraph { int j = 0; while (j < boxes.length) { final RangeBox box = boxes[j++]; - if (box is SpanBox && box.span == span) { - buffer.write(box.toText()); - continue; - } - - if (buffer.isNotEmpty) { - element.appendText(buffer.toString()); - buffer.clear(); - } if (box is SpanBox) { - span = box.span; - element = html.document.createElement('span') as html.HtmlElement; + lastSpanElement = html.document.createElement('span') as html.HtmlElement; applyTextStyleToElement( - element: element, + element: lastSpanElement, style: box.span.style, isSpan: true, ); - rootElement.append(element); + _positionSpanElement(lastSpanElement, line, box); + lastSpanElement.appendText(box.toText()); + rootElement.append(lastSpanElement); buffer.write(box.toText()); } else if (box is PlaceholderBox) { - span = null; - // If there's a line-end after this placeholder, we want the
to - // be inserted in the root paragraph element. - element = rootElement; - rootElement.append( - createPlaceholderElement(placeholder: box.placeholder), - ); + lastSpanElement = null; } else { throw UnimplementedError('Unknown box type: ${box.runtimeType}'); } } - if (buffer.isNotEmpty) { - element.appendText(buffer.toString()); - buffer.clear(); - } - final String? ellipsis = line.ellipsis; if (ellipsis != null) { - element.appendText(ellipsis); + (lastSpanElement ?? rootElement).appendText(ellipsis); } } @@ -307,6 +266,9 @@ void _applyNecessaryParagraphStyles({ }) { final html.CssStyleDeclaration cssStyle = element.style; + // TODO(mdebbar): Now that we absolutely position each span inside the + // paragraph, do we still need these style on

? + if (style.textAlign != null) { cssStyle.textAlign = textAlignToCssValue( style.textAlign, style.textDirection ?? ui.TextDirection.ltr); @@ -352,6 +314,14 @@ void _applySpanStylesToParagraph({ } } +void _positionSpanElement(html.Element element, EngineLineMetrics line, RangeBox box) { + final ui.TextBox textBox = box.toTextBox(line); + element.style + ..position = 'absolute' + ..top = '${textBox.top}px' + ..left = '${textBox.left}px'; +} + /// A common interface for all types of spans that make up a paragraph. /// /// These spans are stored as a flat list in the paragraph object. diff --git a/lib/web_ui/test/html/paragraph/general_golden_test.dart b/lib/web_ui/test/html/paragraph/general_golden_test.dart index e3caf19f03b13..2ac41fe3d0fbc 100644 --- a/lib/web_ui/test/html/paragraph/general_golden_test.dart +++ b/lib/web_ui/test/html/paragraph/general_golden_test.dart @@ -371,7 +371,7 @@ Future testMain() async { }); void testFontFeatures(EngineCanvas canvas) { - const String text = 'Aa Bb Dd Ee Ff Difficult'; + const String text = 'Bb Difficult '; const FontFeature enableSmallCaps = FontFeature('smcp'); const FontFeature disableSmallCaps = FontFeature('smcp', 0); @@ -446,7 +446,7 @@ Future testMain() async { enableOnum, ], )); - builder.addText('$text - $numeric'); + builder.addText('$text $numeric'); builder.pop(); // enableSmallCaps, enableOnum }, )..layout(constrain(double.infinity)); diff --git a/lib/web_ui/test/text/canvas_paragraph_builder_test.dart b/lib/web_ui/test/text/canvas_paragraph_builder_test.dart index 82283e9b9ada4..2997f99d6f427 100644 --- a/lib/web_ui/test/text/canvas_paragraph_builder_test.dart +++ b/lib/web_ui/test/text/canvas_paragraph_builder_test.dart @@ -7,27 +7,28 @@ import 'package:test/test.dart'; import 'package:ui/src/engine.dart'; import 'package:ui/ui.dart'; -bool get isIosSafari => browserEngine == BrowserEngine.webkit && - operatingSystem == OperatingSystem.iOs; +bool get isIosSafari => + browserEngine == BrowserEngine.webkit && + operatingSystem == OperatingSystem.iOs; + +/// Some text measurements are sensitive to browser implementations. Position +/// info in the following tests only pass in Chrome, they are slightly different +/// on each browser. So we need to ignore position info on non-Chrome browsers +/// when comparing expectations with actual output. +bool get isBlink => browserEngine == BrowserEngine.blink; String fontFamilyToAttribute(String fontFamily) { fontFamily = canonicalizeFontFamily(fontFamily)!; if (browserEngine == BrowserEngine.firefox) { - fontFamily = fontFamily.replaceAll('"', '"'); + return fontFamily.replaceAll('"', '"'); } else if (browserEngine == BrowserEngine.blink || browserEngine == BrowserEngine.samsung || browserEngine == BrowserEngine.webkit) { - fontFamily = fontFamily.replaceAll('"', ''); + return fontFamily.replaceAll('"', ''); } - return 'font-family: $fontFamily;'; + return fontFamily; } -final String defaultFontFamily = fontFamilyToAttribute('Ahem'); -const String defaultColor = 'color: rgb(255, 0, 0);'; -const String defaultFontSize = 'font-size: 14px;'; -const String paragraphStyle = - 'position: absolute; white-space: pre;'; - void main() { internalBootstrapBrowserTest(() => testMain); } @@ -47,24 +48,29 @@ Future testMain() async { expect(paragraph.spans, hasLength(1)); paragraph.layout(const ParagraphConstraints(width: double.infinity)); - expect( - paragraph.toDomElement().outerHtml, - '

' - '' + expectOuterHtml( + paragraph, + '

' + '' 'Hello' '' '

', + ignorePositions: !isBlink, ); // Should break "Hello" into "Hel" and "lo". paragraph.layout(const ParagraphConstraints(width: 39.0)); - expect( - paragraph.toDomElement().outerHtml, - '

' - '' - 'Hel
lo' + expectOuterHtml( + paragraph, + '

' + '' + 'Hel' + '' + '' + 'lo' '' '

', + ignorePositions: !isBlink, ); final ParagraphSpan span = paragraph.spans.single; @@ -88,8 +94,8 @@ Future testMain() async { paragraph.layout(const ParagraphConstraints(width: double.infinity)); expect( paragraph.toDomElement().outerHtml, - '

' - '' + '

' + '' 'Hello' '' '

', @@ -109,16 +115,11 @@ Future testMain() async { expect(paragraph.paragraphStyle, style); expect(paragraph.toPlainText(), 'Hello'); - double expectedHeight = 14.0; - if (isIosSafari) { - // On iOS Safari, the height measurement is one extra pixel. - expectedHeight++; - } paragraph.layout(const ParagraphConstraints(width: double.infinity)); expect( paragraph.toDomElement().outerHtml, - '

' - '' + '

' + '' 'Hello' '' '

', @@ -135,16 +136,11 @@ Future testMain() async { expect(paragraph.paragraphStyle, style); expect(paragraph.toPlainText(), 'HelloWorld'); - double expectedHeight = 14.0; - if (isIosSafari) { - // On iOS Safari, the height measurement is one extra pixel. - expectedHeight++; - } paragraph.layout(const ParagraphConstraints(width: 100.0)); expect( paragraph.toDomElement().outerHtml, - '

' - '' + '

' + '' 'Hell...' '' '

', @@ -171,8 +167,8 @@ Future testMain() async { paragraph.layout(const ParagraphConstraints(width: double.infinity)); expect( paragraph.toDomElement().outerHtml, - '

' - '' + '

' + '' 'Hello' '' '

', @@ -207,30 +203,38 @@ Future testMain() async { expect(paragraph.spans, hasLength(2)); paragraph.layout(const ParagraphConstraints(width: double.infinity)); - expect( - paragraph.toDomElement().outerHtml, - '

' - '' + expectOuterHtml( + paragraph, + '

' + '' 'Hello' '' - '' - ' world' + '' + ' ' + '' + '' + 'world' '' '

', + ignorePositions: !isBlink, ); - // Should break "Hello world" into "Hello" and " world". + // Should break "Hello world" into 2 lines: "Hello" and " world". paragraph.layout(const ParagraphConstraints(width: 75.0)); - expect( - paragraph.toDomElement().outerHtml, - '

' - '' + expectOuterHtml( + paragraph, + '

' + '' 'Hello' '' - '' - '
world' + '' + ' ' + '' + '' + 'world' '' '

', + ignorePositions: !isBlink, ); final FlatTextSpan hello = paragraph.spans.first as FlatTextSpan; @@ -272,19 +276,23 @@ Future testMain() async { expect(paragraph.spans, hasLength(3)); paragraph.layout(const ParagraphConstraints(width: double.infinity)); - expect( - paragraph.toDomElement().outerHtml, - '

' - '' + expectOuterHtml( + paragraph, + '

' + '' 'Hello' '' - '' - ' world' + '' + ' ' + '' + '' + 'world' '' - '' + '' '!' '' '

', + ignorePositions: !isBlink, ); final FlatTextSpan hello = paragraph.spans[0] as FlatTextSpan; @@ -336,30 +344,44 @@ Future testMain() async { // There's a new line between "First" and "Second", but "Second" and // "ThirdLongLine" remain together since constraints are infinite. paragraph.layout(const ParagraphConstraints(width: double.infinity)); - expect( - paragraph.toDomElement().outerHtml, - '

' - '' - 'First
Second ' + expectOuterHtml( + paragraph, + '

' + '' + 'First' + '' + '' + 'Second' '' - '' + '' + ' ' + '' + '' 'ThirdLongLine' '' '

', + ignorePositions: !isBlink, ); // Should break the paragraph into "First", "Second" and "ThirdLongLine". paragraph.layout(const ParagraphConstraints(width: 180.0)); - expect( - paragraph.toDomElement().outerHtml, - '

' - '' - 'First
Second
' + expectOuterHtml( + paragraph, + '

' + '' + 'First' + '' + '' + 'Second' + '' + '' + ' ' '' - '' + '' 'ThirdLongLine' '' '

', + ignorePositions: !isBlink, ); }); @@ -384,24 +406,74 @@ Future testMain() async { // The paragraph should take the font size and family from the span with the // greatest font size. paragraph.layout(const ParagraphConstraints(width: double.infinity)); - expect( - paragraph.toDomElement().outerHtml, - '

' - '' - 'First ' + expectOuterHtml( + paragraph, + '

' + '' + 'First' '' - '' - 'Second ' + '' + ' ' '' - '' + '' + 'Second' + '' + '' + ' ' + '' + '' 'Third' '' '

', + // Since we are using unknown font families, we can't predict the text + // measurements. + ignorePositions: true, ); debugEmulateFlutterTesterEnvironment = true; }); } +const String defaultFontFamily = 'Ahem'; +const num defaultFontSize = 14; + +String paragraphStyle({ + String fontFamily = defaultFontFamily, + num fontSize = defaultFontSize, + num? lineHeight, +}) { + return [ + if (lineHeight != null) 'line-height: $lineHeight;', + 'font-size: ${fontSize}px;', + 'font-family: ${fontFamilyToAttribute(fontFamily)};', + 'position: absolute;', + 'white-space: pre;', + ].join(' '); +} + +String spanStyle({ + required num? top, + required num? left, + String fontFamily = defaultFontFamily, + num fontSize = defaultFontSize, + String? fontWeight, + String? fontStyle, + num? lineHeight, + num? letterSpacing, +}) { + return [ + 'color: rgb(255, 0, 0);', + if (lineHeight != null) 'line-height: $lineHeight;', + 'font-size: ${fontSize}px;', + if (fontWeight != null) 'font-weight: $fontWeight;', + if (fontStyle != null) 'font-style: $fontStyle;', + 'font-family: ${fontFamilyToAttribute(fontFamily)};', + if (letterSpacing != null) 'letter-spacing: ${letterSpacing}px;', + 'position: absolute;', + if (top != null) 'top: ${top}px;', + if (left != null) 'left: ${left}px;', + ].join(' '); +} + TextStyle styleWithDefaults({ Color color = const Color(0xFFFF0000), String fontFamily = FlutterViewEmbedder.defaultFontFamily, @@ -421,3 +493,23 @@ TextStyle styleWithDefaults({ letterSpacing: letterSpacing, ); } + +void expectOuterHtml(CanvasParagraph paragraph, String expected, {required bool ignorePositions}) { + String outerHtml = paragraph.toDomElement().outerHtml!; + if (ignorePositions) { + outerHtml = removePositionInfo(outerHtml); + expected = removePositionInfo(expected); + } + + expect(outerHtml, expected); +} + +/// Removes "top" and "left" CSS styles from the given html string. +/// +/// This is needed when the positioning information in the html output is +/// unknown and could be different depending on browser and environment. +String removePositionInfo(String outerHtml) { + return outerHtml + .replaceAll(RegExp(r'\s*top:\s*[\d\.]+px\s*;\s*'), '') + .replaceAll(RegExp(r'\s*left:\s*[\d\.]+px\s*;\s*'), ''); +}