Expose more methods on `ui.Paragraph`: lines (#46125)

[C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
diff --git a/lib/ui/dart_ui.cc b/lib/ui/dart_ui.cc
index 310de11..6b048b4 100644
--- a/lib/ui/dart_ui.cc
+++ b/lib/ui/dart_ui.cc
@@ -218,6 +218,9 @@
   V(Paragraph, didExceedMaxLines, 1)                   \
   V(Paragraph, dispose, 1)                             \
   V(Paragraph, getLineBoundary, 2)                     \
+  V(Paragraph, getLineMetricsAt, 3)                    \
+  V(Paragraph, getLineNumberAt, 2)                     \
+  V(Paragraph, getNumberOfLines, 1)                    \
   V(Paragraph, getPositionForOffset, 3)                \
   V(Paragraph, getRectsForPlaceholders, 1)             \
   V(Paragraph, getRectsForRange, 5)                    \
diff --git a/lib/ui/text.dart b/lib/ui/text.dart
index 8bf5a8c..276c326 100644
--- a/lib/ui/text.dart
+++ b/lib/ui/text.dart
@@ -2772,6 +2772,18 @@
     required this.lineNumber,
   });
 
+  LineMetrics._(
+    this.hardBreak,
+    this.ascent,
+    this.descent,
+    this.unscaledAscent,
+    this.height,
+    this.width,
+    this.left,
+    this.baseline,
+    this.lineNumber,
+  );
+
   /// True if this line ends with an explicit line break (e.g. '\n') or is the end
   /// of the paragraph. False otherwise.
   final bool hardBreak;
@@ -2992,6 +3004,32 @@
   /// to repeatedly call this. Instead, cache the results.
   List<LineMetrics> computeLineMetrics();
 
+  /// Returns the [LineMetrics] for the line at `lineNumber`, or null if the
+  /// given `lineNumber` is greater than or equal to [numberOfLines].
+  LineMetrics? getLineMetricsAt(int lineNumber);
+
+  /// The total number of visible lines in the paragraph.
+  ///
+  /// Returns a non-negative number. If `maxLines` is non-null, the value of
+  /// [numberOfLines] never exceeds `maxLines`.
+  int get numberOfLines;
+
+  /// Returns the line number of the line that contains the code unit that
+  /// `codeUnitOffset` points to.
+  ///
+  /// This method returns null if the given `codeUnitOffset` is out of bounds, or
+  /// is logically after the last visible codepoint. This includes the case where
+  /// its codepoint belongs to a visible line, but the text layout library
+  /// replaced it with an ellipsis.
+  ///
+  /// If the target code unit points to a control character that introduces
+  /// mandatory line breaks (most notably the line feed character `LF`, typically
+  /// represented in strings as the escape sequence "\n"), to conform to
+  /// [the unicode rules](https://unicode.org/reports/tr14/#LB4), the control
+  /// character itself is always considered to be at the end of "current" line
+  /// rather than the beginning of the new line.
+  int? getLineNumberAt(int codeUnitOffset);
+
   /// Release the resources used by this object. The object is no longer usable
   /// after this method is called.
   void dispose();
@@ -3170,6 +3208,23 @@
   external Float64List _computeLineMetrics();
 
   @override
+  LineMetrics? getLineMetricsAt(int lineNumber) => _getLineMetricsAt(lineNumber, LineMetrics._);
+  @Native<Handle Function(Pointer<Void>, Uint32, Handle)>(symbol: 'Paragraph::getLineMetricsAt')
+  external LineMetrics? _getLineMetricsAt(int lineNumber, Function constructor);
+
+  @override
+  @Native<Uint32 Function(Pointer<Void>)>(symbol: 'Paragraph::getNumberOfLines')
+  external int get numberOfLines;
+
+  @override
+  int? getLineNumberAt(int codeUnitOffset) {
+    final int lineNumber = _getLineNumber(codeUnitOffset);
+    return lineNumber < 0 ? null : lineNumber;
+  }
+  @Native<Int32 Function(Pointer<Void>, Uint32)>(symbol: 'Paragraph::getLineNumberAt')
+  external int _getLineNumber(int codeUnitOffset);
+
+  @override
   void dispose() {
     assert(!_disposed);
     assert(() {
diff --git a/lib/ui/text/paragraph.cc b/lib/ui/text/paragraph.cc
index 624ed14..fedf84e 100644
--- a/lib/ui/text/paragraph.cc
+++ b/lib/ui/text/paragraph.cc
@@ -8,10 +8,14 @@
 #include "flutter/common/task_runners.h"
 #include "flutter/fml/logging.h"
 #include "flutter/fml/task_runner.h"
+#include "third_party/dart/runtime/include/dart_api.h"
+#include "third_party/skia/modules/skparagraph/include/DartTypes.h"
+#include "third_party/skia/modules/skparagraph/include/Paragraph.h"
 #include "third_party/tonic/converter/dart_converter.h"
 #include "third_party/tonic/dart_args.h"
 #include "third_party/tonic/dart_binding_macros.h"
 #include "third_party/tonic/dart_library_natives.h"
+#include "third_party/tonic/logging/dart_invoke.h"
 
 namespace flutter {
 
@@ -122,12 +126,12 @@
   return tonic::DartConverter<decltype(result)>::ToDart(result);
 }
 
-Dart_Handle Paragraph::getLineBoundary(unsigned offset) {
+Dart_Handle Paragraph::getLineBoundary(unsigned utf16Offset) {
   std::vector<txt::LineMetrics> metrics = m_paragraph->GetLineMetrics();
   int line_start = -1;
   int line_end = -1;
   for (txt::LineMetrics& line : metrics) {
-    if (offset >= line.start_index && offset <= line.end_index) {
+    if (utf16Offset >= line.start_index && utf16Offset <= line.end_index) {
       line_start = line.start_index;
       line_end = line.end_index;
       break;
@@ -137,7 +141,7 @@
   return tonic::DartConverter<decltype(result)>::ToDart(result);
 }
 
-tonic::Float64List Paragraph::computeLineMetrics() {
+tonic::Float64List Paragraph::computeLineMetrics() const {
   std::vector<txt::LineMetrics> metrics = m_paragraph->GetLineMetrics();
 
   // Layout:
@@ -165,6 +169,42 @@
   return result;
 }
 
+Dart_Handle Paragraph::getLineMetricsAt(int lineNumber,
+                                        Dart_Handle constructor) const {
+  skia::textlayout::LineMetrics line;
+  const bool found = m_paragraph->GetLineMetricsAt(lineNumber, &line);
+  if (!found) {
+    return Dart_Null();
+  }
+  std::array<Dart_Handle, 9> arguments = {
+      Dart_NewBoolean(line.fHardBreak),
+      Dart_NewDouble(line.fAscent),
+      Dart_NewDouble(line.fDescent),
+      Dart_NewDouble(line.fUnscaledAscent),
+      // We add then round to get the height. The
+      // definition of height here is different
+      // than the one in LibTxt.
+      Dart_NewDouble(round(line.fAscent + line.fDescent)),
+      Dart_NewDouble(line.fWidth),
+      Dart_NewDouble(line.fLeft),
+      Dart_NewDouble(line.fBaseline),
+      Dart_NewInteger(line.fLineNumber),
+  };
+
+  Dart_Handle handle =
+      Dart_InvokeClosure(constructor, arguments.size(), arguments.data());
+  tonic::CheckAndHandleError(handle);
+  return handle;
+}
+
+size_t Paragraph::getNumberOfLines() const {
+  return m_paragraph->GetNumberOfLines();
+}
+
+int Paragraph::getLineNumberAt(size_t utf16Offset) const {
+  return m_paragraph->GetLineNumberAt(utf16Offset);
+}
+
 void Paragraph::dispose() {
   m_paragraph.reset();
   ClearDartWrapper();
diff --git a/lib/ui/text/paragraph.h b/lib/ui/text/paragraph.h
index 1a5d821..311c33a 100644
--- a/lib/ui/text/paragraph.h
+++ b/lib/ui/text/paragraph.h
@@ -47,7 +47,10 @@
   Dart_Handle getPositionForOffset(double dx, double dy);
   Dart_Handle getWordBoundary(unsigned offset);
   Dart_Handle getLineBoundary(unsigned offset);
-  tonic::Float64List computeLineMetrics();
+  tonic::Float64List computeLineMetrics() const;
+  Dart_Handle getLineMetricsAt(int lineNumber, Dart_Handle constructor) const;
+  size_t getNumberOfLines() const;
+  int getLineNumberAt(size_t utf16Offset) const;
 
   void dispose();
 
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 dff531d..bc98818 100644
--- a/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart
+++ b/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart
@@ -3237,6 +3237,18 @@
   List<SkLineMetrics> getLineMetrics() =>
       _getLineMetrics().toDart.cast<SkLineMetrics>();
 
+  @JS('getLineMetricsAt')
+  external SkLineMetrics? _getLineMetricsAt(JSNumber index);
+  SkLineMetrics? getLineMetricsAt(double index) => _getLineMetricsAt(index.toJS);
+
+  @JS('getNumberOfLines')
+  external JSNumber _getNumberOfLines();
+  double getNumberOfLines() => _getNumberOfLines().toDartDouble;
+
+  @JS('getLineNumberAt')
+  external JSNumber _getLineNumberAt(JSNumber index);
+  double getLineNumberAt(double index) => _getLineNumberAt(index.toJS).toDartDouble;
+
   @JS('getLongestLine')
   external JSNumber _getLongestLine();
   double getLongestLine() => _getLongestLine().toDartDouble;
diff --git a/lib/web_ui/lib/src/engine/canvaskit/text.dart b/lib/web_ui/lib/src/engine/canvaskit/text.dart
index 0948916..6284ffe 100644
--- a/lib/web_ui/lib/src/engine/canvaskit/text.dart
+++ b/lib/web_ui/lib/src/engine/canvaskit/text.dart
@@ -728,6 +728,26 @@
     return result;
   }
 
+  @override
+  ui.LineMetrics? getLineMetricsAt(int lineNumber) {
+    assert(!_disposed, 'Paragraph has been disposed.');
+    final SkLineMetrics? metrics = skiaObject.getLineMetricsAt(lineNumber.toDouble());
+    return metrics == null ? null : CkLineMetrics._(metrics);
+  }
+
+  @override
+  int get numberOfLines {
+    assert(!_disposed, 'Paragraph has been disposed.');
+    return skiaObject.getNumberOfLines().toInt();
+  }
+
+  @override
+  int? getLineNumberAt(int codeUnitOffset) {
+    assert(!_disposed, 'Paragraph has been disposed.');
+    final int lineNumber = skiaObject.getLineNumberAt(codeUnitOffset.toDouble()).toInt();
+    return lineNumber >= 0 ? lineNumber : null;
+  }
+
   bool _disposed = false;
 
   @override
diff --git a/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/paragraph.dart b/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/paragraph.dart
index c00c1bc..af721f7 100644
--- a/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/paragraph.dart
+++ b/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/paragraph.dart
@@ -102,6 +102,15 @@
   bool get didExceedMaxLines => paragraphGetDidExceedMaxLines(handle);
 
   @override
+  int get numberOfLines => paragraphGetLineCount(handle);
+
+  @override
+  int? getLineNumberAt(int codeUnitOffset) {
+    final int lineNumber = paragraphGetLineNumberAt(handle, codeUnitOffset);
+    return lineNumber >= 0 ? lineNumber : null;
+  }
+
+  @override
   void layout(ui.ParagraphConstraints constraints) {
     paragraphLayout(handle, constraints.width);
     if (!_hasCheckedForMissingCodePoints) {
@@ -214,6 +223,12 @@
       (int index) => SkwasmLineMetrics._(paragraphGetLineMetricsAtIndex(handle, index))
     );
   }
+
+  @override
+  ui.LineMetrics? getLineMetricsAt(int index) {
+    final LineMetricsHandle lineMetrics = paragraphGetLineMetricsAtIndex(handle, index);
+    return lineMetrics == nullptr ? SkwasmLineMetrics._(lineMetrics) : null;
+  }
 }
 
 void withScopedFontList(
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 f527d57..c749a85 100644
--- a/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart
+++ b/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart
@@ -40,7 +40,7 @@
   final EngineParagraphStyle paragraphStyle;
 
   /// The full textual content of the paragraph.
-  late String plainText;
+  final String plainText;
 
   /// Whether this paragraph can be drawn on a bitmap canvas.
   ///
@@ -221,17 +221,12 @@
 
   @override
   ui.TextRange getLineBoundary(ui.TextPosition position) {
-    final int index = position.offset;
-
-    int i;
-    for (i = 0; i < lines.length - 1; i++) {
-      final ParagraphLine line = lines[i];
-      if (index >= line.startIndex && index < line.endIndex) {
-        break;
-      }
+    if (lines.isEmpty) {
+      return ui.TextRange.empty;
     }
-
-    final ParagraphLine line = lines[i];
+    final int? lineNumber = getLineNumberAt(position.offset);
+    // Fallback to the last line for backward compatibility.
+    final ParagraphLine line = lineNumber != null ? lines[lineNumber] : lines.last;
     return ui.TextRange(start: line.startIndex, end: line.endIndex - line.trailingNewlines);
   }
 
@@ -240,6 +235,32 @@
     return lines.map((ParagraphLine line) => line.lineMetrics).toList();
   }
 
+  @override
+  EngineLineMetrics? getLineMetricsAt(int lineNumber) {
+    return 0 <= lineNumber && lineNumber < lines.length
+      ? lines[lineNumber].lineMetrics
+      : null;
+  }
+
+  @override
+  int get numberOfLines => lines.length;
+
+  @override
+  int? getLineNumberAt(int codeUnitOffset) => _findLine(codeUnitOffset, 0, lines.length);
+
+  int? _findLine(int codeUnitOffset, int startLine, int endLine) {
+    if (endLine <= startLine || codeUnitOffset < lines[startLine].startIndex || lines[endLine - 1].endIndex <= codeUnitOffset) {
+      return null;
+    }
+    if (endLine == startLine + 1) {
+      return startLine;
+    }
+    // endLine >= startLine + 2 thus we have
+    // startLine + 1 <= midIndex <= endLine - 1
+    final int midIndex = (startLine + endLine) ~/ 2;
+    return _findLine(codeUnitOffset, midIndex, endLine) ?? _findLine(codeUnitOffset, startLine, midIndex);
+  }
+
   bool _disposed = false;
 
   @override
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 ae46ae6..d03a70b 100644
--- a/lib/web_ui/lib/src/engine/text/layout_service.dart
+++ b/lib/web_ui/lib/src/engine/text/layout_service.dart
@@ -390,7 +390,10 @@
     // it possible to do hit testing. Once we find the box, we look inside that
     // box to find where exactly the `offset` is located.
 
-    final ParagraphLine line = _findLineForY(offset.dy);
+    final ParagraphLine? line = _findLineForY(offset.dy);
+    if (line == null) {
+      return const ui.TextPosition(offset: 0);
+    }
     // [offset] is to the left of the line.
     if (offset.dx <= line.left) {
       return ui.TextPosition(
@@ -416,7 +419,10 @@
     return ui.TextPosition(offset: line.startIndex);
   }
 
-  ParagraphLine _findLineForY(double y) {
+  ParagraphLine? _findLineForY(double y) {
+    if (lines.isEmpty) {
+      return null;
+    }
     // We could do a binary search here but it's not worth it because the number
     // of line is typically low, and each iteration is a cheap comparison of
     // doubles.
diff --git a/lib/web_ui/lib/text.dart b/lib/web_ui/lib/text.dart
index 5d39948..ba09ee5 100644
--- a/lib/web_ui/lib/text.dart
+++ b/lib/web_ui/lib/text.dart
@@ -695,6 +695,9 @@
   TextRange getLineBoundary(TextPosition position);
   List<TextBox> getBoxesForPlaceholders();
   List<LineMetrics> computeLineMetrics();
+  LineMetrics? getLineMetricsAt(int lineNumber);
+  int get numberOfLines;
+  int? getLineNumberAt(int codeUnitOffset);
   void dispose();
   bool get debugDisposed;
 }
diff --git a/lib/web_ui/skwasm/text/paragraph.cpp b/lib/web_ui/skwasm/text/paragraph.cpp
index dcd1cd2..a44baf4 100644
--- a/lib/web_ui/skwasm/text/paragraph.cpp
+++ b/lib/web_ui/skwasm/text/paragraph.cpp
@@ -74,14 +74,18 @@
 
 SKWASM_EXPORT int paragraph_getLineNumberAt(Paragraph* paragraph,
                                             size_t characterIndex) {
-  return paragraph->getLineNumberAt(characterIndex);
+  return paragraph->getLineNumberAtUTF16Offset(characterIndex);
 }
 
 SKWASM_EXPORT LineMetrics* paragraph_getLineMetricsAtIndex(Paragraph* paragraph,
-                                                           size_t index) {
+                                                           size_t lineNumber) {
   auto metrics = new LineMetrics();
-  paragraph->getLineMetricsAt(index, metrics);
-  return metrics;
+  if (paragraph->getLineMetricsAt(lineNumber, metrics)) {
+    return metrics;
+  } else {
+    delete metrics;
+    return nullptr;
+  }
 }
 
 struct TextBoxList {
diff --git a/lib/web_ui/test/canvaskit/text_test.dart b/lib/web_ui/test/canvaskit/text_test.dart
index 2446145..2c788cf 100644
--- a/lib/web_ui/test/canvaskit/text_test.dart
+++ b/lib/web_ui/test/canvaskit/text_test.dart
@@ -124,6 +124,43 @@
       });
     });
 
+    test('empty paragraph', () {
+      const double fontSize = 10.0;
+      final ui.Paragraph paragraph = ui.ParagraphBuilder(CkParagraphStyle(
+        fontSize: fontSize,
+      )).build();
+      paragraph.layout(const ui.ParagraphConstraints(width: double.infinity));
+
+      expect(paragraph.getLineMetricsAt(0), isNull);
+      expect(paragraph.numberOfLines, 0);
+      expect(paragraph.getLineNumberAt(0), isNull);
+    });
+
+    test('Basic line related metrics', () {
+      const double fontSize = 10;
+      final ui.ParagraphBuilder builder = ui.ParagraphBuilder(CkParagraphStyle(
+        fontStyle: ui.FontStyle.normal,
+        fontWeight: ui.FontWeight.normal,
+        fontSize: fontSize,
+        maxLines: 1,
+        ellipsis: 'BBB',
+      ))..addText('A' * 100);
+      final ui.Paragraph paragraph = builder.build();
+      paragraph.layout(const ui.ParagraphConstraints(width: 100.0));
+
+      expect(paragraph.numberOfLines, 1);
+
+      expect(paragraph.getLineMetricsAt(-1), isNull);
+      expect(paragraph.getLineMetricsAt(0), isNotNull);
+      expect(paragraph.getLineMetricsAt(1), isNull);
+
+      expect(paragraph.getLineNumberAt(-1), isNull);
+      expect(paragraph.getLineNumberAt(0), 0);
+      expect(paragraph.getLineNumberAt(6), 0);
+      // The last 3 characters on the first line are ellipsized with BBB.
+      expect(paragraph.getLineMetricsAt(7), isNull);
+    });
+
     test('rounding hack disabled by default', () {
       expect(ui.ParagraphBuilder.shouldDisableRoundingHack, isTrue);
 
diff --git a/lib/web_ui/test/html/text_test.dart b/lib/web_ui/test/html/text_test.dart
index d9c7780..fc0db1f 100644
--- a/lib/web_ui/test/html/text_test.dart
+++ b/lib/web_ui/test/html/text_test.dart
@@ -79,10 +79,6 @@
       expect(paragraph.height, fontSize * 2.0); // because it wraps
       expect(paragraph.width, fontSize * 5.0);
       expect(paragraph.minIntrinsicWidth, fontSize * 4.0);
-
-      // TODO(yjbanov): due to https://github.com/flutter/flutter/issues/21965
-      //                Flutter reports a different number. Ours is correct
-      //                though.
       expect(paragraph.maxIntrinsicWidth, fontSize * 9.0);
       expect(paragraph.alphabeticBaseline, fontSize * .8);
       expect(
@@ -94,6 +90,31 @@
     }
   });
 
+  test('Basic line related metrics', () {
+    const double fontSize = 10;
+    final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle(
+      fontStyle: FontStyle.normal,
+      fontWeight: FontWeight.normal,
+      fontSize: fontSize,
+      maxLines: 1,
+      ellipsis: 'BBB',
+    ))..addText('A' * 100);
+    final Paragraph paragraph = builder.build();
+    paragraph.layout(const ParagraphConstraints(width: 100.0));
+
+    expect(paragraph.numberOfLines, 1);
+
+    expect(paragraph.getLineMetricsAt(-1), isNull);
+    expect(paragraph.getLineMetricsAt(0), isNotNull);
+    expect(paragraph.getLineMetricsAt(1), isNull);
+
+    expect(paragraph.getLineNumberAt(-1), isNull);
+    expect(paragraph.getLineNumberAt(0), 0);
+    expect(paragraph.getLineNumberAt(6), 0);
+    // The last 3 characters on the first line are ellipsized with BBB.
+    expect(paragraph.getLineNumberAt(7), isNull);
+  });
+
   test('Can disable rounding hack', () {
     if (!ParagraphBuilder.shouldDisableRoundingHack) {
       ParagraphBuilder.setDisableRoundingHack(true);
diff --git a/testing/dart/paragraph_test.dart b/testing/dart/paragraph_test.dart
index 771cf98..4af61bb 100644
--- a/testing/dart/paragraph_test.dart
+++ b/testing/dart/paragraph_test.dart
@@ -219,6 +219,74 @@
     expect(line.end, 10);
   });
 
+  test('getLineMetricsAt', () {
+    const double fontSize = 10.0;
+    final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle(
+      fontSize: fontSize,
+      textDirection: TextDirection.rtl,
+      height: 2.0,
+    ));
+    builder.addText('Test\npppp');
+    final Paragraph paragraph = builder.build();
+    paragraph.layout(const ParagraphConstraints(width: 100.0));
+    final LineMetrics? line = paragraph.getLineMetricsAt(1);
+    expect(line?.hardBreak, isTrue);
+    expect(line?.ascent, 15.0);
+    expect(line?.descent, 5.0);
+    expect(line?.height, 20.0);
+    expect(line?.width, 4 * 10.0);
+    expect(line?.left, 100.0 - 40.0);
+    expect(line?.baseline, 20.0 + 15.0);
+    expect(line?.lineNumber, 1);
+  });
+
+  test('line number', () {
+    const double fontSize = 10.0;
+    final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle(fontSize: fontSize));
+    builder.addText('Test\n\nTest');
+    final Paragraph paragraph = builder.build();
+    paragraph.layout(const ParagraphConstraints(width: 100.0));
+    expect(paragraph.numberOfLines, 3);
+    expect(paragraph.getLineNumberAt(4), 0); // first LF
+    expect(paragraph.getLineNumberAt(5), 1); // second LF
+    expect(paragraph.getLineNumberAt(6), 2); // "T" in the second "Test"
+  });
+
+  test('empty paragraph', () {
+    const double fontSize = 10.0;
+    final Paragraph paragraph = ParagraphBuilder(ParagraphStyle(
+      fontSize: fontSize,
+    )).build();
+    paragraph.layout(const ParagraphConstraints(width: double.infinity));
+
+    expect(paragraph.getLineMetricsAt(0), isNull);
+    expect(paragraph.numberOfLines, 0);
+    expect(paragraph.getLineNumberAt(0), isNull);
+  });
+
+  test('OOB indices as input', () {
+    const double fontSize = 10.0;
+    final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle(
+      fontSize: fontSize,
+      maxLines: 1,
+      ellipsis: 'BBB',
+    ))..addText('A' * 100);
+    final Paragraph paragraph = builder.build();
+    paragraph.layout(const ParagraphConstraints(width: 100));
+
+    expect(paragraph.numberOfLines, 1);
+
+    expect(paragraph.getLineMetricsAt(-1), isNull);
+    expect(paragraph.getLineMetricsAt(0), isNotNull);
+    expect(paragraph.getLineMetricsAt(1), isNull);
+
+    expect(paragraph.getLineNumberAt(-1), isNull);
+    expect(paragraph.getLineNumberAt(0), 0);
+    expect(paragraph.getLineNumberAt(6), 0);
+    // The last 3 characters on the first line are ellipsized with BBB.
+    expect(paragraph.getLineMetricsAt(7), isNull);
+  });
+
   test('painting a disposed paragraph does not crash', () {
     final Paragraph paragraph = ParagraphBuilder(ParagraphStyle()).build();
     paragraph.dispose();
diff --git a/third_party/txt/src/skia/paragraph_skia.cc b/third_party/txt/src/skia/paragraph_skia.cc
index 67d67b1..cbb7b85 100644
--- a/third_party/txt/src/skia/paragraph_skia.cc
+++ b/third_party/txt/src/skia/paragraph_skia.cc
@@ -301,6 +301,11 @@
   return line_metrics_.value();
 }
 
+bool ParagraphSkia::GetLineMetricsAt(int lineNumber,
+                                     skt::LineMetrics* lineMetrics) const {
+  return paragraph_->getLineMetricsAt(lineNumber, lineMetrics);
+};
+
 double ParagraphSkia::GetMinIntrinsicWidth() {
   return SkScalarToDouble(paragraph_->getMinIntrinsicWidth());
 }
@@ -378,6 +383,14 @@
   return Paragraph::Range<size_t>(range.start, range.end);
 }
 
+size_t ParagraphSkia::GetNumberOfLines() const {
+  return paragraph_->lineNumber();
+}
+
+int ParagraphSkia::GetLineNumberAt(size_t codeUnitIndex) const {
+  return paragraph_->getLineNumberAtUTF16Offset(codeUnitIndex);
+}
+
 TextStyle ParagraphSkia::SkiaToTxt(const skt::TextStyle& skia) {
   TextStyle txt;
 
diff --git a/third_party/txt/src/skia/paragraph_skia.h b/third_party/txt/src/skia/paragraph_skia.h
index 5779445..bc23b78 100644
--- a/third_party/txt/src/skia/paragraph_skia.h
+++ b/third_party/txt/src/skia/paragraph_skia.h
@@ -50,6 +50,14 @@
 
   std::vector<LineMetrics>& GetLineMetrics() override;
 
+  bool GetLineMetricsAt(
+      int lineNumber,
+      skia::textlayout::LineMetrics* lineMetrics) const override;
+
+  size_t GetNumberOfLines() const override;
+
+  int GetLineNumberAt(size_t utf16Offset) const override;
+
   bool DidExceedMaxLines() override;
 
   void Layout(double width) override;
diff --git a/third_party/txt/src/txt/paragraph.h b/third_party/txt/src/txt/paragraph.h
index 2d736fb..b8ee55d 100644
--- a/third_party/txt/src/txt/paragraph.h
+++ b/third_party/txt/src/txt/paragraph.h
@@ -22,6 +22,8 @@
 #include "line_metrics.h"
 #include "paragraph_style.h"
 #include "third_party/skia/include/core/SkRect.h"
+#include "third_party/skia/modules/skparagraph/include/Metrics.h"
+#include "third_party/skia/modules/skparagraph/include/Paragraph.h"
 
 class SkCanvas;
 
@@ -178,6 +180,22 @@
   virtual Range<size_t> GetWordBoundary(size_t offset) = 0;
 
   virtual std::vector<LineMetrics>& GetLineMetrics() = 0;
+
+  virtual bool GetLineMetricsAt(
+      int lineNumber,
+      skia::textlayout::LineMetrics* lineMetrics) const = 0;
+
+  // Returns the total number of visible lines in the paragraph.
+  virtual size_t GetNumberOfLines() const = 0;
+
+  // Returns the zero-indexed line number that contains the given code unit
+  // offset. Returns -1 if the given offset is out of bounds, or points to a
+  // codepoint that is logically after the last visible codepoint.
+  //
+  // If the offset points to a hard line break, this method returns the line
+  // number of the line this hard line break breaks, intead of the new line it
+  // creates.
+  virtual int GetLineNumberAt(size_t utf16Offset) const = 0;
 };
 
 }  // namespace txt