[web] Separate text fragmenting from layout (#34085)

diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter
index 3b4555d..627096d 100644
--- a/ci/licenses_golden/licenses_flutter
+++ b/ci/licenses_golden/licenses_flutter
@@ -1926,6 +1926,8 @@
 FILE: ../../../flutter/lib/web_ui/lib/src/engine/test_embedding.dart
 FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart
 FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/font_collection.dart
+FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/fragmenter.dart
+FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/layout_fragmenter.dart
 FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/layout_service.dart
 FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/line_break_properties.dart
 FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/line_breaker.dart
diff --git a/lib/web_ui/lib/src/engine.dart b/lib/web_ui/lib/src/engine.dart
index 54464dd..f83e43c 100644
--- a/lib/web_ui/lib/src/engine.dart
+++ b/lib/web_ui/lib/src/engine.dart
@@ -148,6 +148,8 @@
 export 'engine/test_embedding.dart';
 export 'engine/text/canvas_paragraph.dart';
 export 'engine/text/font_collection.dart';
+export 'engine/text/fragmenter.dart';
+export 'engine/text/layout_fragmenter.dart';
 export 'engine/text/layout_service.dart';
 export 'engine/text/line_break_properties.dart';
 export 'engine/text/line_breaker.dart';
diff --git a/lib/web_ui/lib/src/engine/dom.dart b/lib/web_ui/lib/src/engine/dom.dart
index 3ef7e82..ee75499 100644
--- a/lib/web_ui/lib/src/engine/dom.dart
+++ b/lib/web_ui/lib/src/engine/dom.dart
@@ -634,6 +634,8 @@
   external set fillStyle(Object? style);
   external String get font;
   external set font(String value);
+  external String get direction;
+  external set direction(String value);
   external set lineWidth(num? value);
   external set strokeStyle(Object? value);
   external Object? get strokeStyle;
diff --git a/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart b/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart
index e0c70eb..b2b93ab 100644
--- a/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart
+++ b/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart
@@ -913,9 +913,11 @@
     _cachedLastCssFont = null;
   }
 
-  void setCssFont(String cssFont) {
+  void setCssFont(String cssFont, ui.TextDirection textDirection) {
+    final DomCanvasRenderingContext2D ctx = _canvasPool.context;
+    ctx.direction = textDirection == ui.TextDirection.ltr ? 'ltr' : 'rtl';
+
     if (cssFont != _cachedLastCssFont) {
-      final DomCanvasRenderingContext2D ctx = _canvasPool.context;
       ctx.font = cssFont;
       _cachedLastCssFont = cssFont;
     }
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 b0de4f8..59cfc57 100644
--- a/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart
+++ b/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart
@@ -9,6 +9,7 @@
 import '../html/bitmap_canvas.dart';
 import '../profiler.dart';
 import '../util.dart';
+import 'layout_fragmenter.dart';
 import 'layout_service.dart';
 import 'paint_service.dart';
 import 'paragraph.dart';
@@ -16,6 +17,8 @@
 
 const ui.Color _defaultTextColor = ui.Color(0xFFFF0000);
 
+final String _placeholderChar = String.fromCharCode(0xFFFC);
+
 /// A paragraph made up of a flat list of text spans and placeholders.
 ///
 /// [CanvasParagraph] doesn't use a DOM element to represent the structure of
@@ -32,7 +35,7 @@
     required this.plainText,
     required this.placeholderCount,
     required this.canDrawOnCanvas,
-  });
+  }) : assert(spans.isNotEmpty);
 
   /// The flat list of spans that make up this paragraph.
   final List<ParagraphSpan> spans;
@@ -168,38 +171,28 @@
 
     // 2. Append all spans to the paragraph.
 
-    DomHTMLElement? lastSpanElement;
     for (int i = 0; i < lines.length; i++) {
       final ParagraphLine line = lines[i];
-      final List<RangeBox> boxes = line.boxes;
-      final StringBuffer buffer = StringBuffer();
-
-      int j = 0;
-      while (j < boxes.length) {
-        final RangeBox box = boxes[j++];
-
-        if (box is SpanBox) {
-          lastSpanElement = domDocument.createElement('flt-span') as
-              DomHTMLElement;
-          applyTextStyleToElement(
-            element: lastSpanElement,
-            style: box.span.style,
-            isSpan: true,
-          );
-          _positionSpanElement(lastSpanElement, line, box);
-          lastSpanElement.appendText(box.toText());
-          rootElement.append(lastSpanElement);
-          buffer.write(box.toText());
-        } else if (box is PlaceholderBox) {
-          lastSpanElement = null;
-        } else {
-          throw UnimplementedError('Unknown box type: ${box.runtimeType}');
+      for (final LayoutFragment fragment in line.fragments) {
+        if (fragment.isPlaceholder) {
+          continue;
         }
-      }
 
-      final String? ellipsis = line.ellipsis;
-      if (ellipsis != null) {
-        (lastSpanElement ?? rootElement).appendText(ellipsis);
+        final String text = fragment.getText(this);
+        if (text.isEmpty) {
+          continue;
+        }
+
+        final DomHTMLElement spanElement = domDocument.createElement('flt-span') as DomHTMLElement;
+        applyTextStyleToElement(
+          element: spanElement,
+          style: fragment.style,
+          isSpan: true,
+        );
+        _positionSpanElement(spanElement, line, fragment);
+
+        spanElement.appendText(text);
+        rootElement.append(spanElement);
       }
     }
 
@@ -283,8 +276,8 @@
   }
 }
 
-void _positionSpanElement(DomElement element, ParagraphLine line, RangeBox box) {
-  final ui.Rect boxRect = box.toTextBox(line, forPainting: true).toRect();
+void _positionSpanElement(DomElement element, ParagraphLine line, LayoutFragment fragment) {
+  final ui.Rect boxRect = fragment.toPaintingTextBox().toRect();
   element.style
     ..position = 'absolute'
     ..top = '${boxRect.top}px'
@@ -304,6 +297,9 @@
 
   /// The index of the end of the range of text represented by this span.
   int get end;
+
+  /// The resolved style of the span.
+  EngineTextStyle get style;
 }
 
 /// Represent a span of text in the paragraph.
@@ -323,7 +319,7 @@
     required this.end,
   });
 
-  /// The resolved style of the span.
+  @override
   final EngineTextStyle style;
 
   @override
@@ -341,14 +337,24 @@
 
 class PlaceholderSpan extends ParagraphPlaceholder implements ParagraphSpan {
   PlaceholderSpan(
-    int index,
-    super.width,
-    super.height,
-    super.alignment, {
-    required super.baselineOffset,
-    required super.baseline,
-  })   : start = index,
-        end = index;
+    this.style,
+    this.start,
+    this.end,
+    double width,
+    double height,
+    ui.PlaceholderAlignment alignment, {
+    required double baselineOffset,
+    required ui.TextBaseline baseline,
+  }) : super(
+          width,
+          height,
+          alignment,
+          baselineOffset: baselineOffset,
+          baseline: baseline,
+        );
+
+  @override
+  final EngineTextStyle style;
 
   @override
   final int start;
@@ -624,10 +630,19 @@
             alignment == ui.PlaceholderAlignment.belowBaseline ||
             alignment == ui.PlaceholderAlignment.baseline) || baseline != null);
 
+    final int start = _plainTextBuffer.length;
+    _plainTextBuffer.write(_placeholderChar);
+    final int end = _plainTextBuffer.length;
+
+    final EngineTextStyle style = _currentStyleNode.resolveStyle();
+    _updateCanDrawOnCanvas(style);
+
     _placeholderCount++;
     _placeholderScales.add(scale);
     _spans.add(PlaceholderSpan(
-      _plainTextBuffer.length,
+      style,
+      start,
+      end,
       width * scale,
       height * scale,
       alignment,
@@ -652,37 +667,54 @@
 
   @override
   void addText(String text) {
-    final EngineTextStyle style = _currentStyleNode.resolveStyle();
     final int start = _plainTextBuffer.length;
     _plainTextBuffer.write(text);
     final int end = _plainTextBuffer.length;
 
-    if (_canDrawOnCanvas) {
-      final ui.TextDecoration? decoration = style.decoration;
-      if (decoration != null && decoration != ui.TextDecoration.none) {
-        _canDrawOnCanvas = false;
-      }
-    }
-
-    if (_canDrawOnCanvas) {
-      final List<ui.FontFeature>? fontFeatures = style.fontFeatures;
-      if (fontFeatures != null && fontFeatures.isNotEmpty) {
-        _canDrawOnCanvas = false;
-      }
-    }
-
-    if (_canDrawOnCanvas) {
-      final List<ui.FontVariation>? fontVariations = style.fontVariations;
-      if (fontVariations != null && fontVariations.isNotEmpty) {
-        _canDrawOnCanvas = false;
-      }
-    }
+    final EngineTextStyle style = _currentStyleNode.resolveStyle();
+    _updateCanDrawOnCanvas(style);
 
     _spans.add(FlatTextSpan(style: style, start: start, end: end));
   }
 
+  void _updateCanDrawOnCanvas(EngineTextStyle style) {
+    if (!_canDrawOnCanvas) {
+      return;
+    }
+
+    final ui.TextDecoration? decoration = style.decoration;
+    if (decoration != null && decoration != ui.TextDecoration.none) {
+      _canDrawOnCanvas = false;
+      return;
+    }
+
+    final List<ui.FontFeature>? fontFeatures = style.fontFeatures;
+    if (fontFeatures != null && fontFeatures.isNotEmpty) {
+      _canDrawOnCanvas = false;
+      return;
+    }
+
+    final List<ui.FontVariation>? fontVariations = style.fontVariations;
+    if (fontVariations != null && fontVariations.isNotEmpty) {
+      _canDrawOnCanvas = false;
+      return;
+    }
+  }
+
   @override
   CanvasParagraph build() {
+    if (_spans.isEmpty) {
+      // In case `addText` and `addPlaceholder` were never called.
+      //
+      // We want the paragraph to always have a non-empty list of spans to match
+      // the expectations of the [LayoutFragmenter].
+      _spans.add(FlatTextSpan(
+        style: _rootStyleNode.resolveStyle(),
+        start: 0,
+        end: 0,
+      ));
+    }
+
     return CanvasParagraph(
       _spans,
       paragraphStyle: _paragraphStyle,
diff --git a/lib/web_ui/lib/src/engine/text/fragmenter.dart b/lib/web_ui/lib/src/engine/text/fragmenter.dart
new file mode 100644
index 0000000..9fdd82e
--- /dev/null
+++ b/lib/web_ui/lib/src/engine/text/fragmenter.dart
@@ -0,0 +1,34 @@
+// 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.
+
+/// Splits [text] into a list of [TextFragment]s.
+///
+/// Various subclasses can perform the fragmenting based on their own criteria.
+///
+/// See:
+///
+/// - [LineBreakFragmenter]: Fragments text based on line break opportunities.
+/// - [BidiFragmenter]: Fragments text based on directionality.
+abstract class TextFragmenter {
+  const TextFragmenter(this.text);
+
+  /// The text to be fragmented.
+  final String text;
+
+  /// Performs the fragmenting of [text] and returns a list of [TextFragment]s.
+  List<TextFragment> fragment();
+}
+
+/// Represents a fragment produced by [TextFragmenter].
+abstract class TextFragment {
+  const TextFragment(this.start, this.end);
+
+  final int start;
+  final int end;
+
+  /// Whether this fragment's range overlaps with the range from [start] to [end].
+  bool overlapsWith(int start, int end) {
+    return start < this.end && this.start < end;
+  }
+}
diff --git a/lib/web_ui/lib/src/engine/text/layout_fragmenter.dart b/lib/web_ui/lib/src/engine/text/layout_fragmenter.dart
new file mode 100644
index 0000000..0c48c0f
--- /dev/null
+++ b/lib/web_ui/lib/src/engine/text/layout_fragmenter.dart
@@ -0,0 +1,628 @@
+// 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.
+
+import 'dart:math' as math;
+
+import 'package:ui/ui.dart' as ui;
+
+import '../util.dart';
+import 'canvas_paragraph.dart';
+import 'fragmenter.dart';
+import 'layout_service.dart';
+import 'line_breaker.dart';
+import 'paragraph.dart';
+import 'text_direction.dart';
+
+/// Splits [text] into fragments that are ready to be laid out by
+/// [TextLayoutService].
+///
+/// This fragmenter takes into account line breaks, directionality and styles.
+class LayoutFragmenter extends TextFragmenter {
+  const LayoutFragmenter(super.text, this.paragraphSpans);
+
+  final List<ParagraphSpan> paragraphSpans;
+
+  @override
+  List<LayoutFragment> fragment() {
+    final List<LayoutFragment> fragments = <LayoutFragment>[];
+
+    int fragmentStart = 0;
+
+    final Iterator<LineBreakFragment> lineBreakFragments = LineBreakFragmenter(text).fragment().iterator..moveNext();
+    final Iterator<BidiFragment> bidiFragments = BidiFragmenter(text).fragment().iterator..moveNext();
+    final Iterator<ParagraphSpan> spans = paragraphSpans.iterator..moveNext();
+
+    LineBreakFragment currentLineBreakFragment = lineBreakFragments.current;
+    BidiFragment currentBidiFragment = bidiFragments.current;
+    ParagraphSpan currentSpan = spans.current;
+
+    while (true) {
+      final int fragmentEnd = math.min(
+        currentLineBreakFragment.end,
+        math.min(
+          currentBidiFragment.end,
+          currentSpan.end,
+        ),
+      );
+
+      final int distanceFromLineBreak = currentLineBreakFragment.end - fragmentEnd;
+
+      final LineBreakType lineBreakType = distanceFromLineBreak == 0
+          ? currentLineBreakFragment.type
+          : LineBreakType.prohibited;
+
+      final int trailingNewlines = currentLineBreakFragment.trailingNewlines - distanceFromLineBreak;
+      final int trailingSpaces = currentLineBreakFragment.trailingSpaces - distanceFromLineBreak;
+
+      final int fragmentLength = fragmentEnd - fragmentStart;
+      fragments.add(LayoutFragment(
+        fragmentStart,
+        fragmentEnd,
+        lineBreakType,
+        currentBidiFragment.textDirection,
+        currentBidiFragment.fragmentFlow,
+        currentSpan,
+        trailingNewlines: clampInt(trailingNewlines, 0, fragmentLength),
+        trailingSpaces: clampInt(trailingSpaces, 0, fragmentLength),
+      ));
+
+      fragmentStart = fragmentEnd;
+
+      bool moved = false;
+      if (currentLineBreakFragment.end == fragmentEnd) {
+        if (lineBreakFragments.moveNext()) {
+          moved = true;
+          currentLineBreakFragment = lineBreakFragments.current;
+        }
+      }
+      if (currentBidiFragment.end == fragmentEnd) {
+        if (bidiFragments.moveNext()) {
+          moved = true;
+          currentBidiFragment = bidiFragments.current;
+        }
+      }
+      if (currentSpan.end == fragmentEnd) {
+        if (spans.moveNext()) {
+          moved = true;
+          currentSpan = spans.current;
+        }
+      }
+
+      // Once we reached the end of all fragments, exit the loop.
+      if (!moved) {
+        break;
+      }
+    }
+
+    return fragments;
+  }
+}
+
+abstract class _CombinedFragment extends TextFragment {
+  _CombinedFragment(
+    super.start,
+    super.end,
+    this.type,
+    this._textDirection,
+    this.fragmentFlow,
+    this.span, {
+    required this.trailingNewlines,
+    required this.trailingSpaces,
+  })  : assert(trailingNewlines >= 0),
+        assert(trailingSpaces >= trailingNewlines);
+
+  final LineBreakType type;
+
+  ui.TextDirection? get textDirection => _textDirection;
+  ui.TextDirection? _textDirection;
+
+  final FragmentFlow fragmentFlow;
+
+  final ParagraphSpan span;
+
+  final int trailingNewlines;
+
+  final int trailingSpaces;
+
+  @override
+  int get hashCode => Object.hash(
+    start,
+    end,
+    type,
+    textDirection,
+    fragmentFlow,
+    span,
+    trailingNewlines,
+    trailingSpaces,
+  );
+
+  @override
+  bool operator ==(Object other) {
+    return other is LayoutFragment &&
+        other.start == start &&
+        other.end == end &&
+        other.type == type &&
+        other.textDirection == textDirection &&
+        other.fragmentFlow == fragmentFlow &&
+        other.span == span &&
+        other.trailingNewlines == trailingNewlines &&
+        other.trailingSpaces == trailingSpaces;
+  }
+}
+
+class LayoutFragment extends _CombinedFragment with _FragmentMetrics, _FragmentPosition, _FragmentBox {
+  LayoutFragment(
+    super.start,
+    super.end,
+    super.type,
+    super.textDirection,
+    super.fragmentFlow,
+    super.span, {
+    required super.trailingNewlines,
+    required super.trailingSpaces,
+  });
+
+  int get length => end - start;
+  bool get isSpaceOnly => length == trailingSpaces;
+  bool get isPlaceholder => span is PlaceholderSpan;
+  bool get isBreak => type != LineBreakType.prohibited;
+  bool get isHardBreak => type == LineBreakType.mandatory || type == LineBreakType.endOfText;
+  EngineTextStyle get style => span.style;
+
+  /// Returns the substring from [paragraph] that corresponds to this fragment,
+  /// excluding new line characters.
+  String getText(CanvasParagraph paragraph) {
+    return paragraph.plainText.substring(start, end - trailingNewlines);
+  }
+
+  /// Splits this fragment into two fragments with the split point being the
+  /// given [index].
+  // TODO(mdebbar): If we ever get multiple return values in Dart, we should use it!
+  //                See: https://github.com/dart-lang/language/issues/68
+  List<LayoutFragment?> split(int index) {
+    assert(start <= index);
+    assert(index <= end);
+
+    if (start == index) {
+      return <LayoutFragment?>[null, this];
+    }
+
+    if (end == index) {
+      return <LayoutFragment?>[this, null];
+    }
+
+    // The length of the second fragment after the split.
+    final int secondLength = end - index;
+
+    // Trailing spaces/new lines go to the second fragment. Any left over goes
+    // to the first fragment.
+    final int secondTrailingNewlines = math.min(trailingNewlines, secondLength);
+    final int secondTrailingSpaces = math.min(trailingSpaces, secondLength);
+
+    return <LayoutFragment>[
+      LayoutFragment(
+        start,
+        index,
+        LineBreakType.prohibited,
+        textDirection,
+        fragmentFlow,
+        span,
+        trailingNewlines: trailingNewlines - secondTrailingNewlines,
+        trailingSpaces: trailingSpaces - secondTrailingSpaces,
+      ),
+      LayoutFragment(
+        index,
+        end,
+        type,
+        textDirection,
+        fragmentFlow,
+        span,
+        trailingNewlines: secondTrailingNewlines,
+        trailingSpaces: secondTrailingSpaces,
+      ),
+    ];
+  }
+
+  @override
+  String toString() {
+    return '$LayoutFragment($start, $end, $type, $textDirection)';
+  }
+}
+
+mixin _FragmentMetrics on _CombinedFragment {
+  late Spanometer _spanometer;
+
+  /// The rise from the baseline as calculated from the font and style for this text.
+  double get ascent => _ascent;
+  late double _ascent;
+
+  /// The drop from the baseline as calculated from the font and style for this text.
+  double get descent => _descent;
+  late double _descent;
+
+  /// The width of the measured text, not including trailing spaces.
+  double get widthExcludingTrailingSpaces => _widthExcludingTrailingSpaces;
+  late double _widthExcludingTrailingSpaces;
+
+  /// The width of the measured text, including any trailing spaces.
+  double get widthIncludingTrailingSpaces => _widthIncludingTrailingSpaces + _extraWidthForJustification;
+  late double _widthIncludingTrailingSpaces;
+
+  double _extraWidthForJustification = 0.0;
+
+  /// The total height as calculated from the font and style for this text.
+  double get height => ascent + descent;
+
+  double get widthOfTrailingSpaces => widthIncludingTrailingSpaces - widthExcludingTrailingSpaces;
+
+  /// Set measurement values for the fragment.
+  void setMetrics(Spanometer spanometer, {
+    required double ascent,
+    required double descent,
+    required double widthExcludingTrailingSpaces,
+    required double widthIncludingTrailingSpaces,
+  }) {
+    _spanometer = spanometer;
+    _ascent = ascent;
+    _descent = descent;
+    _widthExcludingTrailingSpaces = widthExcludingTrailingSpaces;
+    _widthIncludingTrailingSpaces = widthIncludingTrailingSpaces;
+  }
+}
+
+/// Encapsulates positioning of the fragment relative to the line.
+///
+/// The coordinates are all relative to the line it belongs to. For example,
+/// [left] is the distance from the left edge of the line to the left edge of
+/// the fragment.
+///
+/// This is what the various measurements/coordinates look like for a fragment
+/// in an LTR paragraph:
+///
+///          *------------------------line.width-----------------*
+///                            *---width----*
+///          ┌─────────────────┬────────────┬────────────────────┐
+///          │                 │--FRAGMENT--│                    │
+///          └─────────────────┴────────────┴────────────────────┘
+///          *---startOffset---*
+///          *------left-------*
+///          *--------endOffset-------------*
+///          *----------right---------------*
+///
+///
+/// And in an RTL paragraph, [startOffset] and [endOffset] are flipped because
+/// the line starts from the right. Here's what they look like:
+///
+///          *------------------------line.width-----------------*
+///                            *---width----*
+///          ┌─────────────────┬────────────┬────────────────────┐
+///          │                 │--FRAGMENT--│                    │
+///          └─────────────────┴────────────┴────────────────────┘
+///                                         *----startOffset-----*
+///          *------left-------*
+///                            *-----------endOffset-------------*
+///          *----------right---------------*
+///
+mixin _FragmentPosition on _CombinedFragment, _FragmentMetrics {
+  /// The distance from the beginning of the line to the beginning of the fragment.
+  double get startOffset => _startOffset;
+  late double _startOffset;
+
+  /// The width of the line that contains this fragment.
+  late ParagraphLine line;
+
+  /// The distance from the beginning of the line to the end of the fragment.
+  double get endOffset => startOffset + widthIncludingTrailingSpaces;
+
+  /// The distance from the left edge of the line to the left edge of the fragment.
+  double get left => line.textDirection == ui.TextDirection.ltr
+      ? startOffset
+      : line.width - endOffset;
+
+  /// The distance from the left edge of the line to the right edge of the fragment.
+  double get right => line.textDirection == ui.TextDirection.ltr
+      ? endOffset
+      : line.width - startOffset;
+
+  /// Set the horizontal position of this fragment relative to the [line] that
+  /// contains it.
+  void setPosition({
+    required double startOffset,
+    required ui.TextDirection textDirection,
+  }) {
+    _startOffset = startOffset;
+    _textDirection ??= textDirection;
+  }
+
+  /// Adjust the width of this fragment for paragraph justification.
+  void justifyTo({required double paragraphWidth}) {
+    // Only justify this fragment if it's not a trailing space in the line.
+    if (end > line.endIndex - line.trailingSpaces) {
+      // Don't justify fragments that are part of trailing spaces of the line.
+      return;
+    }
+
+    if (trailingSpaces == 0) {
+      // If this fragment has no spaces, there's nothing to justify.
+      return;
+    }
+
+    final double justificationTotal = paragraphWidth - line.width;
+    final double justificationPerSpace = justificationTotal / line.nonTrailingSpaces;
+    _extraWidthForJustification = justificationPerSpace * trailingSpaces;
+  }
+}
+
+/// Encapsulates calculations related to the bounding box of the fragment
+/// relative to the paragraph.
+mixin _FragmentBox on _CombinedFragment, _FragmentMetrics, _FragmentPosition {
+  double get top => line.baseline - ascent;
+  double get bottom => line.baseline + descent;
+
+  late final ui.TextBox _textBoxIncludingTrailingSpaces = ui.TextBox.fromLTRBD(
+    line.left + left,
+    top,
+    line.left + right,
+    bottom,
+    textDirection!,
+  );
+
+  /// Whether or not the trailing spaces of this fragment are part of trailing
+  /// spaces of the line containing the fragment.
+  bool get _isPartOfTrailingSpacesInLine => end > line.endIndex - line.trailingSpaces;
+
+  /// Returns a [ui.TextBox] for the purpose of painting this fragment.
+  ///
+  /// The coordinates of the resulting [ui.TextBox] are relative to the
+  /// paragraph, not to the line.
+  ///
+  /// Trailing spaces in each line aren't painted on the screen, so they are
+  /// excluded from the resulting text box.
+  ui.TextBox toPaintingTextBox() {
+    if (_isPartOfTrailingSpacesInLine) {
+      // For painting, we exclude the width of trailing spaces from the box.
+      return textDirection! == ui.TextDirection.ltr
+          ? ui.TextBox.fromLTRBD(
+              line.left + left,
+              top,
+              line.left + right - widthOfTrailingSpaces,
+              bottom,
+              textDirection!,
+            )
+          : ui.TextBox.fromLTRBD(
+              line.left + left + widthOfTrailingSpaces,
+              top,
+              line.left + right,
+              bottom,
+              textDirection!,
+            );
+    }
+    return _textBoxIncludingTrailingSpaces;
+  }
+
+  /// Returns a [ui.TextBox] representing this fragment.
+  ///
+  /// The coordinates of the resulting [ui.TextBox] are relative to the
+  /// paragraph, not to the line.
+  ///
+  /// As opposed to [toPaintingTextBox], the resulting text box from this method
+  /// includes trailing spaces of the fragment.
+  ui.TextBox toTextBox({
+    int? start,
+    int? end,
+  }) {
+    start ??= this.start;
+    end ??= this.end;
+
+    if (start <= this.start && end >= this.end - trailingNewlines) {
+      return _textBoxIncludingTrailingSpaces;
+    }
+    return _intersect(start, end);
+  }
+
+  /// Performs the intersection of this fragment with the range given by [start] and
+  /// [end] indices, and returns a [ui.TextBox] representing that intersection.
+  ///
+  /// The coordinates of the resulting [ui.TextBox] are relative to the
+  /// paragraph, not to the line.
+  ui.TextBox _intersect(int start, int end) {
+    // `_intersect` should only be called when there's an actual intersection.
+    assert(start > this.start || end < this.end);
+
+    final double before;
+    if (start <= this.start) {
+      before = 0.0;
+    } else {
+      _spanometer.currentSpan = span;
+      before = _spanometer.measureRange(this.start, start);
+    }
+
+    final double after;
+    if (end >= this.end - trailingNewlines) {
+      after = 0.0;
+    } else {
+      _spanometer.currentSpan = span;
+      after = _spanometer.measureRange(end, this.end - trailingNewlines);
+    }
+
+    final double left, right;
+    if (textDirection! == ui.TextDirection.ltr) {
+      // Example: let's say the text is "Loremipsum" and we want to get the box
+      // for "rem". In this case, `before` is the width of "Lo", and `after`
+      // is the width of "ipsum".
+      //
+      // Here's how the measurements/coordinates look like:
+      //
+      //              before         after
+      //              |----|     |----------|
+      //              +---------------------+
+      //              | L o r e m i p s u m |
+      //              +---------------------+
+      //    this.left ^                     ^ this.right
+      left = this.left + before;
+      right = this.right - after;
+    } else {
+      // Example: let's say the text is "txet_werbeH" ("Hebrew_text" flowing from
+      // right to left). Say we want to get the box for "brew". The `before` is
+      // the width of "He", and `after` is the width of "_text".
+      //
+      //                 after           before
+      //              |----------|       |----|
+      //              +-----------------------+
+      //              | t x e t _ w e r b e H |
+      //              +-----------------------+
+      //    this.left ^                       ^ this.right
+      //
+      // Notice how `before` and `after` are reversed in the RTL example. That's
+      // because the text flows from right to left.
+      left = this.left + after;
+      right = this.right - before;
+    }
+
+    // The fragment's left and right edges are relative to the line. In order
+    // to make them relative to the paragraph, we need to add the left edge of
+    // the line.
+    return ui.TextBox.fromLTRBD(
+      line.left + left,
+      top,
+      line.left + right,
+      bottom,
+      textDirection!,
+    );
+  }
+
+  /// Returns the text position within this fragment's range that's closest to
+  /// the given [x] offset.
+  ///
+  /// The [x] offset is expected to be relative to the left edge of the fragment.
+  ui.TextPosition getPositionForX(double x) {
+    x = _makeXDirectionAgnostic(x);
+
+    final int startIndex = start;
+    final int endIndex = end - trailingNewlines;
+
+    // Check some special cases to return the result quicker.
+
+    final int length = endIndex - startIndex;
+    if (length == 0) {
+      return ui.TextPosition(offset: startIndex);
+    }
+    if (length == 1) {
+      // Find out if `x` is closer to `startIndex` or `endIndex`.
+      final double distanceFromStart = x;
+      final double distanceFromEnd = widthIncludingTrailingSpaces - x;
+      return distanceFromStart < distanceFromEnd
+          ? ui.TextPosition(offset: startIndex)
+          : ui.TextPosition(offset: endIndex, affinity: ui.TextAffinity.upstream,);
+    }
+
+    _spanometer.currentSpan = span;
+    // The resulting `cutoff` is the index of the character where the `x` offset
+    // falls. We should return the text position of either `cutoff` or
+    // `cutoff + 1` depending on which one `x` is closer to.
+    //
+    //   offset x
+    //      ↓
+    // "A B C D E F"
+    //     ↑
+    //   cutoff
+    final int cutoff = _spanometer.forceBreak(
+      startIndex,
+      endIndex,
+      availableWidth: x,
+      allowEmpty: true,
+    );
+
+    if (cutoff == endIndex) {
+      return ui.TextPosition(
+        offset: cutoff,
+        affinity: ui.TextAffinity.upstream,
+      );
+    }
+
+    final double lowWidth = _spanometer.measureRange(startIndex, cutoff);
+    final double highWidth = _spanometer.measureRange(startIndex, cutoff + 1);
+
+    // See if `x` is closer to `cutoff` or `cutoff + 1`.
+    if (x - lowWidth < highWidth - x) {
+      // The offset is closer to cutoff.
+      return ui.TextPosition(offset: cutoff);
+    } else {
+      // The offset is closer to cutoff + 1.
+      return ui.TextPosition(
+        offset: cutoff + 1,
+        affinity: ui.TextAffinity.upstream,
+      );
+    }
+  }
+
+  /// Transforms the [x] coordinate to be direction-agnostic.
+  ///
+  /// The X (input) is relative to the [left] edge of the fragment, and this
+  /// method returns an X' (output) that's relative to beginning of the text.
+  ///
+  /// Here's how it looks for a fragment with LTR content:
+  ///
+  ///          *------------------------line width------------------*
+  ///                      *-----X (input)
+  ///          ┌───────────┬────────────────────────┬───────────────┐
+  ///          │           │ ---text-direction----> │               │
+  ///          └───────────┴────────────────────────┴───────────────┘
+  ///                      *-----X' (output)
+  ///          *---left----*
+  ///          *---------------right----------------*
+  ///
+  ///
+  /// And here's how it looks for a fragment with RTL content:
+  ///
+  ///          *------------------------line width------------------*
+  ///                      *-----X (input)
+  ///          ┌───────────┬────────────────────────┬───────────────┐
+  ///          │           │ <---text-direction---- │               │
+  ///          └───────────┴────────────────────────┴───────────────┘
+  ///                   (output) X'-----------------*
+  ///          *---left----*
+  ///          *---------------right----------------*
+  ///
+  double _makeXDirectionAgnostic(double x) {
+    if (textDirection == ui.TextDirection.rtl) {
+      return widthIncludingTrailingSpaces - x;
+    }
+    return x;
+  }
+}
+
+class EllipsisFragment extends LayoutFragment {
+  EllipsisFragment(
+    int index,
+    ParagraphSpan span,
+  ) : super(
+          index,
+          index,
+          LineBreakType.endOfText,
+          null,
+          // The ellipsis is always at the end of the line, so it can't be
+          // sandwiched. This means it'll always follow the paragraph direction.
+          FragmentFlow.sandwich,
+          span,
+          trailingNewlines: 0,
+          trailingSpaces: 0,
+        );
+
+  @override
+  bool get isSpaceOnly => false;
+
+  @override
+  bool get isPlaceholder => false;
+
+  @override
+  String getText(CanvasParagraph paragraph) {
+    return paragraph.paragraphStyle.ellipsis!;
+  }
+
+  @override
+  List<LayoutFragment> split(int index) {
+    throw Exception('Cannot split an EllipsisFragment');
+  }
+}
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 a5b91bd..b3bdb62 100644
--- a/lib/web_ui/lib/src/engine/text/layout_service.dart
+++ b/lib/web_ui/lib/src/engine/text/layout_service.dart
@@ -9,6 +9,7 @@
 
 import '../dom.dart';
 import 'canvas_paragraph.dart';
+import 'layout_fragmenter.dart';
 import 'line_breaker.dart';
 import 'measurement.dart';
 import 'paragraph.dart';
@@ -23,7 +24,8 @@
 
   final CanvasParagraph paragraph;
 
-  final DomCanvasRenderingContext2D context = createDomCanvasElement().context2D;
+  final DomCanvasRenderingContext2D context =
+      createDomCanvasElement().context2D;
 
   // *** Results of layout *** //
 
@@ -51,13 +53,10 @@
   ui.Rect get paintBounds => _paintBounds;
   ui.Rect _paintBounds = ui.Rect.zero;
 
-  // *** Convenient shortcuts used during layout *** //
+  late final Spanometer spanometer = Spanometer(paragraph, context);
 
-  int? get maxLines => paragraph.paragraphStyle.maxLines;
-  bool get unlimitedLines => maxLines == null;
-
-  String? get ellipsis => paragraph.paragraphStyle.ellipsis;
-  bool get hasEllipsis => ellipsis != null;
+  late final LayoutFragmenter layoutFragmenter =
+      LayoutFragmenter(paragraph.plainText, paragraph.spans);
 
   /// Performs the layout on a paragraph given the [constraints].
   ///
@@ -74,8 +73,6 @@
   /// 2. Enough lines have been computed to satisfy [maxLines].
   /// 3. An ellipsis is appended because of an overflow.
   void performLayout(ui.ParagraphConstraints constraints) {
-    final int spanCount = paragraph.spans.length;
-
     // Reset results from previous layout.
     width = constraints.width;
     height = 0.0;
@@ -85,129 +82,51 @@
     didExceedMaxLines = false;
     lines.clear();
 
-    if (spanCount == 0) {
-      return;
-    }
-
-    final Spanometer spanometer = Spanometer(paragraph, context);
-
-    int spanIndex = 0;
     LineBuilder currentLine =
         LineBuilder.first(paragraph, spanometer, maxWidth: constraints.width);
 
-    // The only way to exit this while loop is by hitting one of the `break;`
-    // statements (e.g. when we reach `endOfText`, when ellipsis has been
-    // appended).
-    while (true) {
-      // ************************** //
-      // *** HANDLE END OF TEXT *** //
-      // ************************** //
+    final List<LayoutFragment> fragments =
+        layoutFragmenter.fragment()..forEach(spanometer.measureFragment);
 
-      // All spans have been consumed.
-      final bool reachedEnd = spanIndex == spanCount;
-      if (reachedEnd) {
-        // In some cases, we need to extend the line to the end of text and
-        // build it:
-        //
-        // 1. Line is not empty. This could happen when the last span is a
-        //    placeholder.
-        //
-        // 2. We haven't reached `LineBreakType.endOfText` yet. This could
-        //    happen when the last character is a new line.
-        if (currentLine.isNotEmpty || currentLine.end.type != LineBreakType.endOfText) {
-          currentLine.extendToEndOfText();
+    outerLoop:
+    for (int i = 0; i < fragments.length; i++) {
+      final LayoutFragment fragment = fragments[i];
+
+      currentLine.addFragment(fragment);
+
+      while (currentLine.isOverflowing) {
+        if (currentLine.canHaveEllipsis) {
+          currentLine.insertEllipsis();
           lines.add(currentLine.build());
+          didExceedMaxLines = true;
+          break outerLoop;
         }
-        break;
-      }
 
-      // ********************************* //
-      // *** THE MAIN MEASUREMENT PART *** //
-      // ********************************* //
-
-      ParagraphSpan span = paragraph.spans[spanIndex];
-
-      if (span is PlaceholderSpan) {
-        if (currentLine.widthIncludingSpace + span.width <= constraints.width) {
-          // The placeholder fits on the current line.
-          currentLine.addPlaceholder(span);
+        if (currentLine.isBreakable) {
+          currentLine.revertToLastBreakOpportunity();
         } else {
-          // The placeholder can't fit on the current line.
-          if (currentLine.isNotEmpty) {
-            lines.add(currentLine.build());
-            currentLine = currentLine.nextLine();
-          }
-          currentLine.addPlaceholder(span);
-        }
-        spanIndex++;
-      } else if (span is FlatTextSpan) {
-        spanometer.currentSpan = span;
-        final DirectionalPosition nextBreak = currentLine.findNextBreak();
-        final double additionalWidth =
-            currentLine.getAdditionalWidthTo(nextBreak.lineBreak);
-
-        if (currentLine.width + additionalWidth <= constraints.width) {
-          // The line can extend to `nextBreak` without overflowing.
-          currentLine.extendTo(nextBreak);
-          if (nextBreak.type == LineBreakType.mandatory) {
-            lines.add(currentLine.build());
-            currentLine = currentLine.nextLine();
-          }
-        } else {
-          // The chunk of text can't fit into the current line.
-          final bool isLastLine =
-              (hasEllipsis && unlimitedLines) || lines.length + 1 == maxLines;
-
-          if (isLastLine && hasEllipsis) {
-            // We've reached the line that requires an ellipsis to be appended
-            // to it.
-
-            currentLine.forceBreak(
-              nextBreak,
-              allowEmpty: true,
-              ellipsis: ellipsis,
-            );
-            lines.add(currentLine.build(ellipsis: ellipsis));
-            break;
-          } else if (currentLine.isNotBreakable) {
-            // The entire line is unbreakable, which means we are dealing
-            // with a single block of text that doesn't fit in a single line.
-            // We need to force-break it without adding an ellipsis.
-
-            currentLine.forceBreak(nextBreak, allowEmpty: false);
-            lines.add(currentLine.build());
-            currentLine = currentLine.nextLine();
-          } else {
-            // Normal line break.
-            currentLine.revertToLastBreakOpportunity();
-            // If a revert had occurred in the line, we need to revert the span
-            // index accordingly.
-            //
-            // If no revert occurred, then `revertedToSpan` will be equal to
-            // `span` and the following while loop won't do anything.
-            final ParagraphSpan revertedToSpan = currentLine.lastSegment.span;
-            while (span != revertedToSpan) {
-              span = paragraph.spans[--spanIndex];
-            }
-            lines.add(currentLine.build());
-            currentLine = currentLine.nextLine();
-          }
+          // The line can't be legally broken, so the last fragment (that caused
+          // the line to overflow) needs to be force-broken.
+          currentLine.forceBreakLastFragment();
         }
 
-        // Only go to the next span if we've reached the end of this span.
-        if (currentLine.end.index >= span.end) {
-          currentLine.createBox();
-          ++spanIndex;
-        }
-      } else {
-        throw UnimplementedError('Unknown span type: ${span.runtimeType}');
+        i += currentLine.appendZeroWidthFragments(fragments, startFrom: i + 1);
+        lines.add(currentLine.build());
+        currentLine = currentLine.nextLine();
       }
 
-      if (lines.length == maxLines) {
-        break;
+      if (currentLine.isHardBreak) {
+        lines.add(currentLine.build());
+        currentLine = currentLine.nextLine();
       }
     }
 
+    final int? maxLines = paragraph.paragraphStyle.maxLines;
+    if (maxLines != null && lines.length > maxLines) {
+      didExceedMaxLines = true;
+      lines.removeRange(maxLines, lines.length);
+    }
+
     // ***************************************************************** //
     // *** PARAGRAPH BASELINE & HEIGHT & LONGEST LINE & PAINT BOUNDS *** //
     // ***************************************************************** //
@@ -241,69 +160,58 @@
       height,
     );
 
-    // ********************** //
-    // *** POSITION BOXES *** //
-    // ********************** //
+    // **************************** //
+    // *** FRAGMENT POSITIONING *** //
+    // **************************** //
 
+    // We have to perform justification alignment first so that we can position
+    // fragments correctly later.
     if (lines.isNotEmpty) {
-      final ParagraphLine lastLine = lines.last;
-      final bool shouldJustifyParagraph =
-        width.isFinite &&
-        paragraph.paragraphStyle.textAlign == ui.TextAlign.justify;
+      final bool shouldJustifyParagraph = width.isFinite &&
+          paragraph.paragraphStyle.textAlign == ui.TextAlign.justify;
 
-      for (final ParagraphLine line in lines) {
+      if (shouldJustifyParagraph) {
         // Don't apply justification to the last line.
-        final bool shouldJustifyLine = shouldJustifyParagraph && line != lastLine;
-        _positionLineBoxes(line, withJustification: shouldJustifyLine);
+        for (int i = 0; i < lines.length - 1; i++) {
+          for (final LayoutFragment fragment in lines[i].fragments) {
+            fragment.justifyTo(paragraphWidth: width);
+          }
+        }
       }
     }
 
+    lines.forEach(_positionLineFragments);
+
     // ******************************** //
     // *** MAX/MIN INTRINSIC WIDTHS *** //
     // ******************************** //
 
-    spanIndex = 0;
-    currentLine =
-        LineBuilder.first(paragraph, spanometer, maxWidth: constraints.width);
+    // TODO(mdebbar): Handle maxLines https://github.com/flutter/flutter/issues/91254
 
-    while (spanIndex < spanCount) {
-      final ParagraphSpan span = paragraph.spans[spanIndex];
-      bool breakToNextLine = false;
+    double runningMinIntrinsicWidth = 0;
+    double runningMaxIntrinsicWidth = 0;
 
-      if (span is PlaceholderSpan) {
-        currentLine.addPlaceholder(span);
-        spanIndex++;
-      } else if (span is FlatTextSpan) {
-        spanometer.currentSpan = span;
-        final DirectionalPosition nextBreak = currentLine.findNextBreak();
-
-        // For the purpose of max intrinsic width, we don't care if the line
-        // fits within the constraints or not. So we always extend it.
-        currentLine.extendTo(nextBreak);
-        if (nextBreak.type == LineBreakType.mandatory) {
-          // We don't want to break the line now because we want to update
-          // min/max intrinsic widths below first.
-          breakToNextLine = true;
-        }
-
-        // Only go to the next span if we've reached the end of this span.
-        if (currentLine.end.index >= span.end) {
-          spanIndex++;
-        }
-      }
-
-      final double widthOfLastSegment = currentLine.lastSegment.width;
-      if (minIntrinsicWidth < widthOfLastSegment) {
-        minIntrinsicWidth = widthOfLastSegment;
-      }
-
+    for (final LayoutFragment fragment in fragments) {
+      runningMinIntrinsicWidth += fragment.widthExcludingTrailingSpaces;
       // Max intrinsic width includes the width of trailing spaces.
-      if (maxIntrinsicWidth < currentLine.widthIncludingSpace) {
-        maxIntrinsicWidth = currentLine.widthIncludingSpace;
-      }
+      runningMaxIntrinsicWidth += fragment.widthIncludingTrailingSpaces;
 
-      if (breakToNextLine) {
-        currentLine = currentLine.nextLine();
+      switch (fragment.type) {
+        case LineBreakType.prohibited:
+          break;
+
+        case LineBreakType.opportunity:
+          minIntrinsicWidth = math.max(minIntrinsicWidth, runningMinIntrinsicWidth);
+          runningMinIntrinsicWidth = 0;
+          break;
+
+        case LineBreakType.mandatory:
+        case LineBreakType.endOfText:
+          minIntrinsicWidth = math.max(minIntrinsicWidth, runningMinIntrinsicWidth);
+          maxIntrinsicWidth = math.max(maxIntrinsicWidth, runningMaxIntrinsicWidth);
+          runningMinIntrinsicWidth = 0;
+          runningMaxIntrinsicWidth = 0;
+          break;
       }
     }
   }
@@ -311,143 +219,130 @@
   ui.TextDirection get _paragraphDirection =>
       paragraph.paragraphStyle.effectiveTextDirection;
 
-  /// Positions the boxes in the given [line] and takes into account their
-  /// directions, the paragraph's direction, and alignment justification.
-  void _positionLineBoxes(ParagraphLine line, {
-    required bool withJustification,
-  }) {
-    final List<RangeBox> boxes = line.boxes;
-    final double justifyPerSpaceBox = withJustification
-      ? _calculateJustifyPerSpaceBox(line)
-      : 0.0;
+  /// Positions the fragments taking into account their directions and the
+  /// paragraph's direction.
+  void _positionLineFragments(ParagraphLine line) {
+    ui.TextDirection previousDirection = _paragraphDirection;
 
-    int i = 0;
-    double cumulativeWidth = 0.0;
-    while (i < boxes.length) {
-      final RangeBox box = boxes[i];
-      if (box.boxDirection == _paragraphDirection) {
-        // The box is in the same direction as the paragraph.
-        box.startOffset = cumulativeWidth;
-        box.lineWidth = line.width;
-        if (box is SpanBox && box.isSpaceOnly && !box.isTrailingSpace) {
-          box._width += justifyPerSpaceBox;
+    double startOffset = 0.0;
+    int? sandwichStart;
+    int sequenceStart = 0;
+
+    for (int i = 0; i <= line.fragments.length; i++) {
+      if (i < line.fragments.length) {
+        final LayoutFragment fragment = line.fragments[i];
+
+        if (fragment.fragmentFlow == FragmentFlow.previous) {
+          sandwichStart = null;
+          continue;
+        }
+        if (fragment.fragmentFlow == FragmentFlow.sandwich) {
+          sandwichStart ??= i;
+          continue;
         }
 
-        cumulativeWidth += box.width;
-        i++;
-        continue;
-      }
 
-      // At this point, we found a box that has the opposite direction to the
-      // paragraph. This could be a sequence of one or more boxes.
-      //
-      // These boxes should flow in the opposite direction. So we need to
-      // position them in reverse order.
-      //
-      // If the last box in the sequence is a space-only box (contains only
-      // whitespace characters), it should be excluded from the sequence.
-      //
-      // Example: an LTR paragraph with the contents:
-      //
-      // "ABC rtl1 rtl2 rtl3 XYZ"
-      //     ^    ^    ^    ^
-      //    SP1  SP2  SP3  SP4
-      //
-      //
-      // box direction:    LTR           RTL               LTR
-      //                |------>|<-----------------------|------>
-      //                +----------------------------------------+
-      //                | ABC | | rtl3 | | rtl2 | | rtl1 | | XYZ |
-      //                +----------------------------------------+
-      //                       ^        ^        ^        ^
-      //                      SP1      SP3      SP2      SP4
-      //
-      // Notice how SP2 and SP3 are flowing in the RTL direction because of the
-      // surrounding RTL words. SP4 is also preceded by an RTL word, but it marks
-      // the end of the RTL sequence, so it goes back to flowing in the paragraph
-      // direction (LTR).
+        assert(fragment.fragmentFlow == FragmentFlow.ltr ||
+            fragment.fragmentFlow == FragmentFlow.rtl);
 
-      final int first = i;
-      int lastNonSpaceBox = first;
-      i++;
-      while (i < boxes.length && boxes[i].boxDirection != _paragraphDirection) {
-        final RangeBox box = boxes[i];
-        if (box is SpanBox && box.isSpaceOnly) {
-          // Do nothing.
-        } else {
-          lastNonSpaceBox = i;
+        final ui.TextDirection currentDirection =
+            fragment.fragmentFlow == FragmentFlow.ltr
+                ? ui.TextDirection.ltr
+                : ui.TextDirection.rtl;
+
+        if (currentDirection == previousDirection) {
+          sandwichStart = null;
+          continue;
         }
-        i++;
       }
-      final int last = lastNonSpaceBox;
-      i = lastNonSpaceBox + 1;
 
-      // The range (first:last) is the entire sequence of boxes that have the
-      // opposite direction to the paragraph.
-      final double sequenceWidth = _positionLineBoxesInReverse(
-        line,
-        first,
-        last,
-        startOffset: cumulativeWidth,
-        justifyPerSpaceBox: justifyPerSpaceBox,
-      );
-      cumulativeWidth += sequenceWidth;
+      // We've reached a fragment that'll flip the text direction. Let's
+      // position the sequence that we've been traversing.
+
+      if (sandwichStart == null) {
+        // Position fragments in range [sequenceStart:i)
+        startOffset += _positionFragmentRange(
+          line: line,
+          start: sequenceStart,
+          end: i,
+          direction: previousDirection,
+          startOffset: startOffset,
+        );
+      } else {
+        // Position fragments in range [sequenceStart:sandwichStart)
+        startOffset += _positionFragmentRange(
+          line: line,
+          start: sequenceStart,
+          end: sandwichStart,
+          direction: previousDirection,
+          startOffset: startOffset,
+        );
+        // Position fragments in range [sandwichStart:i)
+        startOffset += _positionFragmentRange(
+          line: line,
+          start: sandwichStart,
+          end: i,
+          direction: _paragraphDirection,
+          startOffset: startOffset,
+        );
+      }
+
+      sequenceStart = i;
+      sandwichStart = null;
+
+      if (i < line.fragments.length){
+        previousDirection = line.fragments[i].textDirection!;
+      }
     }
   }
 
-  /// Positions a sequence of boxes in the direction opposite to the paragraph
-  /// text direction.
-  ///
-  /// This is needed when a right-to-left sequence appears in the middle of a
-  /// left-to-right paragraph, or vice versa.
-  ///
-  /// Returns the total width of all the positioned boxes in the sequence.
-  ///
-  /// [first] and [last] are expected to be inclusive.
-  double _positionLineBoxesInReverse(
-    ParagraphLine line,
-    int first,
-    int last, {
+  double _positionFragmentRange({
+    required ParagraphLine line,
+    required int start,
+    required int end,
+    required ui.TextDirection direction,
     required double startOffset,
-    required double justifyPerSpaceBox,
   }) {
-    final List<RangeBox> boxes = line.boxes;
-    double cumulativeWidth = 0.0;
-    for (int i = last; i >= first; i--) {
-      // Update the visual position of each box.
-      final RangeBox box = boxes[i];
-      assert(box.boxDirection != _paragraphDirection);
-      box.startOffset = startOffset + cumulativeWidth;
-      box.lineWidth = line.width;
-      if (box is SpanBox && box.isSpaceOnly &&  !box.isTrailingSpace) {
-        box._width += justifyPerSpaceBox;
-      }
+    assert(start <= end);
 
-      cumulativeWidth += box.width;
+    double cumulativeWidth = 0.0;
+
+    // The bodies of the two for loops below must remain identical. The only
+    // difference is the looping direction. One goes from start to end, while
+    // the other goes from end to start.
+
+    if (direction == _paragraphDirection) {
+      for (int i = start; i < end; i++) {
+        cumulativeWidth +=
+            _positionOneFragment(line, i, startOffset + cumulativeWidth, direction);
+      }
+    } else {
+      for (int i = end - 1; i >= start; i--) {
+        cumulativeWidth +=
+            _positionOneFragment(line, i, startOffset + cumulativeWidth, direction);
+      }
     }
+
     return cumulativeWidth;
   }
 
-  /// Calculates for the given [line], the amount of extra width that needs to be
-  /// added to each space box in order to align the line with the rest of the
-  /// paragraph.
-  double _calculateJustifyPerSpaceBox(ParagraphLine line) {
-    final double justifyTotal = width - line.width;
-
-    final int spaceBoxesToJustify = line.nonTrailingSpaceBoxCount;
-    if (spaceBoxesToJustify > 0) {
-      return justifyTotal / spaceBoxesToJustify;
-    }
-
-    return 0.0;
+  double _positionOneFragment(
+    ParagraphLine line,
+    int i,
+    double startOffset,
+    ui.TextDirection direction,
+  ) {
+    final LayoutFragment fragment = line.fragments[i];
+    fragment.setPosition(startOffset: startOffset, textDirection: direction);
+    return fragment.widthIncludingTrailingSpaces;
   }
 
   List<ui.TextBox> getBoxesForPlaceholders() {
     final List<ui.TextBox> boxes = <ui.TextBox>[];
     for (final ParagraphLine line in lines) {
-      for (final RangeBox box in line.boxes) {
-        if (box is PlaceholderBox) {
-          boxes.add(box.toTextBox(line, forPainting: false));
+      for (final LayoutFragment fragment in line.fragments) {
+        if (fragment.isPlaceholder) {
+          boxes.add(fragment.toTextBox());
         }
       }
     }
@@ -475,9 +370,9 @@
 
     for (final ParagraphLine line in lines) {
       if (line.overlapsWith(start, end)) {
-        for (final RangeBox box in line.boxes) {
-          if (box is SpanBox && box.overlapsWith(start, end)) {
-            boxes.add(box.intersect(line, start, end, forPainting: false));
+        for (final LayoutFragment fragment in line.fragments) {
+          if (!fragment.isPlaceholder && fragment.overlapsWith(start, end)) {
+            boxes.add(fragment.toTextBox(start: start, end: end));
           }
         }
       }
@@ -501,15 +396,15 @@
     // [offset] is to the right of the line.
     if (offset.dx >= line.left + line.widthWithTrailingSpaces) {
       return ui.TextPosition(
-        offset: line.endIndexWithoutNewlines,
+        offset: line.endIndex - line.trailingNewlines,
         affinity: ui.TextAffinity.upstream,
       );
     }
 
     final double dx = offset.dx - line.left;
-    for (final RangeBox box in line.boxes) {
-      if (box.left <= dx && dx <= box.right) {
-        return box.getPositionForX(dx);
+    for (final LayoutFragment fragment in line.fragments) {
+      if (fragment.left <= dx && dx <= fragment.right) {
+        return fragment.getPositionForX(dx - fragment.left);
       }
     }
     // Is this ever reachable?
@@ -530,496 +425,32 @@
   }
 }
 
-/// Represents a box inside a paragraph span with the range of [start] to [end].
-///
-/// The box's coordinates are all relative to the line it belongs to. For
-/// example, [left] is the distance from the left edge of the line to the left
-/// edge of the box.
-///
-/// This is what the various measurements/coordinates look like for a box in an
-/// LTR paragraph:
-///
-///          *------------------------lineWidth------------------*
-///                            *--width--*
-///          ┌─────────────────┬─────────┬───────────────────────┐
-///          │                 │---BOX---│                       │
-///          └─────────────────┴─────────┴───────────────────────┘
-///          *---startOffset---*
-///          *------left-------*
-///          *--------endOffset----------*
-///          *----------right------------*
-///
-///
-/// And in an RTL paragraph, [startOffset] and [endOffset] are flipped because
-/// the line starts from the right. Here's what they look like:
-///
-///          *------------------------lineWidth------------------*
-///                            *--width--*
-///          ┌─────────────────┬─────────┬───────────────────────┐
-///          │                 │---BOX---│                       │
-///          └─────────────────┴─────────┴───────────────────────┘
-///                                      *------startOffset------*
-///          *------left-------*
-///                            *-----------endOffset-------------*
-///          *----------right------------*
-///
-abstract class RangeBox {
-  RangeBox(
-    this.start,
-    this.end,
-    this.paragraphDirection,
-    this.boxDirection,
-  );
-
-  final LineBreakResult start;
-  final LineBreakResult end;
-
-  /// The distance from the beginning of the line to the beginning of the box.
-  late final double startOffset;
-
-  /// The distance from the beginning of the line to the end of the box.
-  double get endOffset => startOffset + width;
-
-  /// The distance from the left edge of the line to the left edge of the box.
-  double get left => paragraphDirection == ui.TextDirection.ltr
-      ? startOffset
-      : lineWidth - endOffset;
-
-  /// The distance from the left edge of the line to the right edge of the box.
-  double get right => paragraphDirection == ui.TextDirection.ltr
-      ? endOffset
-      : lineWidth - startOffset;
-
-  /// The distance from the left edge of the box to the right edge of the box.
-  double get width;
-
-  /// The width of the line that this box belongs to.
-  late final double lineWidth;
-
-  /// The text direction of the paragraph that this box belongs to.
-  final ui.TextDirection paragraphDirection;
-
-  /// Indicates how this box flows among other boxes.
-  ///
-  /// Example: In an LTR paragraph, the text "ABC hebrew_word 123 DEF" is shown
-  /// visually in the following order:
-  ///
-  ///                +-------------------------------+
-  ///                | ABC | 123 | drow_werbeh | DEF |
-  ///                +-------------------------------+
-  /// box direction:   LTR   RTL       RTL       LTR
-  ///                 ----> <---- <------------  ---->
-  ///
-  /// (In the above example, we are ignoring whitespace to simplify).
-  final ui.TextDirection boxDirection;
-
-  /// Returns a [ui.TextBox] representing this range box in the given [line].
-  ///
-  /// The coordinates of the resulting [ui.TextBox] are relative to the
-  /// paragraph, not to the line.
-  ///
-  /// The [forPainting] parameter specifies whether the text box is wanted for
-  /// painting purposes or not. The difference is observed in the handling of
-  /// trailing spaces. Trailing spaces aren't painted on the screen, but their
-  /// dimensions are still useful for other cases like highlighting selection.
-  ui.TextBox toTextBox(ParagraphLine line, {required bool forPainting});
-
-  /// Returns the text position within this box's range that's closest to the
-  /// given [x] offset.
-  ///
-  /// The [x] offset is expected to be relative to the left edge of the line,
-  /// just like the coordinates of this box.
-  ui.TextPosition getPositionForX(double x);
-}
-
-/// Represents a box for a [PlaceholderSpan].
-class PlaceholderBox extends RangeBox {
-  PlaceholderBox(
-    this.placeholder, {
-    required LineBreakResult index,
-    required ui.TextDirection paragraphDirection,
-    required ui.TextDirection boxDirection,
-  }) : super(index, index, paragraphDirection, boxDirection);
-
-  final PlaceholderSpan placeholder;
-
-  @override
-  double get width => placeholder.width;
-
-  @override
-  ui.TextBox toTextBox(ParagraphLine line, {required bool forPainting}) {
-    final double left = line.left + this.left;
-    final double right = line.left + this.right;
-
-    final double lineTop = line.baseline - line.ascent;
-
-    final double top;
-    switch (placeholder.alignment) {
-      case ui.PlaceholderAlignment.top:
-        top = lineTop;
-        break;
-
-      case ui.PlaceholderAlignment.middle:
-        top = lineTop + (line.height - placeholder.height) / 2;
-        break;
-
-      case ui.PlaceholderAlignment.bottom:
-        top = lineTop + line.height - placeholder.height;
-        break;
-
-      case ui.PlaceholderAlignment.aboveBaseline:
-        top = line.baseline - placeholder.height;
-        break;
-
-      case ui.PlaceholderAlignment.belowBaseline:
-        top = line.baseline;
-        break;
-
-      case ui.PlaceholderAlignment.baseline:
-        top = line.baseline - placeholder.baselineOffset;
-        break;
-    }
-
-    return ui.TextBox.fromLTRBD(
-      left,
-      top,
-      right,
-      top + placeholder.height,
-      paragraphDirection,
-    );
-  }
-
-  @override
-  ui.TextPosition getPositionForX(double x) {
-    // See if `x` is closer to the left edge or the right edge of the box.
-    final bool closerToLeft = x - left < right - x;
-    return ui.TextPosition(
-      offset: start.index,
-      affinity: closerToLeft ? ui.TextAffinity.upstream : ui.TextAffinity.downstream,
-    );
-  }
-}
-
-/// Represents a box in a [FlatTextSpan].
-class SpanBox extends RangeBox {
-  SpanBox(
-    this.spanometer, {
-    required LineBreakResult start,
-    required LineBreakResult end,
-    required double width,
-    required ui.TextDirection paragraphDirection,
-    required ui.TextDirection boxDirection,
-    required this.contentDirection,
-    required this.isSpaceOnly,
-  })  : span = spanometer.currentSpan,
-        height = spanometer.height,
-        baseline = spanometer.ascent,
-        _width = width,
-        super(start, end, paragraphDirection, boxDirection);
-
-
-  final Spanometer spanometer;
-  final FlatTextSpan span;
-
-  /// The direction of the text inside this box.
-  ///
-  /// To illustrate the difference between [boxDirection] and [contentDirection]
-  /// here's an example:
-  ///
-  /// In an LTR paragraph, the text "ABC hebrew_word 123 DEF" is rendered as
-  /// follows:
-  ///
-  ///                     ----> <---- <------------  ---->
-  ///     box direction:   LTR   RTL       RTL       LTR
-  ///                    +-------------------------------+
-  ///                    | ABC | 123 | drow_werbeh | DEF |
-  ///                    +-------------------------------+
-  /// content direction:   LTR   LTR       RTL       LTR
-  ///                     ----> ----> <------------  ---->
-  ///
-  /// Notice the box containing "123" flows in the RTL direction (because it
-  /// comes after an RTL box), while the content of the box flows in the LTR
-  /// direction (i.e. the text is shown as "123" not "321").
-  final ui.TextDirection contentDirection;
-
-  /// Whether this box is made of only white space.
-  final bool isSpaceOnly;
-
-  /// Whether this box is a trailing space box at the end of a line.
-  bool get isTrailingSpace => _isTrailingSpace;
-  bool _isTrailingSpace = false;
-
-  /// This is made mutable so it can be updated later in the layout process for
-  /// the purpose of aligning the lines of a paragraph with [ui.TextAlign.justify].
-  double _width;
-
-  @override
-  double get width => _width;
-
-  /// Whether the contents of this box flow in the left-to-right direction.
-  bool get isContentLtr => contentDirection == ui.TextDirection.ltr;
-
-  /// Whether the contents of this box flow in the right-to-left direction.
-  bool get isContentRtl => !isContentLtr;
-
-  /// The distance from the top edge to the bottom edge of the box.
-  final double height;
-
-  /// The distance from the top edge of the box to the alphabetic baseline of
-  /// the box.
-  final double baseline;
-
-  /// Whether this box's range overlaps with the range from [startIndex] to
-  /// [endIndex].
-  bool overlapsWith(int startIndex, int endIndex) {
-    return startIndex < end.index && start.index < endIndex;
-  }
-
-  /// Returns the substring of the paragraph that's represented by this box.
-  ///
-  /// Trailing newlines are omitted, if any.
-  String toText() {
-    return spanometer.paragraph.toPlainText().substring(start.index, end.indexWithoutTrailingNewlines);
-  }
-
-  @override
-  ui.TextBox toTextBox(ParagraphLine line, {required bool forPainting}) {
-    return intersect(line, start.index, end.index, forPainting: forPainting);
-  }
-
-  /// Performs the intersection of this box with the range given by [start] and
-  /// [end] indices, and returns a [ui.TextBox] representing that intersection.
-  ///
-  /// The coordinates of the resulting [ui.TextBox] are relative to the
-  /// paragraph, not to the line.
-  ui.TextBox intersect(ParagraphLine line, int start, int end, {required bool forPainting}) {
-    final double top = line.baseline - baseline;
-
-    final double before;
-    if (start <= this.start.index) {
-      before = 0.0;
-    } else {
-      spanometer.currentSpan = span;
-      before = spanometer._measure(this.start.index, start);
-    }
-
-    final double after;
-    if (end >= this.end.indexWithoutTrailingNewlines) {
-      after = 0.0;
-    } else {
-      spanometer.currentSpan = span;
-      after = spanometer._measure(end, this.end.indexWithoutTrailingNewlines);
-    }
-
-    double left, right;
-    if (isContentLtr) {
-      // Example: let's say the text is "Loremipsum" and we want to get the box
-      // for "rem". In this case, `before` is the width of "Lo", and `after`
-      // is the width of "ipsum".
-      //
-      // Here's how the measurements/coordinates look like:
-      //
-      //              before         after
-      //              |----|     |----------|
-      //              +---------------------+
-      //              | L o r e m i p s u m |
-      //              +---------------------+
-      //    this.left ^                     ^ this.right
-      left = this.left + before;
-      right = this.right - after;
-    } else {
-      // Example: let's say the text is "txet_werbeH" ("Hebrew_text" flowing from
-      // right to left). Say we want to get the box for "brew". The `before` is
-      // the width of "He", and `after` is the width of "_text".
-      //
-      //                 after           before
-      //              |----------|       |----|
-      //              +-----------------------+
-      //              | t x e t _ w e r b e H |
-      //              +-----------------------+
-      //    this.left ^                       ^ this.right
-      //
-      // Notice how `before` and `after` are reversed in the RTL example. That's
-      // because the text flows from right to left.
-      left = this.left + after;
-      right = this.right - before;
-    }
-
-    // When painting a paragraph, trailing spaces should have a zero width.
-    final bool isZeroWidth = forPainting && isTrailingSpace;
-    if (isZeroWidth) {
-      // Collapse the box to the left or to the right depending on the paragraph
-      // direction.
-      if (paragraphDirection == ui.TextDirection.ltr) {
-        right = left;
-      } else {
-        left = right;
-      }
-    }
-
-    // The [RangeBox]'s left and right edges are relative to the line. In order
-    // to make them relative to the paragraph, we need to add the left edge of
-    // the line.
-    return ui.TextBox.fromLTRBD(
-      line.left + left,
-      top,
-      line.left + right,
-      top + height,
-      contentDirection,
-    );
-  }
-
-  /// Transforms the [x] coordinate to be relative to this box and matches the
-  /// flow of content.
-  ///
-  /// In LTR paragraphs, the [startOffset] and [endOffset] of an RTL box
-  /// indicate the visual beginning and end of the box. But the text inside the
-  /// box flows in the opposite direction (from [endOffset] to [startOffset]).
-  ///
-  /// The X (input) is relative to the line, and always from left-to-right
-  /// independent of paragraph and content direction.
-  ///
-  /// Here's how it looks for a box with LTR content:
-  ///
-  ///          *------------------------lineWidth------------------*
-  ///          *---------------X (input)
-  ///          ┌───────────┬────────────────────────┬───────────────┐
-  ///          │           │ --content-direction--> │               │
-  ///          └───────────┴────────────────────────┴───────────────┘
-  ///                      *---X' (output)
-  ///          *---left----*
-  ///          *---------------right----------------*
-  ///
-  ///
-  /// And here's how it looks for a box with RTL content:
-  ///
-  ///          *------------------------lineWidth------------------*
-  ///          *----------------X (input)
-  ///          ┌───────────┬────────────────────────┬───────────────┐
-  ///          │           │ <--content-direction-- │               │
-  ///          └───────────┴────────────────────────┴───────────────┘
-  ///                  (output) X'------------------*
-  ///          *---left----*
-  ///          *---------------right----------------*
-  ///
-  double _makeXRelativeToContent(double x) {
-    return isContentRtl ? right - x : x - left;
-  }
-
-  @override
-  ui.TextPosition getPositionForX(double x) {
-    spanometer.currentSpan = span;
-
-    x = _makeXRelativeToContent(x);
-
-    final int startIndex = start.index;
-    final int endIndex = end.indexWithoutTrailingNewlines;
-    // The resulting `cutoff` is the index of the character where the `x` offset
-    // falls. We should return the text position of either `cutoff` or
-    // `cutoff + 1` depending on which one `x` is closer to.
-    //
-    //   offset x
-    //      ↓
-    // "A B C D E F"
-    //     ↑
-    //   cutoff
-    final int cutoff = spanometer.forceBreak(
-      startIndex,
-      endIndex,
-      availableWidth: x,
-      allowEmpty: true,
-    );
-
-    if (cutoff == endIndex) {
-      return ui.TextPosition(
-        offset: cutoff,
-        affinity: ui.TextAffinity.upstream,
-      );
-    }
-
-    final double lowWidth = spanometer._measure(startIndex, cutoff);
-    final double highWidth = spanometer._measure(startIndex, cutoff + 1);
-
-    // See if `x` is closer to `cutoff` or `cutoff + 1`.
-    if (x - lowWidth < highWidth - x) {
-      // The offset is closer to cutoff.
-      return ui.TextPosition(
-        offset: cutoff,
-      );
-    } else {
-      // The offset is closer to cutoff + 1.
-      return ui.TextPosition(
-        offset: cutoff + 1,
-        affinity: ui.TextAffinity.upstream,
-      );
-    }
-  }
-}
-
-/// Represents a segment in a line of a paragraph.
-///
-/// For example, this line: "Lorem ipsum dolor sit" is broken up into the
-/// following segments:
-///
-/// - "Lorem "
-/// - "ipsum "
-/// - "dolor "
-/// - "sit"
-class LineSegment {
-  LineSegment({
-    required this.span,
-    required this.start,
-    required this.end,
-    required this.width,
-    required this.widthIncludingSpace,
-  });
-
-  /// The span that this segment belongs to.
-  final ParagraphSpan span;
-
-  /// The index of the beginning of the segment in the paragraph.
-  final LineBreakResult start;
-
-  /// The index of the end of the segment in the paragraph.
-  final LineBreakResult end;
-
-  /// The width of the segment excluding any trailing white space.
-  final double width;
-
-  /// The width of the segment including any trailing white space.
-  final double widthIncludingSpace;
-
-  /// The width of the trailing white space in the segment.
-  double get widthOfTrailingSpace => widthIncludingSpace - width;
-
-  /// Whether this segment is made of only white space.
-  ///
-  /// We rely on the [width] to determine this because relying on incides
-  /// doesn't work well for placeholders (they are zero-length strings).
-  bool get isSpaceOnly => width == 0;
-}
-
 /// Builds instances of [ParagraphLine] for the given [paragraph].
 ///
 /// Usage of this class starts by calling [LineBuilder.first] to start building
 /// the first line of the paragraph.
 ///
-/// Then new line breaks can be found by calling [LineBuilder.findNextBreak].
+/// Then fragments can be added by calling [addFragment].
 ///
-/// The line can be extended one or more times before it's built by calling
-/// [LineBuilder.build] which generates the [ParagraphLine] instance.
+/// After adding a fragment, one can use [isOverflowing] to determine whether
+/// the added fragment caused the line to overflow or not.
 ///
-/// To start building the next line, simply call [LineBuilder.nextLine] which
-/// creates a new [LineBuilder] that can be extended and built and so on.
+/// Once the line is complete, it can be built by calling [build] to generate
+/// a [ParagraphLine] instance.
+///
+/// To start building the next line, simply call [nextLine] to get a new
+/// [LineBuilder] for the next line.
 class LineBuilder {
   LineBuilder._(
     this.paragraph,
     this.spanometer, {
     required this.maxWidth,
-    required this.start,
     required this.lineNumber,
     required this.accumulatedHeight,
-  }) : _end = start;
+    required List<LayoutFragment> fragments,
+  }) : _fragments = fragments {
+    _recalculateMetrics();
+  }
 
   /// Creates a [LineBuilder] for the first line in a paragraph.
   factory LineBuilder.first(
@@ -1032,41 +463,47 @@
       spanometer,
       maxWidth: maxWidth,
       lineNumber: 0,
-      start: const LineBreakResult.sameIndex(0, LineBreakType.prohibited),
       accumulatedHeight: 0.0,
+      fragments: <LayoutFragment>[],
     );
   }
 
-  final List<LineSegment> _segments = <LineSegment>[];
-  final List<RangeBox> _boxes = <RangeBox>[];
+  final List<LayoutFragment> _fragments;
+  List<LayoutFragment>? _fragmentsForNextLine;
+
+  int get startIndex {
+    assert(_fragments.isNotEmpty || _fragmentsForNextLine!.isNotEmpty);
+
+    return isNotEmpty
+      ? _fragments.first.start
+      : _fragmentsForNextLine!.first.start;
+  }
+
+  int get endIndex {
+    assert(_fragments.isNotEmpty || _fragmentsForNextLine!.isNotEmpty);
+
+    return isNotEmpty
+      ? _fragments.last.end
+      : _fragmentsForNextLine!.first.start;
+  }
 
   final double maxWidth;
   final CanvasParagraph paragraph;
   final Spanometer spanometer;
-  final LineBreakResult start;
   final int lineNumber;
 
   /// The accumulated height of all preceding lines, excluding the current line.
   final double accumulatedHeight;
 
-  /// The index of the end of the line so far.
-  LineBreakResult get end => _end;
-  LineBreakResult _end;
-  set end(LineBreakResult value) {
-    if (value.type != LineBreakType.prohibited) {
-      isBreakable = true;
-    }
-    _end = value;
-  }
-
   /// The width of the line so far, excluding trailing white space.
   double width = 0.0;
 
   /// The width of the line so far, including trailing white space.
   double widthIncludingSpace = 0.0;
 
-  /// The width of trailing white space in the line.
-  double get widthOfTrailingSpace => widthIncludingSpace - width;
+  double get _widthExcludingLastFragment => _fragments.length > 1
+    ? widthIncludingSpace - _fragments.last.widthIncludingTrailingSpaces
+    : 0;
 
   /// The distance from the top of the line to the alphabetic baseline.
   double ascent = 0.0;
@@ -1077,22 +514,31 @@
   /// The height of the line so far.
   double get height => ascent + descent;
 
-  /// The last segment in this line.
-  LineSegment get lastSegment => _segments.last;
+  int _lastBreakableFragment = -1;
+  int _breakCount = 0;
 
-  /// Returns true if there is at least one break opportunity in the line.
-  bool isBreakable = false;
+  /// Whether this line can be legally broken into more than one line.
+  bool get isBreakable {
+    if (_fragments.isEmpty) {
+      return false;
+    }
+    if (_fragments.last.isBreak) {
+      // We need one more break other than the last one.
+      return _breakCount > 1;
+    }
+    return _breakCount > 0;
+  }
 
-  /// Returns true if there's no break opportunity in the line.
+  /// Returns true if the line can't be legally broken any further.
   bool get isNotBreakable => !isBreakable;
 
-  /// Whether the end of this line is a prohibited break.
-  bool get isEndProhibited => end.type == LineBreakType.prohibited;
+  int _spaceCount = 0;
+  int _trailingSpaces = 0;
 
-  int _spaceBoxCount = 0;
+  bool get isEmpty => _fragments.isEmpty;
+  bool get isNotEmpty => _fragments.isNotEmpty;
 
-  bool get isEmpty => _segments.isEmpty;
-  bool get isNotEmpty => _segments.isNotEmpty;
+  bool get isHardBreak => _fragments.isNotEmpty && _fragments.last.isHardBreak;
 
   /// The horizontal offset necessary for the line to be correctly aligned.
   double get alignOffset {
@@ -1113,81 +559,72 @@
     }
   }
 
-  /// Measures the width of text between the end of this line and [newEnd].
-  double getAdditionalWidthTo(LineBreakResult newEnd) {
-    // If the extension is all made of space characters, it shouldn't add
-    // anything to the width.
-    if (end.index == newEnd.indexWithoutTrailingSpaces) {
-      return 0.0;
-    }
+  bool get isOverflowing => width > maxWidth;
 
-    return widthOfTrailingSpace + spanometer.measure(end, newEnd);
-  }
-
-  bool get _isLastBoxAPlaceholder {
-    if (_boxes.isEmpty) {
+  bool get canHaveEllipsis {
+    if (paragraph.paragraphStyle.ellipsis == null) {
       return false;
     }
-    return _boxes.last is PlaceholderBox;
+
+    final int? maxLines = paragraph.paragraphStyle.maxLines;
+    return (maxLines == null) || (maxLines == lineNumber + 1);
+  }
+
+  bool get _canAppendEmptyFragments {
+    if (isHardBreak) {
+      // Can't append more fragments to this line if it has a hard break.
+      return false;
+    }
+
+    if (_fragmentsForNextLine?.isNotEmpty ?? false) {
+      // If we already have fragments prepared for the next line, then we can't
+      // append more fragments to this line.
+      return false;
+    }
+
+    return true;
   }
 
   ui.TextDirection get _paragraphDirection =>
       paragraph.paragraphStyle.effectiveTextDirection;
 
-  late ui.TextDirection _currentBoxDirection = _paragraphDirection;
+  void addFragment(LayoutFragment fragment) {
+    _updateMetrics(fragment);
 
-  late ui.TextDirection _currentContentDirection = _paragraphDirection;
+    if (fragment.isBreak) {
+      _lastBreakableFragment = _fragments.length;
+    }
 
-  bool _shouldCreateBoxBeforeExtendingTo(DirectionalPosition newEnd) {
-    // When the direction changes, we need to make sure to put them in separate
-    // boxes.
-    return newEnd.isSpaceOnly || _currentBoxDirection != newEnd.textDirection || _currentContentDirection != newEnd.textDirection;
+    _fragments.add(fragment);
   }
 
-  /// Extends the line by setting a [newEnd].
-  void extendTo(DirectionalPosition newEnd) {
-    ascent = math.max(ascent, spanometer.ascent);
-    descent = math.max(descent, spanometer.descent);
+  /// Updates the [LineBuilder]'s metrics to take into account the new [fragment].
+  void _updateMetrics(LayoutFragment fragment) {
+    _spaceCount += fragment.trailingSpaces;
 
-    // When the direction changes, we need to make sure to put them in separate
-    // boxes.
-    if (_shouldCreateBoxBeforeExtendingTo(newEnd)) {
-      createBox();
-    }
-    _currentBoxDirection = newEnd.textDirection ?? _currentBoxDirection;
-    _currentContentDirection = newEnd.textDirection ?? ui.TextDirection.ltr;
-
-    _addSegment(_createSegment(newEnd.lineBreak));
-    if (newEnd.isSpaceOnly) {
-      // Whitespace sequences go in their own boxes.
-      createBox(isSpaceOnly: true);
-    }
-  }
-
-  /// Extends the line to the end of the paragraph.
-  void extendToEndOfText() {
-    if (end.type == LineBreakType.endOfText) {
-      return;
-    }
-
-    final LineBreakResult endOfText = LineBreakResult.sameIndex(
-      paragraph.toPlainText().length,
-      LineBreakType.endOfText,
-    );
-
-    // The spanometer may not be ready in some cases. E.g. when the paragraph
-    // is made up of only placeholders and no text.
-    if (spanometer.isReady) {
-      ascent = math.max(ascent, spanometer.ascent);
-      descent = math.max(descent, spanometer.descent);
-      _addSegment(_createSegment(endOfText));
+    if (fragment.isSpaceOnly) {
+      _trailingSpaces += fragment.trailingSpaces;
     } else {
-      end = endOfText;
+      _trailingSpaces = fragment.trailingSpaces;
+      width = widthIncludingSpace + fragment.widthExcludingTrailingSpaces;
     }
+    widthIncludingSpace += fragment.widthIncludingTrailingSpaces;
+
+    if (fragment.isPlaceholder) {
+      _adjustPlaceholderAscentDescent(fragment);
+    }
+
+    if (fragment.isBreak) {
+      _breakCount++;
+    }
+
+    ascent = math.max(ascent, fragment.ascent);
+    descent = math.max(descent, fragment.descent);
   }
 
-  void addPlaceholder(PlaceholderSpan placeholder) {
-    // Increase the line's height to fit the placeholder, if necessary.
+  void _adjustPlaceholderAscentDescent(LayoutFragment fragment) {
+    final PlaceholderSpan placeholder = fragment.span as PlaceholderSpan;
+
     final double ascent, descent;
     switch (placeholder.alignment) {
       case ui.PlaceholderAlignment.top:
@@ -1229,330 +666,198 @@
         break;
     }
 
-    this.ascent = math.max(this.ascent, ascent);
-    this.descent = math.max(this.descent, descent);
-
-    _addSegment(LineSegment(
-      span: placeholder,
-      start: end,
-      end: end,
-      width: placeholder.width,
-      widthIncludingSpace: placeholder.width,
-    ));
-
-    // Add the placeholder box.
-    _boxes.add(PlaceholderBox(
-      placeholder,
-      index: _currentBoxStart,
-      paragraphDirection: _paragraphDirection,
-      boxDirection: _currentBoxDirection,
-    ));
-    _currentBoxStartOffset = widthIncludingSpace;
-    // Breaking is always allowed after a placeholder.
-    isBreakable = true;
-  }
-
-  /// Creates a new segment to be appended to the end of this line.
-  LineSegment _createSegment(LineBreakResult segmentEnd) {
-    // The segment starts at the end of the line.
-    final LineBreakResult segmentStart = end;
-    return LineSegment(
-      span: spanometer.currentSpan,
-      start: segmentStart,
-      end: segmentEnd,
-      width: spanometer.measure(segmentStart, segmentEnd),
-      widthIncludingSpace:
-          spanometer.measureIncludingSpace(segmentStart, segmentEnd),
+    // Update the metrics of the fragment to reflect the calculated ascent and
+    // descent.
+    fragment.setMetrics(spanometer,
+      ascent: ascent,
+      descent: descent,
+      widthExcludingTrailingSpaces: fragment.widthExcludingTrailingSpaces,
+      widthIncludingTrailingSpaces: fragment.widthIncludingTrailingSpaces,
     );
   }
 
-  /// Adds a segment to this line.
-  ///
-  /// It adjusts the width properties to accommodate the new segment. It also
-  /// sets the line end to the end of the segment.
-  void _addSegment(LineSegment segment) {
-    _segments.add(segment);
+  void _recalculateMetrics() {
+    width = 0;
+    widthIncludingSpace = 0;
+    ascent = 0;
+    descent = 0;
+    _spaceCount = 0;
+    _trailingSpaces = 0;
+    _breakCount = 0;
+    _lastBreakableFragment = -1;
 
-    // Adding a space-only segment has no effect on `width` because it doesn't
-    // include trailing white space.
-    if (!segment.isSpaceOnly) {
-      // Add the width of previous trailing space.
-      width += widthOfTrailingSpace + segment.width;
-    }
-    widthIncludingSpace += segment.widthIncludingSpace;
-    end = segment.end;
-  }
-
-  /// Removes the latest [LineSegment] added by [_addSegment].
-  ///
-  /// It re-adjusts the width properties and the end of the line.
-  LineSegment _popSegment() {
-    final LineSegment poppedSegment = _segments.removeLast();
-
-    if (_segments.isEmpty) {
-      width = 0.0;
-      widthIncludingSpace = 0.0;
-      end = start;
-    } else {
-      widthIncludingSpace -= poppedSegment.widthIncludingSpace;
-      end = lastSegment.end;
-
-      // Now, let's figure out what to do with `width`.
-
-      // Popping a space-only segment has no effect on `width`.
-      if (!poppedSegment.isSpaceOnly) {
-        // First, we subtract the width of the popped segment.
-        width -= poppedSegment.width;
-
-        // Second, we subtract all trailing spaces from `width`. There could be
-        // multiple trailing segments that are space-only.
-        double widthOfTrailingSpace = 0.0;
-        int i = _segments.length - 1;
-        while (i >= 0 && _segments[i].isSpaceOnly) {
-          // Since the segment is space-only, `widthIncludingSpace` contains
-          // the width of the space and nothing else.
-          widthOfTrailingSpace += _segments[i].widthIncludingSpace;
-          i--;
-        }
-        if (i >= 0) {
-          // Having `i >= 0` means in the above loop we stopped at a
-          // non-space-only segment. We should also subtract its trailing spaces.
-          widthOfTrailingSpace += _segments[i].widthOfTrailingSpace;
-        }
-        width -= widthOfTrailingSpace;
+    for (int i = 0; i < _fragments.length; i++) {
+      _updateMetrics(_fragments[i]);
+      if (_fragments[i].isBreak) {
+        _lastBreakableFragment = i;
       }
     }
-
-    // Now let's fixes boxes if they need fixing.
-    //
-    // If we popped a segment of an already created box, we should pop the box
-    // too.
-    if (_currentBoxStart.index > poppedSegment.start.index) {
-      final RangeBox poppedBox = _boxes.removeLast();
-      _currentBoxStartOffset -= poppedBox.width;
-      if (poppedBox is SpanBox && poppedBox.isSpaceOnly) {
-        _spaceBoxCount--;
-      }
-    }
-
-    return poppedSegment;
   }
 
-  /// Force-breaks the line in order to fit in [maxWidth] while trying to extend
-  /// to [nextBreak].
-  ///
-  /// This should only be called when there isn't enough width to extend to
-  /// [nextBreak], and either of the following is true:
-  ///
-  /// 1. An ellipsis is being appended to this line, OR
-  /// 2. The line doesn't have any line break opportunities and has to be
-  ///    force-broken.
-  void forceBreak(
-    DirectionalPosition nextBreak, {
-    required bool allowEmpty,
-    String? ellipsis,
-  }) {
-    if (ellipsis == null) {
-      final double availableWidth = maxWidth - widthIncludingSpace;
-      final int breakingPoint = spanometer.forceBreak(
-        end.index,
-        nextBreak.lineBreak.indexWithoutTrailingSpaces,
-        availableWidth: availableWidth,
-        allowEmpty: allowEmpty,
-      );
+  void forceBreakLastFragment({ double? availableWidth, bool allowEmptyLine = false }) {
+    assert(isNotEmpty);
 
-      // This condition can be true in the following case:
-      // 1. Next break is only one character away, with zero or many spaces. AND
-      // 2. There isn't enough width to fit the single character. AND
-      // 3. `allowEmpty` is false.
-      if (breakingPoint == nextBreak.lineBreak.indexWithoutTrailingSpaces) {
-        // In this case, we just extend to `nextBreak` instead of creating a new
-        // artificial break. It's safe (and better) to do so, because we don't
-        // want the trailing white space to go to the next line.
-        extendTo(nextBreak);
-      } else {
-        extendTo(nextBreak.copyWithIndex(breakingPoint));
+    availableWidth ??= maxWidth;
+    assert(widthIncludingSpace > availableWidth);
+
+    _fragmentsForNextLine ??= <LayoutFragment>[];
+
+    // When the line has fragments other than the last one, we can always allow
+    // the last fragment to be empty (i.e. completely removed from the line).
+    final bool hasOtherFragments = _fragments.length > 1;
+    final bool allowLastFragmentToBeEmpty = hasOtherFragments || allowEmptyLine;
+
+    final LayoutFragment lastFragment = _fragments.last;
+
+    if (lastFragment.isPlaceholder) {
+      // Placeholder can't be force-broken. Either keep all of it in the line or
+      // move it to the next line.
+      if (allowLastFragmentToBeEmpty) {
+        _fragmentsForNextLine!.insert(0, _fragments.removeLast());
+        _recalculateMetrics();
       }
       return;
     }
 
-    // For example: "foo bar baz". Let's say all characters have the same width, and
-    // the constraint width can only fit 9 characters "foo bar b". So if the
-    // paragraph has an ellipsis, we can't just remove the last segment "baz"
-    // and replace it with "..." because that would overflow.
-    //
-    // We need to keep popping segments until we are able to fit the "..."
-    // without overflowing. In this example, that would be: "foo ba..."
+    spanometer.currentSpan = lastFragment.span;
+    final double lineWidthWithoutLastFragment = widthIncludingSpace - lastFragment.widthIncludingTrailingSpaces;
+    final double availableWidthForFragment = availableWidth - lineWidthWithoutLastFragment;
+    final int forceBreakEnd = lastFragment.end - lastFragment.trailingNewlines;
 
-    final double ellipsisWidth = spanometer.measureText(ellipsis);
-    final double availableWidth = maxWidth - ellipsisWidth;
-
-    // First, we create the new segment until `nextBreak`.
-    LineSegment segmentToBreak = _createSegment(nextBreak.lineBreak);
-
-    // Then, we keep popping until we find the segment that has to be broken.
-    // After the loop ends, two things are correct:
-    // 1. All remaining segments in `_segments` can fit within constraints.
-    // 2. Adding `segmentToBreak` causes the line to overflow.
-    while (_segments.isNotEmpty && widthIncludingSpace > availableWidth) {
-      segmentToBreak = _popSegment();
-    }
-
-    spanometer.currentSpan = segmentToBreak.span as FlatTextSpan;
-    final double availableWidthForSegment =
-        availableWidth - widthIncludingSpace;
     final int breakingPoint = spanometer.forceBreak(
-      segmentToBreak.start.index,
-      segmentToBreak.end.index,
-      availableWidth: availableWidthForSegment,
-      allowEmpty: allowEmpty,
+      lastFragment.start,
+      forceBreakEnd,
+      availableWidth: availableWidthForFragment,
+      allowEmpty: allowLastFragmentToBeEmpty,
     );
 
-    // There's a possibility that the end of line has moved backwards, so we
-    // need to remove some boxes in that case.
-    while (_boxes.isNotEmpty && _boxes.last.end.index > breakingPoint) {
-      _boxes.removeLast();
+    if (breakingPoint == forceBreakEnd) {
+      // The entire fragment remained intact. Let's keep everything as is.
+      return;
     }
-    _currentBoxStartOffset = widthIncludingSpace;
 
-    extendTo(nextBreak.copyWithIndex(breakingPoint));
+    _fragments.removeLast();
+    _recalculateMetrics();
+
+    final List<LayoutFragment?> split = lastFragment.split(breakingPoint);
+
+    final LayoutFragment? first = split.first;
+    if (first != null) {
+      spanometer.measureFragment(first);
+      addFragment(first);
+    }
+
+    final LayoutFragment? second = split.last;
+    if (second != null) {
+      spanometer.measureFragment(second);
+      _fragmentsForNextLine!.insert(0, second);
+    }
   }
 
-  /// Looks for the last break opportunity in the line and reverts the line to
-  /// that point.
-  ///
-  /// If the line already ends with a break opportunity, this method does
-  /// nothing.
+  void insertEllipsis() {
+    assert(canHaveEllipsis);
+    assert(isOverflowing);
+
+    final String ellipsisText = paragraph.paragraphStyle.ellipsis!;
+
+    _fragmentsForNextLine = <LayoutFragment>[];
+
+    spanometer.currentSpan = _fragments.last.span;
+    double ellipsisWidth = spanometer.measureText(ellipsisText);
+    double availableWidth = math.max(0, maxWidth - ellipsisWidth);
+
+    while (_widthExcludingLastFragment > availableWidth) {
+      _fragmentsForNextLine!.insert(0, _fragments.removeLast());
+      _recalculateMetrics();
+
+      spanometer.currentSpan = _fragments.last.span;
+      ellipsisWidth = spanometer.measureText(ellipsisText);
+      availableWidth = maxWidth - ellipsisWidth;
+    }
+
+    final LayoutFragment lastFragment = _fragments.last;
+    forceBreakLastFragment(availableWidth: availableWidth, allowEmptyLine: true);
+
+    final EllipsisFragment ellipsisFragment = EllipsisFragment(
+      endIndex,
+      lastFragment.span,
+    );
+    ellipsisFragment.setMetrics(spanometer,
+      ascent: lastFragment.ascent,
+      descent: lastFragment.descent,
+      widthExcludingTrailingSpaces: ellipsisWidth,
+      widthIncludingTrailingSpaces: ellipsisWidth,
+    );
+    addFragment(ellipsisFragment);
+  }
+
   void revertToLastBreakOpportunity() {
     assert(isBreakable);
-    while (isEndProhibited) {
-      _popSegment();
+
+    // The last fragment in the line may or may not be breakable. Regardless,
+    // it needs to be removed.
+    //
+    // We need to find the latest breakable fragment in the line (other than the
+    // last fragment). Such breakable fragment is guaranteed to be found because
+    // the line `isBreakable`.
+
+    // Start from the end and skip the last fragment.
+    int i = _fragments.length - 2;
+    while (!_fragments[i].isBreak) {
+      i--;
     }
-    // Make sure the line is not empty and still breakable after popping a few
-    // segments.
-    assert(isNotEmpty);
-    assert(isBreakable);
+
+    _fragmentsForNextLine = _fragments.getRange(i + 1, _fragments.length).toList();
+    _fragments.removeRange(i + 1, _fragments.length);
+    _recalculateMetrics();
   }
 
-  LineBreakResult get _currentBoxStart {
-    if (_boxes.isEmpty) {
-      return start;
-    }
-    // The end of the last box is the start of the new box.
-    return _boxes.last.end;
-  }
-
-  double _currentBoxStartOffset = 0.0;
-
-  double get _currentBoxWidth => widthIncludingSpace - _currentBoxStartOffset;
-
-  /// Cuts a new box in the line.
+  /// Appends as many zero-width fragments as this line allows.
   ///
-  /// If this is the first box in the line, it'll start at the beginning of the
-  /// line. Else, it'll start at the end of the last box.
-  ///
-  /// A box should be cut whenever the end of line is reached, when switching
-  /// from one span to another, or when switching text direction.
-  ///
-  /// [isSpaceOnly] indicates that the box contains nothing but whitespace
-  /// characters.
-  void createBox({bool isSpaceOnly = false}) {
-    final LineBreakResult boxStart = _currentBoxStart;
-    final LineBreakResult boxEnd = end;
-    // Avoid creating empty boxes. This could happen when the end of a span
-    // coincides with the end of a line. In this case, `createBox` is called twice.
-    if (boxStart.index == boxEnd.index) {
-      return;
+  /// Returns the number of fragments that were appended.
+  int appendZeroWidthFragments(List<LayoutFragment> fragments, {required int startFrom}) {
+    int i = startFrom;
+    while (_canAppendEmptyFragments &&
+        i < fragments.length &&
+        fragments[i].widthExcludingTrailingSpaces == 0) {
+      addFragment(fragments[i]);
+      i++;
     }
-
-    _boxes.add(SpanBox(
-      spanometer,
-      start: boxStart,
-      end: boxEnd,
-      width: _currentBoxWidth,
-      paragraphDirection: _paragraphDirection,
-      boxDirection: _currentBoxDirection,
-      contentDirection: _currentContentDirection,
-      isSpaceOnly: isSpaceOnly,
-    ));
-
-    if (isSpaceOnly) {
-      _spaceBoxCount++;
-    }
-
-    _currentBoxStartOffset = widthIncludingSpace;
+    return i - startFrom;
   }
 
   /// Builds the [ParagraphLine] instance that represents this line.
-  ParagraphLine build({String? ellipsis}) {
-    // At the end of each line, we cut the last box of the line.
-    createBox();
-
-    final double ellipsisWidth =
-        ellipsis == null ? 0.0 : spanometer.measureText(ellipsis);
-
-    final int endIndexWithoutNewlines = math.max(start.index, end.indexWithoutTrailingNewlines);
-    final bool hardBreak;
-    if (end.type != LineBreakType.endOfText && _isLastBoxAPlaceholder) {
-      hardBreak = false;
-    } else {
-      hardBreak = end.isHard;
+  ParagraphLine build() {
+    if (_fragmentsForNextLine == null) {
+      _fragmentsForNextLine = _fragments.getRange(_lastBreakableFragment + 1, _fragments.length).toList();
+      _fragments.removeRange(_lastBreakableFragment + 1, _fragments.length);
     }
 
-    _processTrailingSpaces();
-
-    return ParagraphLine(
+    final int trailingNewlines = isEmpty ? 0 : _fragments.last.trailingNewlines;
+    final ParagraphLine line = ParagraphLine(
       lineNumber: lineNumber,
-      ellipsis: ellipsis,
-      startIndex: start.index,
-      endIndex: end.index,
-      endIndexWithoutNewlines: endIndexWithoutNewlines,
-      hardBreak: hardBreak,
-      width: width + ellipsisWidth,
-      widthWithTrailingSpaces: widthIncludingSpace + ellipsisWidth,
+      startIndex: startIndex,
+      endIndex: endIndex,
+      trailingNewlines: trailingNewlines,
+      trailingSpaces: _trailingSpaces,
+      spaceCount: _spaceCount,
+      hardBreak: isHardBreak,
+      width: width,
+      widthWithTrailingSpaces: widthIncludingSpace,
       left: alignOffset,
       height: height,
       baseline: accumulatedHeight + ascent,
       ascent: ascent,
       descent: descent,
-      boxes: _boxes,
-      spaceBoxCount: _spaceBoxCount,
-      trailingSpaceBoxCount: _trailingSpaceBoxCount,
+      fragments: _fragments,
+      textDirection: _paragraphDirection,
     );
-  }
 
-  int _trailingSpaceBoxCount = 0;
-
-  void _processTrailingSpaces() {
-    _trailingSpaceBoxCount = 0;
-    for (int i = _boxes.length - 1; i >= 0; i--) {
-      final RangeBox box = _boxes[i];
-      final bool isSpaceBox = box is SpanBox && box.isSpaceOnly;
-      if (!isSpaceBox) {
-        // We traversed all trailing space boxes.
-        break;
-      }
-
-      box._isTrailingSpace = true;
-      _trailingSpaceBoxCount++;
+    for (final LayoutFragment fragment in _fragments) {
+      fragment.line = line;
     }
-  }
 
-  LineBreakResult? _cachedNextBreak;
-
-  /// Finds the next line break after the end of this line.
-  DirectionalPosition findNextBreak() {
-    LineBreakResult? nextBreak = _cachedNextBreak;
-    final String text = paragraph.toPlainText();
-    // Don't recompute the `nextBreak` until the line has reached the previously
-    // computed `nextBreak`.
-    if (nextBreak == null || end.index >= nextBreak.index) {
-      final int maxEnd = spanometer.currentSpan.end;
-      nextBreak = nextLineBreak(text, end.index, maxEnd: maxEnd);
-      _cachedNextBreak = nextBreak;
-    }
-    // The current end of the line is the beginning of the next block.
-    return getDirectionalBlockEnd(text, end, nextBreak);
+    return line;
   }
 
   /// Creates a new [LineBuilder] to build the next line in the paragraph.
@@ -1561,9 +866,9 @@
       paragraph,
       spanometer,
       maxWidth: maxWidth,
-      start: end,
       lineNumber: lineNumber + 1,
       accumulatedHeight: accumulatedHeight + height,
+      fragments: _fragmentsForNextLine ?? <LayoutFragment>[],
     );
   }
 }
@@ -1604,10 +909,10 @@
   double? get letterSpacing => currentSpan.style.letterSpacing;
 
   TextHeightRuler? _currentRuler;
-  FlatTextSpan? _currentSpan;
+  ParagraphSpan? _currentSpan;
 
-  FlatTextSpan get currentSpan => _currentSpan!;
-  set currentSpan(FlatTextSpan? span) {
+  ParagraphSpan get currentSpan => _currentSpan!;
+  set currentSpan(ParagraphSpan? span) {
     if (span == _currentSpan) {
       return;
     }
@@ -1649,24 +954,44 @@
   /// The line height of the current span.
   double get height => _currentRuler!.height;
 
-  /// Measures the width of text between two line breaks.
-  ///
-  /// Doesn't include the width of any trailing white space.
-  double measure(LineBreakResult start, LineBreakResult end) {
-    return _measure(start.index, end.indexWithoutTrailingSpaces);
-  }
-
-  /// Measures the width of text between two line breaks.
-  ///
-  /// Includes the width of trailing white space, if any.
-  double measureIncludingSpace(LineBreakResult start, LineBreakResult end) {
-    return _measure(start.index, end.indexWithoutTrailingNewlines);
-  }
-
   double measureText(String text) {
     return measureSubstring(context, text, 0, text.length);
   }
 
+  double measureRange(int start, int end) {
+    assert(_currentSpan != null);
+
+    // Make sure the range is within the current span.
+    assert(start >= currentSpan.start && start <= currentSpan.end);
+    assert(end >= currentSpan.start && end <= currentSpan.end);
+
+    return _measure(start, end);
+  }
+
+  void measureFragment(LayoutFragment fragment) {
+    if (fragment.isPlaceholder) {
+      final PlaceholderSpan placeholder = fragment.span as PlaceholderSpan;
+      // The ascent/descent values of the placeholder fragment will be finalized
+      // later when the line is built.
+      fragment.setMetrics(this,
+        ascent: placeholder.height,
+        descent: 0,
+        widthExcludingTrailingSpaces: placeholder.width,
+        widthIncludingTrailingSpaces: placeholder.width,
+      );
+    } else {
+      currentSpan = fragment.span;
+      final double widthExcludingTrailingSpaces = _measure(fragment.start, fragment.end - fragment.trailingSpaces);
+      final double widthIncludingTrailingSpaces = _measure(fragment.start, fragment.end - fragment.trailingNewlines);
+      fragment.setMetrics(this,
+        ascent: ascent,
+        descent: descent,
+        widthExcludingTrailingSpaces: widthExcludingTrailingSpaces,
+        widthIncludingTrailingSpaces: widthIncludingTrailingSpaces,
+      );
+    }
+  }
+
   /// In a continuous, unbreakable block of text from [start] to [end], finds
   /// the point where text should be broken to fit in the given [availableWidth].
   ///
@@ -1687,11 +1012,9 @@
   }) {
     assert(_currentSpan != null);
 
-    final FlatTextSpan span = currentSpan;
-
     // Make sure the range is within the current span.
-    assert(start >= span.start && start <= span.end);
-    assert(end >= span.start && end <= span.end);
+    assert(start >= currentSpan.start && start <= currentSpan.end);
+    assert(end >= currentSpan.start && end <= currentSpan.end);
 
     if (availableWidth <= 0.0) {
       return allowEmpty ? start : start + 1;
@@ -1699,7 +1022,7 @@
 
     int low = start;
     int high = end;
-    do {
+    while (high - low > 1) {
       final int mid = (low + high) ~/ 2;
       final double width = _measure(start, mid);
       if (width < availableWidth) {
@@ -1709,7 +1032,7 @@
       } else {
         low = high = mid;
       }
-    } while (high - low > 1);
+    }
 
     if (low == start && !allowEmpty) {
       low++;
@@ -1719,11 +1042,9 @@
 
   double _measure(int start, int end) {
     assert(_currentSpan != null);
-    final FlatTextSpan span = currentSpan;
-
     // Make sure the range is within the current span.
-    assert(start >= span.start && start <= span.end);
-    assert(end >= span.start && end <= span.end);
+    assert(start >= currentSpan.start && start <= currentSpan.end);
+    assert(end >= currentSpan.start && end <= currentSpan.end);
 
     final String text = paragraph.toPlainText();
     return measureSubstring(
diff --git a/lib/web_ui/lib/src/engine/text/line_breaker.dart b/lib/web_ui/lib/src/engine/text/line_breaker.dart
index 9068980..fa2adb8 100644
--- a/lib/web_ui/lib/src/engine/text/line_breaker.dart
+++ b/lib/web_ui/lib/src/engine/text/line_breaker.dart
@@ -2,9 +2,7 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import 'dart:math' as math;
-
-import '../util.dart';
+import 'fragmenter.dart';
 import 'line_break_properties.dart';
 import 'unicode_range.dart';
 
@@ -26,96 +24,42 @@
   endOfText,
 }
 
-/// Acts as a tuple that encapsulates information about a line break.
-///
-/// It contains multiple indices that are helpful when it comes to measuring the
-/// width of a line of text.
-///
-/// [indexWithoutTrailingSpaces] <= [indexWithoutTrailingNewlines] <= [index]
-///
-/// Example: for the string "foo \nbar " here are the indices:
-/// ```
-///   f   o   o       \n  b   a   r
-/// ^   ^   ^   ^   ^   ^   ^   ^   ^   ^
-/// 0   1   2   3   4   5   6   7   8   9
-/// ```
-/// It contains two line breaks:
-/// ```
-/// // The first line break:
-/// LineBreakResult(5, 4, 3, LineBreakType.mandatory)
-///
-/// // Second line break:
-/// LineBreakResult(9, 9, 8, LineBreakType.mandatory)
-/// ```
-class LineBreakResult {
-  const LineBreakResult(
-    this.index,
-    this.indexWithoutTrailingNewlines,
-    this.indexWithoutTrailingSpaces,
-    this.type,
-  ): assert(indexWithoutTrailingSpaces <= indexWithoutTrailingNewlines),
-     assert(indexWithoutTrailingNewlines <= index);
-
-  /// Creates a [LineBreakResult] where all indices are the same (i.e. there are
-  /// no trailing spaces or new lines).
-  const LineBreakResult.sameIndex(this.index, this.type)
-      : indexWithoutTrailingNewlines = index,
-        indexWithoutTrailingSpaces = index;
-
-  /// The true index at which the line break should occur, including all spaces
-  /// and new lines.
-  final int index;
-
-  /// The index of the line break excluding any trailing new lines.
-  final int indexWithoutTrailingNewlines;
-
-  /// The index of the line break excluding any trailing spaces.
-  final int indexWithoutTrailingSpaces;
-
-  /// The type of line break is useful to determine the behavior in text
-  /// measurement.
-  ///
-  /// For example, a mandatory line break always causes a line break regardless
-  /// of width constraints. But a line break opportunity requires further checks
-  /// to decide whether to take the line break or not.
-  final LineBreakType type;
-
-  bool get isHard =>
-      type == LineBreakType.mandatory || type == LineBreakType.endOfText;
+/// Splits [text] into fragments based on line breaks.
+class LineBreakFragmenter extends TextFragmenter {
+  const LineBreakFragmenter(super.text);
 
   @override
-  int get hashCode => Object.hash(
-        index,
-        indexWithoutTrailingNewlines,
-        indexWithoutTrailingSpaces,
-        type,
-      );
+  List<LineBreakFragment> fragment() {
+    return _computeLineBreakFragments(text);
+  }
+}
+
+class LineBreakFragment extends TextFragment {
+  const LineBreakFragment(super.start, super.end, this.type, {
+    required this.trailingNewlines,
+    required this.trailingSpaces,
+  });
+
+  final LineBreakType type;
+  final int trailingNewlines;
+  final int trailingSpaces;
+
+  @override
+  int get hashCode => Object.hash(start, end, type, trailingNewlines, trailingSpaces);
 
   @override
   bool operator ==(Object other) {
-    if (identical(this, other)) {
-      return true;
-    }
-    if (other.runtimeType != runtimeType) {
-      return false;
-    }
-    return other is LineBreakResult &&
-        other.index == index &&
-        other.indexWithoutTrailingNewlines == indexWithoutTrailingNewlines &&
-        other.indexWithoutTrailingSpaces == indexWithoutTrailingSpaces &&
-        other.type == type;
+    return other is LineBreakFragment &&
+        other.start == start &&
+        other.end == end &&
+        other.type == type &&
+        other.trailingNewlines == trailingNewlines &&
+        other.trailingSpaces == trailingSpaces;
   }
 
   @override
   String toString() {
-    if (assertionsEnabled) {
-      return 'LineBreakResult(index: $index, '
-          'without new lines: $indexWithoutTrailingNewlines, '
-          'without spaces: $indexWithoutTrailingSpaces, '
-          'type: $type)';
-    } else {
-      return super.toString();
-    }
+    return 'LineBreakFragment($start, $end, $type)';
   }
 }
 
@@ -151,6 +95,10 @@
       (charCode >= 0xFE17 && charCode <= 0xFF62);
 }
 
+bool _isSurrogatePair(int? codePoint) {
+  return codePoint != null && codePoint > 0xFFFF;
+}
+
 /// Finds the next line break in the given [text] starting from [index].
 ///
 /// We think about indices as pointing between characters, and they go all the
@@ -163,100 +111,105 @@
 /// 0   1   2   3   4   5   6   7
 /// ```
 ///
-/// This way the indices work well with [String.substring()].
+/// This way the indices work well with [String.substring].
 ///
 /// Useful resources:
 ///
 /// * https://www.unicode.org/reports/tr14/tr14-45.html#Algorithm
 /// * https://www.unicode.org/Public/11.0.0/ucd/LineBreak.txt
-LineBreakResult nextLineBreak(String text, int index, {int? maxEnd}) {
-  final LineBreakResult unsafeResult = _unsafeNextLineBreak(text, index, maxEnd: maxEnd);
-  if (maxEnd != null && unsafeResult.index > maxEnd) {
-    return LineBreakResult(
-      maxEnd,
-      math.min(maxEnd, unsafeResult.indexWithoutTrailingNewlines),
-      math.min(maxEnd, unsafeResult.indexWithoutTrailingSpaces),
-      LineBreakType.prohibited,
-    );
-  }
-  return unsafeResult;
-}
-
-LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) {
-  int? codePoint = getCodePoint(text, index);
-  LineCharProperty curr = lineLookup.findForChar(codePoint);
-
-  LineCharProperty? prev1;
+List<LineBreakFragment> _computeLineBreakFragments(String text) {
+  final List<LineBreakFragment> fragments = <LineBreakFragment>[];
 
   // Keeps track of the character two positions behind.
   LineCharProperty? prev2;
+  LineCharProperty? prev1;
 
-  // When there's a sequence of spaces or combining marks, this variable
-  // contains the base property i.e. the property of the character before the
-  // sequence.
-  LineCharProperty? baseOfSpaceSequence;
+  int? codePoint = getCodePoint(text, 0);
+  LineCharProperty? curr = lineLookup.findForChar(codePoint);
 
-  /// The index of the last character that wasn't a space.
-  int lastNonSpaceIndex = index;
+  // When there's a sequence of spaces, this variable contains the base property
+  // i.e. the property of the character preceding the sequence.
+  LineCharProperty baseOfSpaceSequence = LineCharProperty.WJ;
 
-  /// The index of the last character that wasn't a new line.
-  int lastNonNewlineIndex = index;
+  // When there's a sequence of combining marks, this variable contains the base
+  // property i.e. the property of the character preceding the sequence.
+  LineCharProperty baseOfCombiningMarks = LineCharProperty.AL;
 
-  // When the text/line starts with SP, we should treat the beginning of text/line
-  // as if it were a WJ (word joiner).
-  if (curr == LineCharProperty.SP) {
-    baseOfSpaceSequence = LineCharProperty.WJ;
+  int index = 0;
+  int trailingNewlines = 0;
+  int trailingSpaces = 0;
+
+  int fragmentStart = 0;
+
+  void setBreak(LineBreakType type, int debugRuleNumber) {
+    final int fragmentEnd =
+        type == LineBreakType.endOfText ? text.length : index;
+    assert(fragmentEnd >= fragmentStart);
+
+    if (prev1 == LineCharProperty.SP) {
+      trailingSpaces++;
+    } else if (_isHardBreak(prev1) || prev1 == LineCharProperty.CR) {
+      trailingNewlines++;
+      trailingSpaces++;
+    }
+
+    if (type == LineBreakType.prohibited) {
+      // Don't create a fragment.
+      return;
+    }
+
+    fragments.add(LineBreakFragment(
+      fragmentStart,
+      fragmentEnd,
+      type,
+      trailingNewlines: trailingNewlines,
+      trailingSpaces: trailingSpaces,
+    ));
+
+    fragmentStart = index;
+
+    // Reset trailing spaces/newlines counter after a new fragment.
+    trailingNewlines = 0;
+    trailingSpaces = 0;
+
+    prev1 = prev2 = null;
   }
 
-  bool isCurrZWJ = curr == LineCharProperty.ZWJ;
+  // Never break at the start of text.
+  // LB2: sot ×
+  setBreak(LineBreakType.prohibited, 2);
 
-  // LB10: Treat any remaining combining mark or ZWJ as AL.
-  // This catches the case where a CM is the first character on the line.
-  if (curr == LineCharProperty.CM || curr == LineCharProperty.ZWJ) {
-    curr = LineCharProperty.AL;
-  }
+  // Never break at the start of text.
+  // LB2: sot ×
+  //
+  // Skip index 0 because a line break can't exist at the start of text.
+  index++;
 
   int regionalIndicatorCount = 0;
 
-  // Always break at the end of text.
-  // LB3: ! eot
-  while (index < text.length) {
-    if (maxEnd != null && index > maxEnd) {
-      return LineBreakResult(
-        maxEnd,
-        math.min(maxEnd, lastNonNewlineIndex),
-        math.min(maxEnd, lastNonSpaceIndex),
-        LineBreakType.prohibited,
-      );
-    }
-
-    // Keep count of the RI (regional indicator) sequence.
-    if (curr == LineCharProperty.RI) {
-      regionalIndicatorCount++;
-    } else {
-      regionalIndicatorCount = 0;
-    }
-
-    if (codePoint != null && codePoint > 0xFFFF) {
-      // Advance `index` one extra step when handling a surrogate pair in the
-      // string.
-      index++;
-    }
-    index++;
+  // We need to go until `text.length` in order to handle the case where the
+  // paragraph ends with a hard break. In this case, there will be an empty line
+  // at the end.
+  for (; index <= text.length; index++) {
     prev2 = prev1;
     prev1 = curr;
 
-    final bool isPrevZWJ = isCurrZWJ;
-
-    // Reset the base when we are past the space sequence.
-    if (prev1 != LineCharProperty.SP) {
-      baseOfSpaceSequence = null;
+    if (_isSurrogatePair(codePoint)) {
+      // Can't break in the middle of a surrogate pair.
+      setBreak(LineBreakType.prohibited, -1);
+      // Advance `index` one extra step to skip the tail of the surrogate pair.
+      index++;
     }
 
     codePoint = getCodePoint(text, index);
     curr = lineLookup.findForChar(codePoint);
 
-    isCurrZWJ = curr == LineCharProperty.ZWJ;
+    // Keep count of the RI (regional indicator) sequence.
+    if (prev1 == LineCharProperty.RI) {
+      regionalIndicatorCount++;
+    } else {
+      regionalIndicatorCount = 0;
+    }
 
     // Always break after hard line breaks.
     // LB4: BK !
@@ -265,69 +218,44 @@
     // LB5: LF !
     //      NL !
     if (_isHardBreak(prev1)) {
-      return LineBreakResult(
-        index,
-        lastNonNewlineIndex,
-        lastNonSpaceIndex,
-        LineBreakType.mandatory,
-      );
+      setBreak(LineBreakType.mandatory, 5);
+      continue;
     }
 
     if (prev1 == LineCharProperty.CR) {
       if (curr == LineCharProperty.LF) {
         // LB5: CR × LF
-        continue;
+        setBreak(LineBreakType.prohibited, 5);
       } else {
         // LB5: CR !
-        return LineBreakResult(
-          index,
-          lastNonNewlineIndex,
-          lastNonSpaceIndex,
-          LineBreakType.mandatory,
-        );
+        setBreak(LineBreakType.mandatory, 5);
       }
-    }
-
-    // At this point, we know for sure the prev character wasn't a new line.
-    lastNonNewlineIndex = index;
-    if (prev1 != LineCharProperty.SP) {
-      lastNonSpaceIndex = index;
+      continue;
     }
 
     // Do not break before hard line breaks.
     // LB6: × ( BK | CR | LF | NL )
     if (_isHardBreak(curr) || curr == LineCharProperty.CR) {
+      setBreak(LineBreakType.prohibited, 6);
       continue;
     }
 
-    // Always break at the end of text.
-    // LB3: ! eot
     if (index >= text.length) {
-      return LineBreakResult(
-        text.length,
-        lastNonNewlineIndex,
-        lastNonSpaceIndex,
-        LineBreakType.endOfText,
-      );
+      break;
+    }
+
+    // Establish the base for the space sequence.
+    if (prev1 != LineCharProperty.SP) {
+      // When the text/line starts with SP, we should treat the beginning of text/line
+      // as if it were a WJ (word joiner).
+      baseOfSpaceSequence = prev1 ?? LineCharProperty.WJ;
     }
 
     // Do not break before spaces or zero width space.
     // LB7: × SP
-    if (curr == LineCharProperty.SP) {
-      // When we encounter SP, we preserve the property of the previous
-      // character so we can later apply the indirect breaking rules.
-      if (prev1 == LineCharProperty.SP) {
-        // If we are in the middle of a space sequence, a base should've
-        // already been set.
-        assert(baseOfSpaceSequence != null);
-      } else {
-        // We are at the beginning of a space sequence, establish the base.
-        baseOfSpaceSequence = prev1;
-      }
-      continue;
-    }
-    // LB7: × ZW
-    if (curr == LineCharProperty.ZW) {
+    //      × ZW
+    if (curr == LineCharProperty.SP || curr == LineCharProperty.ZW) {
+      setBreak(LineBreakType.prohibited, 7);
       continue;
     }
 
@@ -336,53 +264,61 @@
     // LB8: ZW SP* ÷
     if (prev1 == LineCharProperty.ZW ||
         baseOfSpaceSequence == LineCharProperty.ZW) {
-      return LineBreakResult(
-        index,
-        lastNonNewlineIndex,
-        lastNonSpaceIndex,
-        LineBreakType.opportunity,
-      );
+      setBreak(LineBreakType.opportunity, 8);
+      continue;
+    }
+
+    // Do not break after a zero width joiner.
+    // LB8a: ZWJ ×
+    if (prev1 == LineCharProperty.ZWJ) {
+      setBreak(LineBreakType.prohibited, 8);
+      continue;
+    }
+
+    // Establish the base for the sequences of combining marks.
+    if (prev1 != LineCharProperty.CM && prev1 != LineCharProperty.ZWJ) {
+      baseOfCombiningMarks = prev1 ?? LineCharProperty.AL;
     }
 
     // Do not break a combining character sequence; treat it as if it has the
     // line breaking class of the base character in all of the following rules.
     // Treat ZWJ as if it were CM.
-    // LB9: Treat X (CM | ZWJ)* as if it were X
-    //      where X is any line break class except BK, NL, LF, CR, SP, or ZW.
     if (curr == LineCharProperty.CM || curr == LineCharProperty.ZWJ) {
-      // Other properties: BK, NL, LF, CR, ZW would've already generated a line
-      // break, so we won't find them in `prev`.
-      if (prev1 == LineCharProperty.SP) {
+      if (baseOfCombiningMarks == LineCharProperty.SP) {
         // LB10: Treat any remaining combining mark or ZWJ as AL.
         curr = LineCharProperty.AL;
       } else {
-        if (prev1 == LineCharProperty.RI) {
+        // LB9: Treat X (CM | ZWJ)* as if it were X
+        //      where X is any line break class except BK, NL, LF, CR, SP, or ZW.
+        curr = baseOfCombiningMarks;
+        if (curr == LineCharProperty.RI) {
           // Prevent the previous RI from being double-counted.
           regionalIndicatorCount--;
         }
-        // Preserve the property of the previous character to treat the sequence
-        // as if it were X.
-        curr = prev1;
+        setBreak(LineBreakType.prohibited, 9);
         continue;
       }
     }
-
-    // Do not break after a zero width joiner.
-    // LB8a: ZWJ ×
-    if (isPrevZWJ) {
-      continue;
+    // In certain situations (e.g. CM immediately following a hard break), we
+    // need to also check if the previous character was CM/ZWJ. That's because
+    // hard breaks caused the previous iteration to short-circuit, which leads
+    // to `baseOfCombiningMarks` not being updated properly.
+    if (prev1 == LineCharProperty.CM || prev1 == LineCharProperty.ZWJ) {
+      prev1 = baseOfCombiningMarks;
     }
 
     // Do not break before or after Word joiner and related characters.
     // LB11: × WJ
     //       WJ ×
     if (curr == LineCharProperty.WJ || prev1 == LineCharProperty.WJ) {
+      setBreak(LineBreakType.prohibited, 11);
       continue;
     }
 
     // Do not break after NBSP and related characters.
     // LB12: GL ×
     if (prev1 == LineCharProperty.GL) {
+      setBreak(LineBreakType.prohibited, 12);
       continue;
     }
 
@@ -393,6 +329,7 @@
             prev1 == LineCharProperty.BA ||
             prev1 == LineCharProperty.HY) &&
         curr == LineCharProperty.GL) {
+      setBreak(LineBreakType.prohibited, 12);
       continue;
     }
 
@@ -412,6 +349,7 @@
             curr == LineCharProperty.EX ||
             curr == LineCharProperty.IS ||
             curr == LineCharProperty.SY)) {
+      setBreak(LineBreakType.prohibited, 13);
       continue;
     }
 
@@ -421,6 +359,7 @@
     // The above is a quote from unicode.org. In our implementation, we did the
     // following modification: Allow breaks when there are spaces.
     if (prev1 == LineCharProperty.OP) {
+      setBreak(LineBreakType.prohibited, 14);
       continue;
     }
 
@@ -430,6 +369,7 @@
     // The above is a quote from unicode.org. In our implementation, we did the
     // following modification: Allow breaks when there are spaces.
     if (prev1 == LineCharProperty.QU && curr == LineCharProperty.OP) {
+      setBreak(LineBreakType.prohibited, 15);
       continue;
     }
 
@@ -441,6 +381,7 @@
             prev1 == LineCharProperty.CP ||
             baseOfSpaceSequence == LineCharProperty.CP) &&
         curr == LineCharProperty.NS) {
+      setBreak(LineBreakType.prohibited, 16);
       continue;
     }
 
@@ -449,37 +390,34 @@
     if ((prev1 == LineCharProperty.B2 ||
             baseOfSpaceSequence == LineCharProperty.B2) &&
         curr == LineCharProperty.B2) {
+      setBreak(LineBreakType.prohibited, 17);
       continue;
     }
 
     // Break after spaces.
     // LB18: SP ÷
     if (prev1 == LineCharProperty.SP) {
-      return LineBreakResult(
-        index,
-        lastNonNewlineIndex,
-        lastNonSpaceIndex,
-        LineBreakType.opportunity,
-      );
+      setBreak(LineBreakType.opportunity, 18);
+      continue;
     }
 
     // Do not break before or after quotation marks, such as ‘”’.
     // LB19: × QU
     //       QU ×
     if (prev1 == LineCharProperty.QU || curr == LineCharProperty.QU) {
+      setBreak(LineBreakType.prohibited, 19);
       continue;
     }
 
     // Break before and after unresolved CB.
     // LB20: ÷ CB
     //       CB ÷
+    //
+    // In flutter web, we use this as an object-replacement character for
+    // placeholders.
     if (prev1 == LineCharProperty.CB || curr == LineCharProperty.CB) {
-      return LineBreakResult(
-        index,
-        lastNonNewlineIndex,
-        lastNonSpaceIndex,
-        LineBreakType.opportunity,
-      );
+      setBreak(LineBreakType.opportunity, 20);
+      continue;
     }
 
     // Do not break before hyphen-minus, other hyphens, fixed-width spaces,
@@ -492,6 +430,7 @@
         curr == LineCharProperty.HY ||
         curr == LineCharProperty.NS ||
         prev1 == LineCharProperty.BB) {
+      setBreak(LineBreakType.prohibited, 21);
       continue;
     }
 
@@ -499,18 +438,21 @@
     // LB21a: HL (HY | BA) ×
     if (prev2 == LineCharProperty.HL &&
         (prev1 == LineCharProperty.HY || prev1 == LineCharProperty.BA)) {
+      setBreak(LineBreakType.prohibited, 21);
       continue;
     }
 
     // Don’t break between Solidus and Hebrew letters.
     // LB21b: SY × HL
     if (prev1 == LineCharProperty.SY && curr == LineCharProperty.HL) {
+      setBreak(LineBreakType.prohibited, 21);
       continue;
     }
 
     // Do not break before ellipses.
     // LB22: × IN
     if (curr == LineCharProperty.IN) {
+      setBreak(LineBreakType.prohibited, 22);
       continue;
     }
 
@@ -519,6 +461,7 @@
     //       NU × (AL | HL)
     if ((_isALorHL(prev1) && curr == LineCharProperty.NU) ||
         (prev1 == LineCharProperty.NU && _isALorHL(curr))) {
+      setBreak(LineBreakType.prohibited, 23);
       continue;
     }
 
@@ -529,6 +472,7 @@
         (curr == LineCharProperty.ID ||
             curr == LineCharProperty.EB ||
             curr == LineCharProperty.EM)) {
+      setBreak(LineBreakType.prohibited, 23);
       continue;
     }
     // LB23a: (ID | EB | EM) × PO
@@ -536,6 +480,7 @@
             prev1 == LineCharProperty.EB ||
             prev1 == LineCharProperty.EM) &&
         curr == LineCharProperty.PO) {
+      setBreak(LineBreakType.prohibited, 23);
       continue;
     }
 
@@ -544,11 +489,13 @@
     // LB24: (PR | PO) × (AL | HL)
     if ((prev1 == LineCharProperty.PR || prev1 == LineCharProperty.PO) &&
         _isALorHL(curr)) {
+      setBreak(LineBreakType.prohibited, 24);
       continue;
     }
     // LB24: (AL | HL) × (PR | PO)
     if (_isALorHL(prev1) &&
         (curr == LineCharProperty.PR || curr == LineCharProperty.PO)) {
+      setBreak(LineBreakType.prohibited, 24);
       continue;
     }
 
@@ -558,11 +505,13 @@
             prev1 == LineCharProperty.CP ||
             prev1 == LineCharProperty.NU) &&
         (curr == LineCharProperty.PO || curr == LineCharProperty.PR)) {
+      setBreak(LineBreakType.prohibited, 25);
       continue;
     }
     // LB25: (PO | PR) × OP
     if ((prev1 == LineCharProperty.PO || prev1 == LineCharProperty.PR) &&
         curr == LineCharProperty.OP) {
+      setBreak(LineBreakType.prohibited, 25);
       continue;
     }
     // LB25: (PO | PR | HY | IS | NU | SY) × NU
@@ -573,6 +522,7 @@
             prev1 == LineCharProperty.NU ||
             prev1 == LineCharProperty.SY) &&
         curr == LineCharProperty.NU) {
+      setBreak(LineBreakType.prohibited, 25);
       continue;
     }
 
@@ -583,38 +533,45 @@
             curr == LineCharProperty.JV ||
             curr == LineCharProperty.H2 ||
             curr == LineCharProperty.H3)) {
+      setBreak(LineBreakType.prohibited, 26);
       continue;
     }
     // LB26: (JV | H2) × (JV | JT)
     if ((prev1 == LineCharProperty.JV || prev1 == LineCharProperty.H2) &&
         (curr == LineCharProperty.JV || curr == LineCharProperty.JT)) {
+      setBreak(LineBreakType.prohibited, 26);
       continue;
     }
     // LB26: (JT | H3) × JT
     if ((prev1 == LineCharProperty.JT || prev1 == LineCharProperty.H3) &&
         curr == LineCharProperty.JT) {
+      setBreak(LineBreakType.prohibited, 26);
       continue;
     }
 
     // Treat a Korean Syllable Block the same as ID.
     // LB27: (JL | JV | JT | H2 | H3) × PO
     if (_isKoreanSyllable(prev1) && curr == LineCharProperty.PO) {
+      setBreak(LineBreakType.prohibited, 27);
       continue;
     }
     // LB27: PR × (JL | JV | JT | H2 | H3)
     if (prev1 == LineCharProperty.PR && _isKoreanSyllable(curr)) {
+      setBreak(LineBreakType.prohibited, 27);
       continue;
     }
 
     // Do not break between alphabetics.
     // LB28: (AL | HL) × (AL | HL)
     if (_isALorHL(prev1) && _isALorHL(curr)) {
+      setBreak(LineBreakType.prohibited, 28);
       continue;
     }
 
     // Do not break between numeric punctuation and alphabetics (“e.g.”).
     // LB29: IS × (AL | HL)
     if (prev1 == LineCharProperty.IS && _isALorHL(curr)) {
+      setBreak(LineBreakType.prohibited, 29);
       continue;
     }
 
@@ -627,12 +584,14 @@
     if ((_isALorHL(prev1) || prev1 == LineCharProperty.NU) &&
         curr == LineCharProperty.OP &&
         !_hasEastAsianWidthFWH(text.codeUnitAt(index))) {
+      setBreak(LineBreakType.prohibited, 30);
       continue;
     }
     // LB30: CP × (AL | HL | NU)
     if (prev1 == LineCharProperty.CP &&
         !_hasEastAsianWidthFWH(text.codeUnitAt(index - 1)) &&
         (_isALorHL(curr) || curr == LineCharProperty.NU)) {
+      setBreak(LineBreakType.prohibited, 30);
       continue;
     }
 
@@ -642,37 +601,29 @@
     //        [^RI] (RI RI)* RI × RI
     if (curr == LineCharProperty.RI) {
       if (regionalIndicatorCount.isOdd) {
-        continue;
+        setBreak(LineBreakType.prohibited, 30);
       } else {
-        return LineBreakResult(
-          index,
-          lastNonNewlineIndex,
-          lastNonSpaceIndex,
-          LineBreakType.opportunity,
-        );
+        setBreak(LineBreakType.opportunity, 30);
       }
+      continue;
     }
 
     // Do not break between an emoji base and an emoji modifier.
     // LB30b: EB × EM
     if (prev1 == LineCharProperty.EB && curr == LineCharProperty.EM) {
+      setBreak(LineBreakType.prohibited, 30);
       continue;
     }
 
     // Break everywhere else.
     // LB31: ALL ÷
     //       ÷ ALL
-    return LineBreakResult(
-      index,
-      lastNonNewlineIndex,
-      lastNonSpaceIndex,
-      LineBreakType.opportunity,
-    );
+    setBreak(LineBreakType.opportunity, 31);
   }
-  return LineBreakResult(
-    text.length,
-    lastNonNewlineIndex,
-    lastNonSpaceIndex,
-    LineBreakType.endOfText,
-  );
+
+  // Always break at the end of text.
+  // LB3: ! eot
+  setBreak(LineBreakType.endOfText, 3);
+
+  return fragments;
 }
diff --git a/lib/web_ui/lib/src/engine/text/paint_service.dart b/lib/web_ui/lib/src/engine/text/paint_service.dart
index 1183a66..857d55a 100644
--- a/lib/web_ui/lib/src/engine/text/paint_service.dart
+++ b/lib/web_ui/lib/src/engine/text/paint_service.dart
@@ -8,7 +8,7 @@
 import '../html/bitmap_canvas.dart';
 import '../html/painting.dart';
 import 'canvas_paragraph.dart';
-import 'layout_service.dart';
+import 'layout_fragmenter.dart';
 import 'paragraph.dart';
 
 /// Responsible for painting a [CanvasParagraph] on a [BitmapCanvas].
@@ -18,31 +18,15 @@
   final CanvasParagraph paragraph;
 
   void paint(BitmapCanvas canvas, ui.Offset offset) {
-    // Loop through all the lines, for each line, loop through all the boxes and
-    // paint them. The boxes have enough information so they can be painted
+    // Loop through all the lines, for each line, loop through all fragments and
+    // paint them. The fragment objects have enough information to be painted
     // individually.
     final List<ParagraphLine> lines = paragraph.lines;
 
-    if (lines.isEmpty) {
-      return;
-    }
-
     for (final ParagraphLine line in lines) {
-      if (line.boxes.isEmpty) {
-        continue;
-      }
-
-      final RangeBox lastBox = line.boxes.last;
-
-      for (final RangeBox box in line.boxes) {
-        final bool isTrailingSpaceBox =
-            box == lastBox && box is SpanBox && box.isSpaceOnly;
-
-        // Don't paint background for the trailing space in the line.
-        if (!isTrailingSpaceBox) {
-          _paintBackground(canvas, offset, line, box);
-        }
-        _paintText(canvas, offset, line, box);
+      for (final LayoutFragment fragment in line.fragments) {
+        _paintBackground(canvas, offset, fragment);
+        _paintText(canvas, offset, line, fragment);
       }
     }
   }
@@ -50,17 +34,17 @@
   void _paintBackground(
     BitmapCanvas canvas,
     ui.Offset offset,
-    ParagraphLine line,
-    RangeBox box,
+    LayoutFragment fragment,
   ) {
-    if (box is SpanBox) {
-      final FlatTextSpan span = box.span;
-
+    final ParagraphSpan span = fragment.span;
+    if (span is FlatTextSpan) {
       // Paint the background of the box, if the span has a background.
       final SurfacePaint? background = span.style.background as SurfacePaint?;
       if (background != null) {
-        final ui.Rect rect = box.toTextBox(line, forPainting: true).toRect().shift(offset);
-        canvas.drawRect(rect, background.paintData);
+        final ui.Rect rect = fragment.toPaintingTextBox().toRect();
+        if (!rect.isEmpty) {
+          canvas.drawRect(rect.shift(offset), background.paintData);
+        }
       }
     }
   }
@@ -69,63 +53,63 @@
     BitmapCanvas canvas,
     ui.Offset offset,
     ParagraphLine line,
-    RangeBox box,
+    LayoutFragment fragment,
   ) {
     // There's no text to paint in placeholder spans.
-    if (box is SpanBox) {
-      final FlatTextSpan span = box.span;
-
-      _applySpanStyleToCanvas(span, canvas);
-      final double x = offset.dx + line.left + box.left;
-      final double y = offset.dy + line.baseline;
-
-      // Don't paint the text for space-only boxes. This is just an
-      // optimization, it doesn't have any effect on the output.
-      if (!box.isSpaceOnly) {
-        final String text = paragraph.toPlainText().substring(
-              box.start.index,
-              box.end.indexWithoutTrailingNewlines,
-            );
-        final double? letterSpacing = span.style.letterSpacing;
-        if (letterSpacing == null || letterSpacing == 0.0) {
-          canvas.drawText(text, x, y,
-              style: span.style.foreground?.style, shadows: span.style.shadows);
-        } else {
-          // TODO(mdebbar): Implement letter-spacing on canvas more efficiently:
-          //                https://github.com/flutter/flutter/issues/51234
-          double charX = x;
-          final int len = text.length;
-          for (int i = 0; i < len; i++) {
-            final String char = text[i];
-            canvas.drawText(char, charX.roundToDouble(), y,
-                style: span.style.foreground?.style,
-                shadows: span.style.shadows);
-            charX += letterSpacing + canvas.measureText(char).width!;
-          }
-        }
-      }
-
-      // Paint the ellipsis using the same span styles.
-      final String? ellipsis = line.ellipsis;
-      if (ellipsis != null && box == line.boxes.last) {
-        final double x = offset.dx + line.left + box.right;
-        canvas.drawText(ellipsis, x, y, style: span.style.foreground?.style);
-      }
-
-      canvas.tearDownPaint();
+    if (fragment.isPlaceholder) {
+      return;
     }
+
+    // Don't paint the text for space-only boxes. This is just an
+    // optimization, it doesn't have any effect on the output.
+    if (fragment.isSpaceOnly) {
+      return;
+    }
+
+    _prepareCanvasForFragment(canvas, fragment);
+    final double fragmentX = fragment.textDirection! == ui.TextDirection.ltr
+        ? fragment.left
+        : fragment.right;
+
+    final double x = offset.dx + line.left + fragmentX;
+    final double y = offset.dy + line.baseline;
+
+    final EngineTextStyle style = fragment.style;
+
+    final String text = fragment.getText(paragraph);
+    final double? letterSpacing = style.letterSpacing;
+    if (letterSpacing == null || letterSpacing == 0.0) {
+      canvas.drawText(text, x, y,
+          style: style.foreground?.style, shadows: style.shadows);
+    } else {
+      // TODO(mdebbar): Implement letter-spacing on canvas more efficiently:
+      //                https://github.com/flutter/flutter/issues/51234
+      double charX = x;
+      final int len = text.length;
+      for (int i = 0; i < len; i++) {
+        final String char = text[i];
+        canvas.drawText(char, charX.roundToDouble(), y,
+            style: style.foreground?.style,
+            shadows: style.shadows);
+        charX += letterSpacing + canvas.measureText(char).width!;
+      }
+    }
+
+    canvas.tearDownPaint();
   }
 
-  void _applySpanStyleToCanvas(FlatTextSpan span, BitmapCanvas canvas) {
+  void _prepareCanvasForFragment(BitmapCanvas canvas, LayoutFragment fragment) {
+    final EngineTextStyle style = fragment.style;
+
     final SurfacePaint? paint;
-    final ui.Paint? foreground = span.style.foreground;
+    final ui.Paint? foreground = style.foreground;
     if (foreground != null) {
       paint = foreground as SurfacePaint;
     } else {
-      paint = (ui.Paint()..color = span.style.color!) as SurfacePaint;
+      paint = (ui.Paint()..color = style.color!) as SurfacePaint;
     }
 
-    canvas.setCssFont(span.style.cssFontString);
+    canvas.setCssFont(style.cssFontString, fragment.textDirection!);
     canvas.setUpPaint(paint.paintData, null);
   }
 }
diff --git a/lib/web_ui/lib/src/engine/text/paragraph.dart b/lib/web_ui/lib/src/engine/text/paragraph.dart
index 7011038..ef0d559 100644
--- a/lib/web_ui/lib/src/engine/text/paragraph.dart
+++ b/lib/web_ui/lib/src/engine/text/paragraph.dart
@@ -10,7 +10,8 @@
 import '../dom.dart';
 import '../embedder.dart';
 import '../util.dart';
-import 'layout_service.dart';
+import 'canvas_paragraph.dart';
+import 'layout_fragmenter.dart';
 import 'ruler.dart';
 
 class EngineLineMetrics implements ui.LineMetrics {
@@ -114,16 +115,17 @@
     required double left,
     required double baseline,
     required int lineNumber,
-    required this.ellipsis,
     required this.startIndex,
     required this.endIndex,
-    required this.endIndexWithoutNewlines,
+    required this.trailingNewlines,
+    required this.trailingSpaces,
+    required this.spaceCount,
     required this.widthWithTrailingSpaces,
-    required this.boxes,
-    required this.spaceBoxCount,
-    required this.trailingSpaceBoxCount,
+    required this.fragments,
+    required this.textDirection,
     this.displayText,
-  }) : lineMetrics = EngineLineMetrics(
+  }) : assert(trailingNewlines <= endIndex - startIndex),
+       lineMetrics = EngineLineMetrics(
           hardBreak: hardBreak,
           ascent: ascent,
           descent: descent,
@@ -138,12 +140,6 @@
   /// Metrics for this line of the paragraph.
   final EngineLineMetrics lineMetrics;
 
-  /// The string to be displayed as an overflow indicator.
-  ///
-  /// When the value is non-null, it means this line is overflowing and the
-  /// [ellipsis] needs to be displayed at the end of it.
-  final String? ellipsis;
-
   /// The index (inclusive) in the text where this line begins.
   final int startIndex;
 
@@ -153,9 +149,14 @@
   /// the text and doesn't stop at the overflow cutoff.
   final int endIndex;
 
-  /// The index (exclusive) in the text where this line ends, ignoring newline
-  /// characters.
-  final int endIndexWithoutNewlines;
+  /// The number of new line characters at the end of the line.
+  final int trailingNewlines;
+
+  /// The number of spaces at the end of the line.
+  final int trailingSpaces;
+
+  /// The number of space characters in the entire line.
+  final int spaceCount;
 
   /// The full width of the line including all trailing space but not new lines.
   ///
@@ -169,21 +170,17 @@
   /// spaces so [widthWithTrailingSpaces] is more suitable.
   final double widthWithTrailingSpaces;
 
-  /// The list of boxes representing the entire line, possibly across multiple
-  /// spans.
-  final List<RangeBox> boxes;
+  /// The fragments that make up this line.
+  final List<LayoutFragment> fragments;
 
-  /// The number of boxes that are space-only.
-  final int spaceBoxCount;
-
-  /// The number of trailing boxes that are space-only.
-  final int trailingSpaceBoxCount;
+  /// The text direction of this line, which is the same as the paragraph's.
+  final ui.TextDirection textDirection;
 
   /// The text to be rendered on the screen representing this line.
   final String? displayText;
 
-  /// The number of space-only boxes excluding trailing spaces.
-  int get nonTrailingSpaceBoxCount => spaceBoxCount - trailingSpaceBoxCount;
+  /// The number of space characters in the line excluding trailing spaces.
+  int get nonTrailingSpaces => spaceCount - trailingSpaces;
 
   // Convenient getters for line metrics properties.
 
@@ -201,17 +198,25 @@
     return startIndex < this.endIndex && this.startIndex < endIndex;
   }
 
+  String getText(CanvasParagraph paragraph) {
+    final StringBuffer buffer = StringBuffer();
+    for (final LayoutFragment fragment in fragments) {
+      buffer.write(fragment.getText(paragraph));
+    }
+    return buffer.toString();
+  }
+
   @override
   int get hashCode => Object.hash(
         lineMetrics,
-        ellipsis,
         startIndex,
         endIndex,
-        endIndexWithoutNewlines,
+        trailingNewlines,
+        trailingSpaces,
+        spaceCount,
         widthWithTrailingSpaces,
-        boxes,
-        spaceBoxCount,
-        trailingSpaceBoxCount,
+        fragments,
+        textDirection,
         displayText,
       );
 
@@ -225,16 +230,21 @@
     }
     return other is ParagraphLine &&
         other.lineMetrics == lineMetrics &&
-        other.ellipsis == ellipsis &&
         other.startIndex == startIndex &&
         other.endIndex == endIndex &&
-        other.endIndexWithoutNewlines == endIndexWithoutNewlines &&
+        other.trailingNewlines == trailingNewlines &&
+        other.trailingSpaces == trailingSpaces &&
+        other.spaceCount == spaceCount &&
         other.widthWithTrailingSpaces == widthWithTrailingSpaces &&
-        other.boxes == boxes &&
-        other.spaceBoxCount == spaceBoxCount &&
-        other.trailingSpaceBoxCount == trailingSpaceBoxCount &&
+        other.fragments == fragments &&
+        other.textDirection == textDirection &&
         other.displayText == displayText;
   }
+
+  @override
+  String toString() {
+    return '$ParagraphLine($startIndex, $endIndex, $lineMetrics)';
+  }
 }
 
 /// The web implementation of [ui.ParagraphStyle].
@@ -496,6 +506,52 @@
     );
   }
 
+  EngineTextStyle copyWith({
+    ui.Color? color,
+    ui.TextDecoration? decoration,
+    ui.Color? decorationColor,
+    ui.TextDecorationStyle? decorationStyle,
+    double? decorationThickness,
+    ui.FontWeight? fontWeight,
+    ui.FontStyle? fontStyle,
+    ui.TextBaseline? textBaseline,
+    String? fontFamily,
+    List<String>? fontFamilyFallback,
+    double? fontSize,
+    double? letterSpacing,
+    double? wordSpacing,
+    double? height,
+    ui.Locale? locale,
+    ui.Paint? background,
+    ui.Paint? foreground,
+    List<ui.Shadow>? shadows,
+    List<ui.FontFeature>? fontFeatures,
+    List<ui.FontVariation>? fontVariations,
+  }) {
+    return EngineTextStyle(
+      color: color ?? this.color,
+      decoration: decoration ?? this.decoration,
+      decorationColor: decorationColor ?? this.decorationColor,
+      decorationStyle: decorationStyle ?? this.decorationStyle,
+      decorationThickness: decorationThickness ?? this.decorationThickness,
+      fontWeight: fontWeight ?? this.fontWeight,
+      fontStyle: fontStyle ?? this.fontStyle,
+      textBaseline: textBaseline ?? this.textBaseline,
+      fontFamily: fontFamily ?? this.fontFamily,
+      fontFamilyFallback: fontFamilyFallback ?? this.fontFamilyFallback,
+      fontSize: fontSize ?? this.fontSize,
+      letterSpacing: letterSpacing ?? this.letterSpacing,
+      wordSpacing: wordSpacing ?? this.wordSpacing,
+      height: height ?? this.height,
+      locale: locale ?? this.locale,
+      background: background ?? this.background,
+      foreground: foreground ?? this.foreground,
+      shadows: shadows ?? this.shadows,
+      fontFeatures: fontFeatures ?? this.fontFeatures,
+      fontVariations: fontVariations ?? this.fontVariations,
+    );
+  }
+
   @override
   bool operator ==(Object other) {
     if (identical(this, other)) {
diff --git a/lib/web_ui/lib/src/engine/text/text_direction.dart b/lib/web_ui/lib/src/engine/text/text_direction.dart
index 782b0b6..5fe0f33 100644
--- a/lib/web_ui/lib/src/engine/text/text_direction.dart
+++ b/lib/web_ui/lib/src/engine/text/text_direction.dart
@@ -4,9 +4,63 @@
 
 import 'package:ui/ui.dart' as ui;
 
-import 'line_breaker.dart';
+import 'fragmenter.dart';
 import 'unicode_range.dart';
 
+enum FragmentFlow {
+  /// The fragment flows from left to right regardless of its surroundings.
+  ltr,
+  /// The fragment flows from right to left regardless of its surroundings.
+  rtl,
+  /// The fragment flows the same as the previous fragment.
+  ///
+  /// If it's the first fragment in a line, then it flows the same as the
+  /// paragraph direction.
+  ///
+  /// E.g. digits.
+  previous,
+  /// If the previous and next fragments flow in the same direction, then this
+  /// fragment flows in that same direction. Otherwise, it flows the same as the
+  /// paragraph direction.
+  ///
+  /// E.g. spaces, symbols.
+  sandwich,
+}
+
+/// Splits [text] into fragments based on directionality.
+class BidiFragmenter extends TextFragmenter {
+  const BidiFragmenter(super.text);
+
+  @override
+  List<BidiFragment> fragment() {
+    return _computeBidiFragments(text);
+  }
+}
+
+class BidiFragment extends TextFragment {
+  const BidiFragment(super.start, super.end, this.textDirection, this.fragmentFlow);
+
+  final ui.TextDirection? textDirection;
+  final FragmentFlow fragmentFlow;
+
+  @override
+  int get hashCode => Object.hash(start, end, textDirection, fragmentFlow);
+
+  @override
+  bool operator ==(Object other) {
+    return other is BidiFragment &&
+        other.start == start &&
+        other.end == end &&
+        other.textDirection == textDirection &&
+        other.fragmentFlow == fragmentFlow;
+  }
+
+  @override
+  String toString() {
+    return 'BidiFragment($start, $end, $textDirection)';
+  }
+}
+
 // This data was taken from the source code of the Closure library:
 //
 // - https://github.com/google/closure-library/blob/9d24a6c1809a671c2e54c328897ebeae15a6d172/closure/goog/i18n/bidi.js#L203-L234
@@ -50,69 +104,83 @@
   null,
 );
 
-/// Represents a block of text with a certain [ui.TextDirection].
-class DirectionalPosition {
-  const DirectionalPosition(this.lineBreak, this.textDirection, this.isSpaceOnly);
+List<BidiFragment> _computeBidiFragments(String text) {
+  final List<BidiFragment> fragments = <BidiFragment>[];
 
-  final LineBreakResult lineBreak;
+  if (text.isEmpty) {
+    fragments.add(const BidiFragment(0, 0, null, FragmentFlow.previous));
+    return fragments;
+  }
 
-  final ui.TextDirection? textDirection;
+  int fragmentStart = 0;
+  ui.TextDirection? textDirection = _getTextDirection(text, 0);
+  FragmentFlow fragmentFlow = _getFragmentFlow(text, 0);
 
-  final bool isSpaceOnly;
+  for (int i = 1; i < text.length; i++) {
+    final ui.TextDirection? charTextDirection = _getTextDirection(text, i);
 
-  LineBreakType get type => lineBreak.type;
+    if (charTextDirection != textDirection) {
+      // We've reached the end of a text direction fragment.
+      fragments.add(BidiFragment(fragmentStart, i, textDirection, fragmentFlow));
+      fragmentStart = i;
+      textDirection = charTextDirection;
 
-  /// Creates a copy of this [DirectionalPosition] with a different [index].
-  ///
-  /// The type of the returned [DirectionalPosition] is set to
-  /// [LineBreakType.prohibited].
-  DirectionalPosition copyWithIndex(int index) {
-    return DirectionalPosition(
-      LineBreakResult.sameIndex(index, LineBreakType.prohibited),
-      textDirection,
-      isSpaceOnly,
-    );
+      fragmentFlow = _getFragmentFlow(text, i);
+    } else {
+      // This code handles the case of a sequence of digits followed by a sequence
+      // of LTR characters with no space in between.
+      if (fragmentFlow == FragmentFlow.previous) {
+        fragmentFlow = _getFragmentFlow(text, i);
+      }
+    }
+  }
+
+  fragments.add(BidiFragment(fragmentStart, text.length, textDirection, fragmentFlow));
+  return fragments;
+}
+
+ui.TextDirection? _getTextDirection(String text, int i) {
+  final int codePoint = getCodePoint(text, i)!;
+  if (_isDigit(codePoint) || _isMashriqiDigit(codePoint)) {
+    // A sequence of regular digits or Mashriqi digits always goes from left to
+    // regardless of their fragment flow direction.
+    return ui.TextDirection.ltr;
+  }
+
+  final ui.TextDirection? textDirection = _textDirectionLookup.findForChar(codePoint);
+  if (textDirection != null) {
+    return textDirection;
+  }
+
+  return null;
+}
+
+FragmentFlow _getFragmentFlow(String text, int i) {
+  final int codePoint = getCodePoint(text, i)!;
+  if (_isDigit(codePoint)) {
+    return FragmentFlow.previous;
+  }
+  if (_isMashriqiDigit(codePoint)) {
+    return FragmentFlow.rtl;
+  }
+
+  final ui.TextDirection? textDirection = _textDirectionLookup.findForChar(codePoint);
+  switch (textDirection) {
+    case ui.TextDirection.ltr:
+      return FragmentFlow.ltr;
+
+    case ui.TextDirection.rtl:
+      return FragmentFlow.rtl;
+
+    case null:
+      return FragmentFlow.sandwich;
   }
 }
 
-/// Finds the end of the directional block of text that starts at [start] up
-/// until [end].
-///
-/// If the block goes beyond [end], the part after [end] is ignored.
-DirectionalPosition getDirectionalBlockEnd(
-  String text,
-  LineBreakResult start,
-  LineBreakResult end,
-) {
-  if (start.index == end.index) {
-    return DirectionalPosition(end, null, false);
-  }
+bool _isDigit(int codePoint) {
+  return codePoint >= kChar_0 && codePoint <= kChar_9;
+}
 
-  // Check if we are in a space-only block.
-  if (start.index == end.indexWithoutTrailingSpaces) {
-    return DirectionalPosition(end, null, true);
-  }
-
-  final ui.TextDirection? blockDirection = _textDirectionLookup.find(text, start.index);
-  int i = start.index + 1;
-
-  while (i < end.indexWithoutTrailingSpaces) {
-    final ui.TextDirection? direction = _textDirectionLookup.find(text, i);
-    if (direction != blockDirection) {
-      // Reached the next block.
-      break;
-    }
-    i++;
-  }
-
-  if (i == end.indexWithoutTrailingNewlines) {
-    // If all that remains before [end] is new lines, let's include them in the
-    // block.
-    return DirectionalPosition(end, blockDirection, false);
-  }
-  return DirectionalPosition(
-    LineBreakResult.sameIndex(i, LineBreakType.prohibited),
-    blockDirection,
-    false,
-  );
+bool _isMashriqiDigit(int codePoint) {
+  return codePoint >= kMashriqi_0 && codePoint <= kMashriqi_9;
 }
diff --git a/lib/web_ui/lib/src/engine/text/unicode_range.dart b/lib/web_ui/lib/src/engine/text/unicode_range.dart
index 81d14ed..0e280cc 100644
--- a/lib/web_ui/lib/src/engine/text/unicode_range.dart
+++ b/lib/web_ui/lib/src/engine/text/unicode_range.dart
@@ -3,12 +3,14 @@
 // found in the LICENSE file.
 
 const int kChar_0 = 48;
-const int kChar_9 = 57;
+const int kChar_9 = kChar_0 + 9;
 const int kChar_A = 65;
 const int kChar_Z = 90;
 const int kChar_a = 97;
 const int kChar_z = 122;
 const int kCharBang = 33;
+const int kMashriqi_0 = 0x660;
+const int kMashriqi_9 = kMashriqi_0 + 9;
 
 enum _ComparisonResult {
   inside,
diff --git a/lib/web_ui/test/html/bitmap_canvas_golden_test.dart b/lib/web_ui/test/html/bitmap_canvas_golden_test.dart
index 211bf44..656bd30 100644
--- a/lib/web_ui/test/html/bitmap_canvas_golden_test.dart
+++ b/lib/web_ui/test/html/bitmap_canvas_golden_test.dart
@@ -10,6 +10,7 @@
 import 'package:ui/ui.dart';
 
 import 'package:web_engine_tester/golden_tester.dart';
+import 'paragraph/helper.dart';
 import 'screenshot.dart';
 
 void main() {
@@ -225,8 +226,13 @@
       ..lineTo(-r, 0)
       ..close()).shift(const Offset(250, 250));
 
+    final SurfacePaintData borderPaint = SurfacePaintData()
+      ..color = black
+      ..style = PaintingStyle.stroke;
+
     canvas.drawPath(path, pathPaint);
     canvas.drawParagraph(paragraph, const Offset(180, 50));
+    canvas.drawRect(Rect.fromLTWH(180, 50, paragraph.width, paragraph.height), borderPaint);
 
     expect(
       canvas.rootElement.querySelectorAll('flt-paragraph').map<String?>((DomElement e) => e.text).toList(),
diff --git a/lib/web_ui/test/html/paragraph/helper.dart b/lib/web_ui/test/html/paragraph/helper.dart
index 05f85e6..fde2dd6 100644
--- a/lib/web_ui/test/html/paragraph/helper.dart
+++ b/lib/web_ui/test/html/paragraph/helper.dart
@@ -6,6 +6,22 @@
 import 'package:ui/ui.dart';
 import 'package:web_engine_tester/golden_tester.dart';
 
+const LineBreakType prohibited = LineBreakType.prohibited;
+const LineBreakType opportunity = LineBreakType.opportunity;
+const LineBreakType mandatory = LineBreakType.mandatory;
+const LineBreakType endOfText = LineBreakType.endOfText;
+
+const TextDirection ltr = TextDirection.ltr;
+const TextDirection rtl = TextDirection.rtl;
+
+const FragmentFlow ffLtr = FragmentFlow.ltr;
+const FragmentFlow ffRtl = FragmentFlow.rtl;
+const FragmentFlow ffPrevious = FragmentFlow.previous;
+const FragmentFlow ffSandwich = FragmentFlow.sandwich;
+
+const String rtlWord1 = 'واحدة';
+const String rtlWord2 = 'ثنتان';
+
 const Color white = Color(0xFFFFFFFF);
 const Color black = Color(0xFF000000);
 const Color red = Color(0xFFFF0000);
diff --git a/lib/web_ui/test/html/paragraph/overflow_golden_test.dart b/lib/web_ui/test/html/paragraph/overflow_golden_test.dart
index ff39d66..9f1e8bc 100644
--- a/lib/web_ui/test/html/paragraph/overflow_golden_test.dart
+++ b/lib/web_ui/test/html/paragraph/overflow_golden_test.dart
@@ -28,6 +28,9 @@
     const double fontSize = 22.0;
     const double width = 126.0;
     const double padding = 20.0;
+    final SurfacePaintData borderPaint = SurfacePaintData()
+      ..color = black
+      ..style = PaintingStyle.stroke;
 
     paragraph = rich(
       EngineParagraphStyle(fontFamily: 'Roboto', fontSize: fontSize, ellipsis: '...'),
@@ -39,6 +42,7 @@
       },
     )..layout(constrain(width));
     canvas.drawParagraph(paragraph, offset);
+    canvas.drawRect(Rect.fromLTWH(offset.dx, offset.dy, width, paragraph.height), borderPaint);
     offset = offset.translate(0, paragraph.height + padding);
 
     paragraph = rich(
@@ -53,6 +57,7 @@
       },
     )..layout(constrain(width));
     canvas.drawParagraph(paragraph, offset);
+    canvas.drawRect(Rect.fromLTWH(offset.dx, offset.dy, width, paragraph.height), borderPaint);
     offset = offset.translate(0, paragraph.height + padding);
 
     paragraph = rich(
@@ -83,6 +88,7 @@
       },
     )..layout(constrain(width));
     canvas.drawParagraph(paragraph, offset);
+    canvas.drawRect(Rect.fromLTWH(offset.dx, offset.dy, width, paragraph.height), borderPaint);
     offset = offset.translate(0, paragraph.height + padding);
 
     paragraph = rich(
@@ -91,9 +97,9 @@
         builder.pushStyle(EngineTextStyle.only(color: blue));
         builder.addText('Lorem');
         builder.pushStyle(EngineTextStyle.only(color: green));
-        builder.addText('ipsum');
+        builder.addText('ipsu');
         builder.pushStyle(EngineTextStyle.only(color: red));
-        builder.addText('dolor');
+        builder.addText('mdolor');
         builder.pushStyle(EngineTextStyle.only(color: black));
         builder.addText('sit');
         builder.pushStyle(EngineTextStyle.only(color: blue));
@@ -103,6 +109,7 @@
       },
     )..layout(constrain(width));
     canvas.drawParagraph(paragraph, offset);
+    canvas.drawRect(Rect.fromLTWH(offset.dx, offset.dy, width, paragraph.height), borderPaint);
     offset = offset.translate(0, paragraph.height + padding);
   }
 
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 b898f5a..2a7040f 100644
--- a/lib/web_ui/test/text/canvas_paragraph_builder_test.dart
+++ b/lib/web_ui/test/text/canvas_paragraph_builder_test.dart
@@ -7,6 +7,8 @@
 import 'package:ui/src/engine.dart';
 import 'package:ui/ui.dart';
 
+import '../html/paragraph/helper.dart';
+
 bool get isIosSafari =>
     browserEngine == BrowserEngine.webkit &&
     operatingSystem == OperatingSystem.iOs;
@@ -35,6 +37,28 @@
 Future<void> testMain() async {
   await initializeTestFlutterViewEmbedder();
 
+  test('empty paragraph', () {
+    final CanvasParagraph paragraph1 = rich(
+      EngineParagraphStyle(),
+      (CanvasParagraphBuilder builder) {},
+    );
+    expect(paragraph1.plainText, '');
+    expect(paragraph1.spans, hasLength(1));
+    expect(paragraph1.spans.single.start, 0);
+    expect(paragraph1.spans.single.end, 0);
+
+    final CanvasParagraph paragraph2 = rich(
+      EngineParagraphStyle(),
+      (CanvasParagraphBuilder builder) {
+        builder.addText('');
+      },
+    );
+    expect(paragraph2.plainText, '');
+    expect(paragraph2.spans, hasLength(1));
+    expect(paragraph2.spans.single.start, 0);
+    expect(paragraph2.spans.single.end, 0);
+  });
+
   test('Builds a text-only canvas paragraph', () {
     final EngineParagraphStyle style = EngineParagraphStyle(fontSize: 13.0);
     final CanvasParagraphBuilder builder = CanvasParagraphBuilder(style);
@@ -142,7 +166,10 @@
       paragraph,
       '<flt-paragraph style="${paragraphStyle()}">'
       '<flt-span style="${spanStyle(top: 0, left: 0, width: 14*4)}">'
-      'Hell...'
+      'Hell'
+      '</flt-span>'
+      '<flt-span style="${spanStyle(top: 0, left: 14*4, width: 14*3)}">'
+      '...'
       '</flt-span>'
       '</flt-paragraph>',
       ignorePositions: !isBlink,
@@ -356,10 +383,10 @@
       '<flt-span style="${spanStyle(top: 13, left: 0, width: 13*6, fontSize: 13)}">'
       'Second'
       '</flt-span>'
-      '<flt-span style="${spanStyle(top: 13, left: 78, width: 13*1, fontSize: 13)}">'
+      '<flt-span style="${spanStyle(top: 13, left: 13*6, width: 13*1, fontSize: 13)}">'
       ' '
       '</flt-span>'
-      '<flt-span style="${spanStyle(top: 13, left: 91, width: 13*13, fontSize: 13, fontStyle: 'italic')}">'
+      '<flt-span style="${spanStyle(top: 13, left: 13*7, width: 13*13, fontSize: 13, fontStyle: 'italic')}">'
       'ThirdLongLine'
       '</flt-span>'
       '</flt-paragraph>',
@@ -377,8 +404,7 @@
       '<flt-span style="${spanStyle(top: 13, left: 0, width: 13*6, fontSize: 13)}">'
       'Second'
       '</flt-span>'
-      // Trailing space.
-      '<flt-span style="${spanStyle(top: 13, left: 78, width: 0, fontSize: 13)}">'
+      '<flt-span style="${spanStyle(top: 13, left: 13*6, width: 0, fontSize: 13)}">'
       ' '
       '</flt-span>'
       '<flt-span style="${spanStyle(top: 26, left: 0, width: 13*13, fontSize: 13, fontStyle: 'italic')}">'
diff --git a/lib/web_ui/test/text/canvas_paragraph_test.dart b/lib/web_ui/test/text/canvas_paragraph_test.dart
index 6a7caaa..c661016 100644
--- a/lib/web_ui/test/text/canvas_paragraph_test.dart
+++ b/lib/web_ui/test/text/canvas_paragraph_test.dart
@@ -370,7 +370,7 @@
       );
     });
 
-    test('pops boxes when segments are popped', () {
+    test('reverts to last line break opportunity', () {
       final CanvasParagraph paragraph = rich(ahemStyle, (ui.ParagraphBuilder builder) {
         // Lines:
         //   "AAA "
@@ -383,6 +383,10 @@
         builder.addText('DD');
       });
 
+      String getTextForFragment(LayoutFragment fragment) {
+        return paragraph.plainText.substring(fragment.start, fragment.end);
+      }
+
       // The layout algorithm will keep appending segments to the line builder
       // until it reaches: "AAA B_". At that point, it'll try to add the "C" but
       // realizes there isn't enough width in the line. Since the line already
@@ -398,29 +402,32 @@
       final ParagraphLine firstLine = paragraph.lines[0];
       final ParagraphLine secondLine = paragraph.lines[1];
 
-      // There should be no "B" in the first line's boxes.
-      expect(firstLine.boxes, hasLength(2));
+      // There should be no "B" in the first line's fragments.
+      expect(firstLine.fragments, hasLength(2));
 
-      expect((firstLine.boxes[0] as SpanBox).toText(), 'AAA');
-      expect((firstLine.boxes[0] as SpanBox).left, 0.0);
+      expect(getTextForFragment(firstLine.fragments[0]), 'AAA');
+      expect(firstLine.fragments[0].left, 0.0);
 
-      expect((firstLine.boxes[1] as SpanBox).toText(), ' ');
-      expect((firstLine.boxes[1] as SpanBox).left, 30.0);
+      expect(getTextForFragment(firstLine.fragments[1]), ' ');
+      expect(firstLine.fragments[1].left, 30.0);
 
-      // Make sure the second line isn't missing any boxes.
-      expect(secondLine.boxes, hasLength(4));
+      // Make sure the second line isn't missing any fragments.
+      expect(secondLine.fragments, hasLength(5));
 
-      expect((secondLine.boxes[0] as SpanBox).toText(), 'B');
-      expect((secondLine.boxes[0] as SpanBox).left, 0.0);
+      expect(getTextForFragment(secondLine.fragments[0]), 'B');
+      expect(secondLine.fragments[0].left, 0.0);
 
-      expect((secondLine.boxes[1] as SpanBox).toText(), '_C');
-      expect((secondLine.boxes[1] as SpanBox).left, 10.0);
+      expect(getTextForFragment(secondLine.fragments[1]), '_');
+      expect(secondLine.fragments[1].left, 10.0);
 
-      expect((secondLine.boxes[2] as SpanBox).toText(), ' ');
-      expect((secondLine.boxes[2] as SpanBox).left, 30.0);
+      expect(getTextForFragment(secondLine.fragments[2]), 'C');
+      expect(secondLine.fragments[2].left, 20.0);
 
-      expect((secondLine.boxes[3] as SpanBox).toText(), 'DD');
-      expect((secondLine.boxes[3] as SpanBox).left, 40.0);
+      expect(getTextForFragment(secondLine.fragments[3]), ' ');
+      expect(secondLine.fragments[3].left, 30.0);
+
+      expect(getTextForFragment(secondLine.fragments[4]), 'DD');
+      expect(secondLine.fragments[4].left, 40.0);
     });
   });
 
diff --git a/lib/web_ui/test/text/layout_fragmenter_test.dart b/lib/web_ui/test/text/layout_fragmenter_test.dart
new file mode 100644
index 0000000..0c315ce
--- /dev/null
+++ b/lib/web_ui/test/text/layout_fragmenter_test.dart
@@ -0,0 +1,293 @@
+// 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.
+
+import 'package:test/bootstrap/browser.dart';
+import 'package:test/test.dart';
+import 'package:ui/src/engine.dart';
+import 'package:ui/ui.dart';
+
+import '../html/paragraph/helper.dart';
+
+final EngineTextStyle defaultStyle = EngineTextStyle.only(
+  color: const Color(0xFFFF0000),
+  fontFamily: FlutterViewEmbedder.defaultFontFamily,
+  fontSize: FlutterViewEmbedder.defaultFontSize,
+);
+final EngineTextStyle style1 = defaultStyle.copyWith(fontSize: 20);
+final EngineTextStyle style2 = defaultStyle.copyWith(color: blue);
+final EngineTextStyle style3 = defaultStyle.copyWith(fontFamily: 'Roboto');
+
+void main() {
+  internalBootstrapBrowserTest(() => testMain);
+}
+
+Future<void> testMain() async {
+  group('$LayoutFragmenter', () {
+    test('empty paragraph', () {
+      final CanvasParagraph paragraph1 = rich(
+        EngineParagraphStyle(),
+        (CanvasParagraphBuilder builder) {},
+      );
+      expect(split(paragraph1), <_Fragment>[
+        _Fragment('', endOfText, null, ffPrevious, defaultStyle),
+      ]);
+
+      final CanvasParagraph paragraph2 = rich(
+        EngineParagraphStyle(),
+        (CanvasParagraphBuilder builder) {
+          builder.addText('');
+        },
+      );
+      expect(split(paragraph2), <_Fragment>[
+        _Fragment('', endOfText, null, ffPrevious, defaultStyle),
+      ]);
+
+      final CanvasParagraph paragraph3 = rich(
+        EngineParagraphStyle(),
+        (CanvasParagraphBuilder builder) {
+          builder.pushStyle(style1);
+          builder.addText('');
+        },
+      );
+      expect(split(paragraph3), <_Fragment>[
+        _Fragment('', endOfText, null, ffPrevious, style1),
+      ]);
+    });
+
+    test('single span', () {
+      final CanvasParagraph paragraph =
+          plain(EngineParagraphStyle(), 'Lorem 12 $rtlWord1   ipsum34');
+      expect(split(paragraph), <_Fragment>[
+        _Fragment('Lorem', prohibited, ltr, ffLtr, defaultStyle),
+        _Fragment(' ', opportunity, null, ffSandwich, defaultStyle, sp: 1),
+        _Fragment('12', prohibited, ltr, ffPrevious, defaultStyle),
+        _Fragment(' ', opportunity, null, ffSandwich, defaultStyle, sp: 1),
+        _Fragment(rtlWord1, prohibited, rtl, ffRtl, defaultStyle),
+        _Fragment('   ', opportunity, null, ffSandwich, defaultStyle, sp: 3),
+        _Fragment('ipsum34', endOfText, ltr, ffLtr, defaultStyle),
+      ]);
+    });
+
+    test('multi span', () {
+      final CanvasParagraph paragraph = rich(
+        EngineParagraphStyle(),
+        (CanvasParagraphBuilder builder) {
+          builder.pushStyle(style1);
+          builder.addText('Lorem');
+          builder.pop();
+          builder.pushStyle(style2);
+          builder.addText(' ipsum 12 ');
+          builder.pop();
+          builder.pushStyle(style3);
+          builder.addText(' $rtlWord1 foo.');
+          builder.pop();
+        },
+      );
+
+      expect(split(paragraph), <_Fragment>[
+        _Fragment('Lorem', prohibited, ltr, ffLtr, style1),
+        _Fragment(' ', opportunity, null, ffSandwich, style2, sp: 1),
+        _Fragment('ipsum', prohibited, ltr, ffLtr, style2),
+        _Fragment(' ', opportunity, null, ffSandwich, style2, sp: 1),
+        _Fragment('12', prohibited, ltr, ffPrevious, style2),
+        _Fragment(' ', prohibited, null, ffSandwich, style2, sp: 1),
+        _Fragment(' ', opportunity, null, ffSandwich, style3, sp: 1),
+        _Fragment(rtlWord1, prohibited, rtl, ffRtl, style3),
+        _Fragment(' ', opportunity, null, ffSandwich, style3, sp: 1),
+        _Fragment('foo', prohibited, ltr, ffLtr, style3),
+        _Fragment('.', endOfText, null, ffSandwich, style3),
+      ]);
+    });
+
+    test('new lines', () {
+      final CanvasParagraph paragraph = rich(
+        EngineParagraphStyle(),
+        (CanvasParagraphBuilder builder) {
+          builder.pushStyle(style1);
+          builder.addText('Lor\nem \n');
+          builder.pop();
+          builder.pushStyle(style2);
+          builder.addText(' \n  ipsum 12 ');
+          builder.pop();
+          builder.pushStyle(style3);
+          builder.addText(' $rtlWord1 fo');
+          builder.pop();
+          builder.pushStyle(style1);
+          builder.addText('o.');
+          builder.pop();
+        },
+      );
+
+      expect(split(paragraph), <_Fragment>[
+        _Fragment('Lor', prohibited, ltr, ffLtr, style1),
+        _Fragment('\n', mandatory, null, ffSandwich, style1, nl: 1, sp: 1),
+        _Fragment('em', prohibited, ltr, ffLtr, style1),
+        _Fragment(' \n', mandatory, null, ffSandwich, style1, nl: 1, sp: 2),
+        _Fragment(' \n', mandatory, null, ffSandwich, style2, nl: 1, sp: 2),
+        _Fragment('  ', opportunity, null, ffSandwich, style2, sp: 2),
+        _Fragment('ipsum', prohibited, ltr, ffLtr, style2),
+        _Fragment(' ', opportunity, null, ffSandwich, style2, sp: 1),
+        _Fragment('12', prohibited, ltr, ffPrevious, style2),
+        _Fragment(' ', prohibited, null, ffSandwich, style2, sp: 1),
+        _Fragment(' ', opportunity, null, ffSandwich, style3, sp: 1),
+        _Fragment(rtlWord1, prohibited, rtl, ffRtl, style3),
+        _Fragment(' ', opportunity, null, ffSandwich, style3, sp: 1),
+        _Fragment('fo', prohibited, ltr, ffLtr, style3),
+        _Fragment('o', prohibited, ltr, ffLtr, style1),
+        _Fragment('.', endOfText, null, ffSandwich, style1),
+      ]);
+    });
+
+    test('last line is empty', () {
+      final CanvasParagraph paragraph = rich(
+        EngineParagraphStyle(),
+        (CanvasParagraphBuilder builder) {
+          builder.pushStyle(style1);
+          builder.addText('Lorem \n');
+          builder.pop();
+          builder.pushStyle(style2);
+          builder.addText(' \n  ipsum \n');
+          builder.pop();
+        },
+      );
+
+      expect(split(paragraph), <_Fragment>[
+        _Fragment('Lorem', prohibited, ltr, ffLtr, style1),
+        _Fragment(' \n', mandatory, null, ffSandwich, style1, nl: 1, sp: 2),
+        _Fragment(' \n', mandatory, null, ffSandwich, style2, nl: 1, sp: 2),
+        _Fragment('  ', opportunity, null, ffSandwich, style2, sp: 2),
+        _Fragment('ipsum', prohibited, ltr, ffLtr, style2),
+        _Fragment(' \n', mandatory, null, ffSandwich, style2, nl: 1, sp: 2),
+        _Fragment('', endOfText, null, ffSandwich, style2),
+      ]);
+    });
+
+    test('space-only spans', () {
+      final CanvasParagraph paragraph = rich(
+        EngineParagraphStyle(),
+        (CanvasParagraphBuilder builder) {
+          builder.addText('Lorem ');
+          builder.pushStyle(style1);
+          builder.addText('   ');
+          builder.pop();
+          builder.pushStyle(style2);
+          builder.addText('  ');
+          builder.pop();
+          builder.addText('ipsum');
+        },
+      );
+
+      expect(split(paragraph), <_Fragment>[
+        _Fragment('Lorem', prohibited, ltr, ffLtr, defaultStyle),
+        _Fragment(' ', prohibited, null, ffSandwich, defaultStyle, sp: 1),
+        _Fragment('   ', prohibited, null, ffSandwich, style1, sp: 3),
+        _Fragment('  ', opportunity, null, ffSandwich, style2, sp: 2),
+        _Fragment('ipsum', endOfText, ltr, ffLtr, defaultStyle),
+      ]);
+    });
+
+    test('placeholders', () {
+      final CanvasParagraph paragraph = rich(
+        EngineParagraphStyle(),
+        (CanvasParagraphBuilder builder) {
+          builder.pushStyle(style1);
+          builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+          builder.addText('Lorem');
+          builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+          builder.addText('ipsum\n');
+          builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+          builder.pop();
+          builder.pushStyle(style2);
+          builder.addText('$rtlWord1 ');
+          builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+          builder.addText('\nsit');
+          builder.pop();
+          builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+        },
+      );
+
+      final String placeholderChar = String.fromCharCode(0xFFFC);
+
+      expect(split(paragraph), <_Fragment>[
+        _Fragment(placeholderChar, opportunity, ltr, ffLtr, null),
+        _Fragment('Lorem', opportunity, ltr, ffLtr, style1),
+        _Fragment(placeholderChar, opportunity, ltr, ffLtr, null),
+        _Fragment('ipsum', prohibited, ltr, ffLtr, style1),
+        _Fragment('\n', mandatory, null, ffSandwich, style1, nl: 1, sp: 1),
+        _Fragment(placeholderChar, opportunity, ltr, ffLtr, null),
+        _Fragment(rtlWord1, prohibited, rtl, ffRtl, style2),
+        _Fragment(' ', opportunity, null, ffSandwich, style2, sp: 1),
+        _Fragment(placeholderChar, prohibited, ltr, ffLtr, null),
+        _Fragment('\n', mandatory, null, ffSandwich, style2, nl: 1, sp: 1),
+        _Fragment('sit', opportunity, ltr, ffLtr, style2),
+        _Fragment(placeholderChar, endOfText, ltr, ffLtr, null),
+      ]);
+    });
+  });
+}
+
+/// Holds information about how a fragment.
+class _Fragment {
+  _Fragment(this.text, this.type, this.textDirection, this.fragmentFlow, this.style, {
+    this.nl = 0,
+    this.sp = 0,
+  });
+
+  factory _Fragment._fromLayoutFragment(String text, LayoutFragment layoutFragment) {
+    final ParagraphSpan span = layoutFragment.span;
+    return _Fragment(
+      text.substring(layoutFragment.start, layoutFragment.end),
+      layoutFragment.type,
+      layoutFragment.textDirection,
+      layoutFragment.fragmentFlow,
+      span is FlatTextSpan ? span.style : null,
+      nl: layoutFragment.trailingNewlines,
+      sp: layoutFragment.trailingSpaces,
+    );
+  }
+
+  final String text;
+  final LineBreakType type;
+  final TextDirection? textDirection;
+  final FragmentFlow fragmentFlow;
+  final EngineTextStyle? style;
+
+  /// The number of trailing new line characters.
+  final int nl;
+
+  /// The number of trailing spaces.
+  final int sp;
+
+  @override
+  int get hashCode => Object.hash(text, type, textDirection, fragmentFlow, style, nl, sp);
+
+  @override
+  bool operator ==(Object other) {
+    return other is _Fragment &&
+        other.text == text &&
+        other.type == type &&
+        other.textDirection == textDirection &&
+        other.fragmentFlow == fragmentFlow &&
+        other.style == style &&
+        other.nl == nl &&
+        other.sp == sp;
+  }
+
+  @override
+  String toString() {
+    return '"$text" ($type, $textDirection, $fragmentFlow, nl: $nl, sp: $sp)';
+  }
+}
+
+List<_Fragment> split(CanvasParagraph paragraph) {
+  return <_Fragment>[
+    for (final LayoutFragment layoutFragment
+        in computeLayoutFragments(paragraph))
+      _Fragment._fromLayoutFragment(paragraph.plainText, layoutFragment)
+  ];
+}
+
+List<LayoutFragment> computeLayoutFragments(CanvasParagraph paragraph) {
+  return LayoutFragmenter(paragraph.plainText, paragraph.spans).fragment();
+}
diff --git a/lib/web_ui/test/text/layout_service_helper.dart b/lib/web_ui/test/text/layout_service_helper.dart
index 9fef792..e516d19 100644
--- a/lib/web_ui/test/text/layout_service_helper.dart
+++ b/lib/web_ui/test/text/layout_service_helper.dart
@@ -10,7 +10,6 @@
   String? displayText,
   int? startIndex,
   int? endIndex, {
-  int?  endIndexWithoutNewlines,
   bool? hardBreak,
   double? height,
   double? width,
@@ -22,7 +21,6 @@
     displayText: displayText,
     startIndex: startIndex,
     endIndex: endIndex,
-    endIndexWithoutNewlines: endIndexWithoutNewlines,
     hardBreak: hardBreak,
     height: height,
     width: width,
@@ -33,7 +31,6 @@
 }
 
 void expectLines(CanvasParagraph paragraph, List<TestLine> expectedLines) {
-  final String text = paragraph.toPlainText();
   final List<ParagraphLine> lines = paragraph.lines;
   expect(lines, hasLength(expectedLines.length));
   for (int i = 0; i < lines.length; i++) {
@@ -46,11 +43,7 @@
       reason: 'line #$i had the wrong `lineNumber`. Expected: $i. Actual: ${line.lineNumber}',
     );
     if (expectedLine.displayText != null) {
-      String displayText =
-          text.substring(line.startIndex, line.endIndexWithoutNewlines);
-      if (line.ellipsis != null) {
-        displayText += line.ellipsis!;
-      }
+      final String displayText = line.getText(paragraph);
       expect(
         displayText,
         expectedLine.displayText,
@@ -74,14 +67,6 @@
             'line #$i had a different `endIndex` value: "${line.endIndex}" vs. "${expectedLine.endIndex}"',
       );
     }
-    if (expectedLine.endIndexWithoutNewlines != null) {
-      expect(
-        line.endIndexWithoutNewlines,
-        expectedLine.endIndexWithoutNewlines,
-        reason:
-            'line #$i had a different `endIndexWithoutNewlines` value: "${line.endIndexWithoutNewlines}" vs. "${expectedLine.endIndexWithoutNewlines}"',
-      );
-    }
     if (expectedLine.hardBreak != null) {
       expect(
         line.hardBreak,
@@ -130,7 +115,6 @@
     this.displayText,
     this.startIndex,
     this.endIndex,
-    this.endIndexWithoutNewlines,
     this.hardBreak,
     this.height,
     this.width,
@@ -142,7 +126,6 @@
   final String? displayText;
   final int? startIndex;
   final int? endIndex;
-  final int? endIndexWithoutNewlines;
   final bool? hardBreak;
   final double? height;
   final double? width;
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 9cdb9a5..777c80c 100644
--- a/lib/web_ui/test/text/layout_service_plain_test.dart
+++ b/lib/web_ui/test/text/layout_service_plain_test.dart
@@ -46,8 +46,10 @@
 
     expect(paragraph.maxIntrinsicWidth, 0);
     expect(paragraph.minIntrinsicWidth, 0);
-    expect(paragraph.height, 0);
-    expect(paragraph.computeLineMetrics(), isEmpty);
+    expect(paragraph.height, 10);
+    expectLines(paragraph, <TestLine>[
+      l('', 0, 0, width: 0.0, height: 10.0, baseline: 8.0),
+    ]);
   });
 
   test('preserves whitespace when measuring', () {
@@ -422,7 +424,7 @@
     expect(longText.maxIntrinsicWidth, 480);
     expect(longText.height, 10);
     expectLines(longText, <TestLine>[
-      l('AA...', 0, 2, hardBreak: false, width: 50.0, left: 0.0),
+      l('AA...', 0, 2, hardBreak: true, width: 50.0, left: 0.0),
     ]);
 
     // The short prefix should make the text break into two lines, but the
@@ -436,7 +438,7 @@
     expect(longTextShortPrefix.height, 20);
     expectLines(longTextShortPrefix, <TestLine>[
       l('AAA', 0, 4, hardBreak: true, width: 30.0, left: 0.0),
-      l('AA...', 4, 6, hardBreak: false, width: 50.0, left: 0.0),
+      l('AA...', 4, 6, hardBreak: true, width: 50.0, left: 0.0),
     ]);
 
     // Constraints only enough to fit "AA" with the ellipsis, but not the
@@ -447,7 +449,7 @@
     expect(trailingSpace.maxIntrinsicWidth, 60);
     expect(trailingSpace.height, 10);
     expectLines(trailingSpace, <TestLine>[
-      l('AA...', 0, 2, hardBreak: false, width: 50.0, left: 0.0),
+      l('AA...', 0, 2, hardBreak: true, width: 50.0, left: 0.0),
     ]);
 
     // Tiny constraints.
@@ -457,7 +459,7 @@
     expect(paragraph.maxIntrinsicWidth, 40);
     expect(paragraph.height, 10);
     expectLines(paragraph, <TestLine>[
-      l('...', 0, 0, hardBreak: false, width: 30.0, left: 0.0),
+      l('...', 0, 0, hardBreak: true, width: 30.0, left: 0.0),
     ]);
 
     // Tinier constraints (not enough for the ellipsis).
@@ -471,7 +473,7 @@
     //   l('.', 0, 0, hardBreak: false, width: 10.0, left: 0.0),
     // ]);
     expectLines(paragraph, <TestLine>[
-      l('...', 0, 0, hardBreak: false, width: 30.0, left: 0.0),
+      l('...', 0, 0, hardBreak: true, width: 30.0, left: 0.0),
     ]);
   });
 
@@ -550,14 +552,14 @@
     paragraph = plain(onelineStyle, 'abcd efg')..layout(constrain(60.0));
     expect(paragraph.height, 10);
     expectLines(paragraph, <TestLine>[
-      l('abc...', 0, 3, hardBreak: false, width: 60.0, left: 0.0),
+      l('abc...', 0, 3, hardBreak: true, width: 60.0, left: 0.0),
     ]);
 
     // Another simple overflow case.
     paragraph = plain(onelineStyle, 'a bcde fgh')..layout(constrain(60.0));
     expect(paragraph.height, 10);
     expectLines(paragraph, <TestLine>[
-      l('a b...', 0, 3, hardBreak: false, width: 60.0, left: 0.0),
+      l('a b...', 0, 3, hardBreak: true, width: 60.0, left: 0.0),
     ]);
 
     // The ellipsis is supposed to go on the second line, but because the
@@ -574,7 +576,7 @@
     expect(paragraph.height, 20);
     expectLines(paragraph, <TestLine>[
       l('abcd ', 0, 5, hardBreak: false, width: 40.0, left: 0.0),
-      l('efg...', 5, 8, hardBreak: false, width: 60.0, left: 0.0),
+      l('efg...', 5, 8, hardBreak: true, width: 60.0, left: 0.0),
     ]);
 
     // Even if the second line can be broken, we don't break it, we just
@@ -584,7 +586,7 @@
     expect(paragraph.height, 20);
     expectLines(paragraph, <TestLine>[
       l('abcde ', 0, 6, hardBreak: false, width: 50.0, left: 0.0),
-      l('f g...', 6, 9, hardBreak: false, width: 60.0, left: 0.0),
+      l('f g...', 6, 9, hardBreak: true, width: 60.0, left: 0.0),
     ]);
 
     // First line overflows but second line doesn't.
@@ -601,7 +603,7 @@
     expect(paragraph.height, 20);
     expectLines(paragraph, <TestLine>[
       l('abcdef', 0, 6, hardBreak: false, width: 60.0, left: 0.0),
-      l('g h...', 6, 9, hardBreak: false, width: 60.0, left: 0.0),
+      l('g h...', 6, 9, hardBreak: true, width: 60.0, left: 0.0),
     ]);
   });
 
diff --git a/lib/web_ui/test/text/layout_service_rich_test.dart b/lib/web_ui/test/text/layout_service_rich_test.dart
index 28d50bc..f1d3816 100644
--- a/lib/web_ui/test/text/layout_service_rich_test.dart
+++ b/lib/web_ui/test/text/layout_service_rich_test.dart
@@ -11,6 +11,8 @@
 import '../html/paragraph/helper.dart';
 import 'layout_service_helper.dart';
 
+final String placeholderChar = String.fromCharCode(0xFFFC);
+
 const ui.Color white = ui.Color(0xFFFFFFFF);
 const ui.Color black = ui.Color(0xFF000000);
 const ui.Color red = ui.Color(0xFFFF0000);
@@ -166,7 +168,7 @@
     expect(paragraph.minIntrinsicWidth, 300.0);
     expect(paragraph.height, 50.0);
     expectLines(paragraph, <TestLine>[
-      l('', 0, 0, hardBreak: true, width: 300.0, left: 100.0),
+      l(placeholderChar, 0, 1, hardBreak: true, width: 300.0, left: 100.0),
     ]);
   });
 
@@ -185,7 +187,7 @@
     expect(paragraph.minIntrinsicWidth, 300.0);
     expect(paragraph.height, 50.0);
     expectLines(paragraph, <TestLine>[
-      l('abcd', 0, 4, hardBreak: true, width: 340.0, left: 30.0),
+      l('abcd$placeholderChar', 0, 5, hardBreak: true, width: 340.0, left: 30.0),
     ]);
   });
 
@@ -207,8 +209,8 @@
     expect(paragraph.height, 10.0 + 40.0 + 10.0);
     expectLines(paragraph, <TestLine>[
       l('Lorem', 0, 6, hardBreak: true, width: 50.0, height: 10.0, left: 125.0),
-      l('', 6, 6, hardBreak: false, width: 300.0, height: 40.0, left: 0.0),
-      l('ipsum', 6, 11, hardBreak: true, width: 50.0, height: 10.0, left: 125.0),
+      l(placeholderChar, 6, 7, hardBreak: false, width: 300.0, height: 40.0, left: 0.0),
+      l('ipsum', 7, 12, hardBreak: true, width: 50.0, height: 10.0, left: 125.0),
     ]);
   });
 
diff --git a/lib/web_ui/test/text/line_breaker_test.dart b/lib/web_ui/test/text/line_breaker_test.dart
index 3b21855..b536bd4 100644
--- a/lib/web_ui/test/text/line_breaker_test.dart
+++ b/lib/web_ui/test/text/line_breaker_test.dart
@@ -6,53 +6,53 @@
 import 'package:test/test.dart';
 
 import 'package:ui/src/engine.dart';
+import 'package:ui/ui.dart';
 
+import '../html/paragraph/helper.dart';
 import 'line_breaker_test_helper.dart';
 import 'line_breaker_test_raw_data.dart';
 
+final String placeholderChar = String.fromCharCode(0xFFFC);
+
 void main() {
   internalBootstrapBrowserTest(() => testMain);
 }
 
 void testMain() {
-  group('nextLineBreak', () {
-    test('Does not go beyond the ends of a string', () {
-      expect(split('foo'), <Line>[
-        Line('foo', LineBreakType.endOfText),
+  group('$LineBreakFragmenter', () {
+    test('empty string', () {
+      expect(split(''), <Line>[
+        Line('', endOfText),
       ]);
-
-      final LineBreakResult result = nextLineBreak('foo', 'foo'.length);
-      expect(result.index, 'foo'.length);
-      expect(result.type, LineBreakType.endOfText);
     });
 
     test('whitespace', () {
       expect(split('foo bar'), <Line>[
-        Line('foo ', LineBreakType.opportunity),
-        Line('bar', LineBreakType.endOfText),
+        Line('foo ', opportunity, sp: 1),
+        Line('bar', endOfText),
       ]);
       expect(split('  foo    bar  '), <Line>[
-        Line('  ', LineBreakType.opportunity),
-        Line('foo    ', LineBreakType.opportunity),
-        Line('bar  ', LineBreakType.endOfText),
+        Line('  ', opportunity, sp: 2),
+        Line('foo    ', opportunity, sp: 4),
+        Line('bar  ', endOfText, sp: 2),
       ]);
     });
 
     test('single-letter lines', () {
       expect(split('foo a bar'), <Line>[
-        Line('foo ', LineBreakType.opportunity),
-        Line('a ', LineBreakType.opportunity),
-        Line('bar', LineBreakType.endOfText),
+        Line('foo ', opportunity, sp: 1),
+        Line('a ', opportunity, sp: 1),
+        Line('bar', endOfText),
       ]);
       expect(split('a b c'), <Line>[
-        Line('a ', LineBreakType.opportunity),
-        Line('b ', LineBreakType.opportunity),
-        Line('c', LineBreakType.endOfText),
+        Line('a ', opportunity, sp: 1),
+        Line('b ', opportunity, sp: 1),
+        Line('c', endOfText),
       ]);
       expect(split(' a b '), <Line>[
-        Line(' ', LineBreakType.opportunity),
-        Line('a ', LineBreakType.opportunity),
-        Line('b ', LineBreakType.endOfText),
+        Line(' ', opportunity, sp: 1),
+        Line('a ', opportunity, sp: 1),
+        Line('b ', endOfText, sp: 1),
       ]);
     });
 
@@ -60,242 +60,344 @@
       final String bk = String.fromCharCode(0x000B);
       // Can't have a line break between CR×LF.
       expect(split('foo\r\nbar'), <Line>[
-        Line('foo\r\n', LineBreakType.mandatory),
-        Line('bar', LineBreakType.endOfText),
+        Line('foo\r\n', mandatory, nl: 2, sp: 2),
+        Line('bar', endOfText),
       ]);
 
       // Any other new line is considered a line break on its own.
 
       expect(split('foo\n\nbar'), <Line>[
-        Line('foo\n', LineBreakType.mandatory),
-        Line('\n', LineBreakType.mandatory),
-        Line('bar', LineBreakType.endOfText),
+        Line('foo\n', mandatory, nl: 1, sp: 1),
+        Line('\n', mandatory, nl: 1, sp: 1),
+        Line('bar', endOfText),
       ]);
       expect(split('foo\r\rbar'), <Line>[
-        Line('foo\r', LineBreakType.mandatory),
-        Line('\r', LineBreakType.mandatory),
-        Line('bar', LineBreakType.endOfText),
+        Line('foo\r', mandatory, nl: 1, sp: 1),
+        Line('\r', mandatory, nl: 1, sp: 1),
+        Line('bar', endOfText),
       ]);
       expect(split('foo$bk${bk}bar'), <Line>[
-        Line('foo$bk', LineBreakType.mandatory),
-        Line(bk, LineBreakType.mandatory),
-        Line('bar', LineBreakType.endOfText),
+        Line('foo$bk', mandatory, nl: 1, sp: 1),
+        Line(bk, mandatory, nl: 1, sp: 1),
+        Line('bar', endOfText),
       ]);
 
       expect(split('foo\n\rbar'), <Line>[
-        Line('foo\n', LineBreakType.mandatory),
-        Line('\r', LineBreakType.mandatory),
-        Line('bar', LineBreakType.endOfText),
+        Line('foo\n', mandatory, nl: 1, sp: 1),
+        Line('\r', mandatory, nl: 1, sp: 1),
+        Line('bar', endOfText),
       ]);
       expect(split('foo$bk\rbar'), <Line>[
-        Line('foo$bk', LineBreakType.mandatory),
-        Line('\r', LineBreakType.mandatory),
-        Line('bar', LineBreakType.endOfText),
+        Line('foo$bk', mandatory, nl: 1, sp: 1),
+        Line('\r', mandatory, nl: 1, sp: 1),
+        Line('bar', endOfText),
       ]);
       expect(split('foo\r${bk}bar'), <Line>[
-        Line('foo\r', LineBreakType.mandatory),
-        Line(bk, LineBreakType.mandatory),
-        Line('bar', LineBreakType.endOfText),
+        Line('foo\r', mandatory, nl: 1, sp: 1),
+        Line(bk, mandatory, nl: 1, sp: 1),
+        Line('bar', endOfText),
       ]);
       expect(split('foo$bk\nbar'), <Line>[
-        Line('foo$bk', LineBreakType.mandatory),
-        Line('\n', LineBreakType.mandatory),
-        Line('bar', LineBreakType.endOfText),
+        Line('foo$bk', mandatory, nl: 1, sp: 1),
+        Line('\n', mandatory, nl: 1, sp: 1),
+        Line('bar', endOfText),
       ]);
       expect(split('foo\n${bk}bar'), <Line>[
-        Line('foo\n', LineBreakType.mandatory),
-        Line(bk, LineBreakType.mandatory),
-        Line('bar', LineBreakType.endOfText),
+        Line('foo\n', mandatory, nl: 1, sp: 1),
+        Line(bk, mandatory, nl: 1, sp: 1),
+        Line('bar', endOfText),
       ]);
 
       // New lines at the beginning and end.
 
       expect(split('foo\n'), <Line>[
-        Line('foo\n', LineBreakType.mandatory),
-        Line('', LineBreakType.endOfText),
+        Line('foo\n', mandatory, nl: 1, sp: 1),
+        Line('', endOfText),
       ]);
       expect(split('foo\r'), <Line>[
-        Line('foo\r', LineBreakType.mandatory),
-        Line('', LineBreakType.endOfText),
+        Line('foo\r', mandatory, nl: 1, sp: 1),
+        Line('', endOfText),
       ]);
       expect(split('foo$bk'), <Line>[
-        Line('foo$bk', LineBreakType.mandatory),
-        Line('', LineBreakType.endOfText),
+        Line('foo$bk', mandatory, nl: 1, sp: 1),
+        Line('', endOfText),
       ]);
 
       expect(split('\nfoo'), <Line>[
-        Line('\n', LineBreakType.mandatory),
-        Line('foo', LineBreakType.endOfText),
+        Line('\n', mandatory, nl: 1, sp: 1),
+        Line('foo', endOfText),
       ]);
       expect(split('\rfoo'), <Line>[
-        Line('\r', LineBreakType.mandatory),
-        Line('foo', LineBreakType.endOfText),
+        Line('\r', mandatory, nl: 1, sp: 1),
+        Line('foo', endOfText),
       ]);
       expect(split('${bk}foo'), <Line>[
-        Line(bk, LineBreakType.mandatory),
-        Line('foo', LineBreakType.endOfText),
+        Line(bk, mandatory, nl: 1, sp: 1),
+        Line('foo', endOfText),
       ]);
 
       // Whitespace with new lines.
 
       expect(split('foo  \n'), <Line>[
-        Line('foo  \n', LineBreakType.mandatory),
-        Line('', LineBreakType.endOfText),
+        Line('foo  \n', mandatory, nl: 1, sp: 3),
+        Line('', endOfText),
       ]);
 
       expect(split('foo  \n   '), <Line>[
-        Line('foo  \n', LineBreakType.mandatory),
-        Line('   ', LineBreakType.endOfText),
+        Line('foo  \n', mandatory, nl: 1, sp: 3),
+        Line('   ', endOfText, sp: 3),
       ]);
 
       expect(split('foo  \n   bar'), <Line>[
-        Line('foo  \n', LineBreakType.mandatory),
-        Line('   ', LineBreakType.opportunity),
-        Line('bar', LineBreakType.endOfText),
+        Line('foo  \n', mandatory, nl: 1, sp: 3),
+        Line('   ', opportunity, sp: 3),
+        Line('bar', endOfText),
       ]);
 
       expect(split('\n  foo'), <Line>[
-        Line('\n', LineBreakType.mandatory),
-        Line('  ', LineBreakType.opportunity),
-        Line('foo', LineBreakType.endOfText),
+        Line('\n', mandatory, nl: 1, sp: 1),
+        Line('  ', opportunity, sp: 2),
+        Line('foo', endOfText),
       ]);
       expect(split('   \n  foo'), <Line>[
-        Line('   \n', LineBreakType.mandatory),
-        Line('  ', LineBreakType.opportunity),
-        Line('foo', LineBreakType.endOfText),
+        Line('   \n', mandatory, nl: 1, sp: 4),
+        Line('  ', opportunity, sp: 2),
+        Line('foo', endOfText),
       ]);
     });
 
     test('trailing spaces and new lines', () {
-      expect(
-        findBreaks('foo bar  '),
-        const <LineBreakResult>[
-          LineBreakResult(4, 4, 3, LineBreakType.opportunity),
-          LineBreakResult(9, 9, 7, LineBreakType.endOfText),
+      expect(split('foo bar  '), <Line>[
+          Line('foo ', opportunity, sp: 1),
+          Line('bar  ', endOfText, sp: 2),
         ],
       );
 
-      expect(
-        findBreaks('foo  \nbar\nbaz   \n'),
-        const <LineBreakResult>[
-          LineBreakResult(6, 5, 3, LineBreakType.mandatory),
-          LineBreakResult(10, 9, 9, LineBreakType.mandatory),
-          LineBreakResult(17, 16, 13, LineBreakType.mandatory),
-          LineBreakResult(17, 17, 17, LineBreakType.endOfText),
+      expect(split('foo  \nbar\nbaz   \n'), <Line>[
+          Line('foo  \n', mandatory, nl: 1, sp: 3),
+          Line('bar\n', mandatory, nl: 1, sp: 1),
+          Line('baz   \n', mandatory, nl: 1, sp: 4),
+          Line('', endOfText),
         ],
       );
     });
 
     test('leading spaces', () {
-      expect(
-        findBreaks(' foo'),
-        const <LineBreakResult>[
-          LineBreakResult(1, 1, 0, LineBreakType.opportunity),
-          LineBreakResult(4, 4, 4, LineBreakType.endOfText),
+      expect(split(' foo'), <Line>[
+          Line(' ', opportunity, sp: 1),
+          Line('foo', endOfText),
         ],
       );
 
-      expect(
-        findBreaks('   foo'),
-        const <LineBreakResult>[
-          LineBreakResult(3, 3, 0, LineBreakType.opportunity),
-          LineBreakResult(6, 6, 6, LineBreakType.endOfText),
+      expect(split('   foo'), <Line>[
+          Line('   ', opportunity, sp: 3),
+          Line('foo', endOfText),
         ],
       );
 
-      expect(
-        findBreaks('  foo   bar'),
-        const <LineBreakResult>[
-          LineBreakResult(2, 2, 0, LineBreakType.opportunity),
-          LineBreakResult(8, 8, 5, LineBreakType.opportunity),
-          LineBreakResult(11, 11, 11, LineBreakType.endOfText),
+      expect(split('  foo   bar'), <Line>[
+          Line('  ', opportunity, sp: 2),
+          Line('foo   ', opportunity, sp: 3),
+          Line('bar', endOfText),
         ],
       );
 
-      expect(
-        findBreaks('  \n   foo'),
-        const <LineBreakResult>[
-          LineBreakResult(3, 2, 0, LineBreakType.mandatory),
-          LineBreakResult(6, 6, 3, LineBreakType.opportunity),
-          LineBreakResult(9, 9, 9, LineBreakType.endOfText),
+      expect(split('  \n   foo'), <Line>[
+          Line('  \n', mandatory, nl: 1, sp: 3),
+          Line('   ', opportunity, sp: 3),
+          Line('foo', endOfText),
         ],
       );
     });
 
     test('whitespace before the last character', () {
-      const String text = 'Lorem sit .';
-      const LineBreakResult expectedResult =
-          LineBreakResult(10, 10, 9, LineBreakType.opportunity);
+      expect(split('Lorem sit .'), <Line>[
+          Line('Lorem ', opportunity, sp: 1),
+          Line('sit ', opportunity, sp: 1),
+          Line('.', endOfText),
+        ],
+      );
+    });
 
-      LineBreakResult result;
+    test('placeholders', () {
+      final CanvasParagraph paragraph = rich(
+        EngineParagraphStyle(),
+        (CanvasParagraphBuilder builder) {
+          builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+          builder.addText('Lorem');
+          builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+          builder.addText('ipsum\n');
+          builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+          builder.addText('dolor');
+          builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+          builder.addText('\nsit');
+          builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+        },
+      );
 
-      result = nextLineBreak(text, 6);
-      expect(result, expectedResult);
+      final String placeholderChar = String.fromCharCode(0xFFFC);
 
-      result = nextLineBreak(text, 9);
-      expect(result, expectedResult);
+      expect(splitParagraph(paragraph), <Line>[
+        Line(placeholderChar, opportunity),
+        Line('Lorem', opportunity),
+        Line(placeholderChar, opportunity),
+        Line('ipsum\n', mandatory, nl: 1, sp: 1),
+        Line(placeholderChar, opportunity),
+        Line('dolor', opportunity),
+        Line('$placeholderChar\n', mandatory, nl: 1, sp: 1),
+        Line('sit', opportunity),
+        Line(placeholderChar, endOfText),
+      ]);
+    });
 
-      result = nextLineBreak(text, 9, maxEnd: 10);
-      expect(result, expectedResult);
+    test('single placeholder', () {
+      final CanvasParagraph paragraph = rich(
+        EngineParagraphStyle(),
+        (CanvasParagraphBuilder builder) {
+          builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+        },
+      );
+
+      final String placeholderChar = String.fromCharCode(0xFFFC);
+
+      expect(splitParagraph(paragraph), <Line>[
+        Line(placeholderChar, endOfText),
+      ]);
+    });
+
+    test('placeholders surrounded by spaces and new lines', () {
+      final CanvasParagraph paragraph = rich(
+        EngineParagraphStyle(),
+        (CanvasParagraphBuilder builder) {
+          builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+          builder.addText('  Lorem  ');
+          builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+          builder.addText('  \nipsum \n');
+          builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+          builder.addText('\n');
+          builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+        },
+      );
+
+      expect(splitParagraph(paragraph), <Line>[
+          Line('$placeholderChar  ', opportunity, sp: 2),
+          Line('Lorem  ', opportunity, sp: 2),
+          Line('$placeholderChar  \n', mandatory, nl: 1, sp: 3),
+          Line('ipsum \n', mandatory, nl: 1, sp: 2),
+          Line('$placeholderChar\n', mandatory, nl: 1, sp: 1),
+          Line(placeholderChar, endOfText),
+        ],
+      );
+    });
+
+    test('surrogates', () {
+      expect(split('A\u{1F600}'), <Line>[
+          Line('A', opportunity),
+          Line('\u{1F600}', endOfText),
+        ],
+      );
+
+      expect(split('\u{1F600}A'), <Line>[
+          Line('\u{1F600}', opportunity),
+          Line('A', endOfText),
+        ],
+      );
+
+      expect(split('\u{1F600}\u{1F600}'), <Line>[
+          Line('\u{1F600}', opportunity),
+          Line('\u{1F600}', endOfText),
+        ],
+      );
+
+      expect(split('A \u{1F600} \u{1F600}'), <Line>[
+          Line('A ', opportunity, sp: 1),
+          Line('\u{1F600} ', opportunity, sp: 1),
+          Line('\u{1F600}', endOfText),
+        ],
+      );
     });
 
     test('comprehensive test', () {
-      final List<TestCase> testCollection = parseRawTestData(rawLineBreakTestData);
+      final List<TestCase> testCollection =
+          parseRawTestData(rawLineBreakTestData);
       for (int t = 0; t < testCollection.length; t++) {
         final TestCase testCase = testCollection[t];
-        final String text = testCase.toText();
 
-        int lastLineBreak = 0;
+        final String text = testCase.toText();
+        final List<LineBreakFragment> fragments = LineBreakFragmenter(text).fragment();
+
+        // `f` is the index in the `fragments` list.
+        int f = 0;
+        LineBreakFragment currentFragment = fragments[f];
+
         int surrogateCount = 0;
         // `s` is the index in the `testCase.signs` list.
-        for (int s = 0; s < testCase.signs.length; s++) {
+        for (int s = 0; s < testCase.signs.length - 1; s++) {
           // `i` is the index in the `text`.
           final int i = s + surrogateCount;
-          if (s < testCase.chars.length && testCase.chars[s].isSurrogatePair) {
-            surrogateCount++;
-          }
-
           final Sign sign = testCase.signs[s];
-          final LineBreakResult result = nextLineBreak(text, lastLineBreak);
+
           if (sign.isBreakOpportunity) {
-            // The line break should've been found at index `i`.
             expect(
-              result.index,
+              currentFragment.end,
               i,
               reason: 'Failed at test case number $t:\n'
                   '$testCase\n'
                   '"$text"\n'
-                  '\nExpected line break at {$lastLineBreak - $i} but found line break at {$lastLineBreak - ${result.index}}.',
+                  '\nExpected fragment to end at {$i} but ended at {${currentFragment.end}}.',
             );
-
-            // Since this is a line break, passing a `maxEnd` that's greater
-            // should return the same line break.
-            final LineBreakResult maxEndResult =
-                nextLineBreak(text, lastLineBreak, maxEnd: i + 1);
-            expect(maxEndResult.index, i);
-            expect(maxEndResult.type, isNot(LineBreakType.prohibited));
-
-            lastLineBreak = i;
+            currentFragment = fragments[++f];
           } else {
-            // This isn't a line break opportunity so the line break should be
-            // somewhere after index `i`.
             expect(
-              result.index,
+              currentFragment.end,
               greaterThan(i),
               reason: 'Failed at test case number $t:\n'
                   '$testCase\n'
                   '"$text"\n'
-                  '\nUnexpected line break found at {$lastLineBreak - ${result.index}}.',
+                  '\nFragment ended in early at {${currentFragment.end}}.',
             );
+          }
 
-            // Since this isn't a line break, passing it as a `maxEnd` should
-            // return `maxEnd` as a prohibited line break type.
-            final LineBreakResult maxEndResult =
-                nextLineBreak(text, lastLineBreak, maxEnd: i);
-            expect(maxEndResult.index, i);
-            expect(maxEndResult.type, LineBreakType.prohibited);
+          if (s < testCase.chars.length && testCase.chars[s].isSurrogatePair) {
+            surrogateCount++;
           }
         }
+
+        // Now let's look at the last sign, which requires different handling.
+
+        // The last line break is an endOfText (or a hard break followed by
+        // endOfText if the last character is a hard line break).
+        if (currentFragment.type == mandatory) {
+          // When last character is a hard line break, there should be an
+          // extra fragment to represent the empty line at the end.
+          expect(
+            fragments,
+            hasLength(f + 2),
+            reason: 'Failed at test case number $t:\n'
+                '$testCase\n'
+                '"$text"\n'
+                "\nExpected an extra fragment for endOfText but there wasn't one.",
+          );
+
+          currentFragment = fragments[++f];
+        }
+
+        expect(
+          currentFragment.type,
+          endOfText,
+          reason: 'Failed at test case number $t:\n'
+              '$testCase\n'
+              '"$text"\n\n'
+              'Expected an endOfText fragment but found: $currentFragment',
+        );
+        expect(
+          currentFragment.end,
+          text.length,
+          reason: 'Failed at test case number $t:\n'
+              '$testCase\n'
+              '"$text"\n\n'
+              'Expected an endOfText fragment ending at {${text.length}} but found: $currentFragment',
+        );
       }
     });
   });
@@ -303,17 +405,32 @@
 
 /// Holds information about how a line was split from a string.
 class Line {
-  Line(this.text, this.breakType);
+  Line(this.text, this.breakType, {this.nl = 0, this.sp = 0});
+
+  factory Line.fromLineBreakFragment(String text, LineBreakFragment fragment) {
+    return Line(
+      text.substring(fragment.start, fragment.end),
+      fragment.type,
+      nl: fragment.trailingNewlines,
+      sp: fragment.trailingSpaces,
+    );
+  }
 
   final String text;
   final LineBreakType breakType;
+  final int nl;
+  final int sp;
 
   @override
-  int get hashCode => Object.hash(text, breakType);
+  int get hashCode => Object.hash(text, breakType, nl, sp);
 
   @override
   bool operator ==(Object other) {
-    return other is Line && other.text == text && other.breakType == breakType;
+    return other is Line &&
+        other.text == text &&
+        other.breakType == breakType &&
+        other.nl == nl &&
+        other.sp == sp;
   }
 
   String get escapedText {
@@ -329,29 +446,20 @@
 
   @override
   String toString() {
-    return '"$escapedText" ($breakType)';
+    return '"$escapedText" ($breakType, nl: $nl, sp: $sp)';
   }
 }
 
 List<Line> split(String text) {
-  final List<Line> lines = <Line>[];
-
-  int lastIndex = 0;
-  for (final LineBreakResult brk in findBreaks(text)) {
-    lines.add(Line(text.substring(lastIndex, brk.index), brk.type));
-    lastIndex = brk.index;
-  }
-  return lines;
+  return <Line>[
+    for (final LineBreakFragment fragment in LineBreakFragmenter(text).fragment())
+      Line.fromLineBreakFragment(text, fragment)
+  ];
 }
 
-List<LineBreakResult> findBreaks(String text) {
-  final List<LineBreakResult> breaks = <LineBreakResult>[];
-
-  LineBreakResult brk = nextLineBreak(text, 0);
-  breaks.add(brk);
-  while (brk.type != LineBreakType.endOfText) {
-    brk = nextLineBreak(text, brk.index);
-    breaks.add(brk);
-  }
-  return breaks;
+List<Line> splitParagraph(CanvasParagraph paragraph) {
+  return <Line>[
+    for (final LineBreakFragment fragment in LineBreakFragmenter(paragraph.plainText).fragment())
+      Line.fromLineBreakFragment(paragraph.toPlainText(), fragment)
+  ];
 }
diff --git a/lib/web_ui/test/text/text_direction_test.dart b/lib/web_ui/test/text/text_direction_test.dart
index 61b7b44..3c39220 100644
--- a/lib/web_ui/test/text/text_direction_test.dart
+++ b/lib/web_ui/test/text/text_direction_test.dart
@@ -2,125 +2,197 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-
 import 'package:test/bootstrap/browser.dart';
 import 'package:test/test.dart';
 import 'package:ui/src/engine.dart';
 import 'package:ui/ui.dart';
 
-// Two RTL strings, 5 characters each, to match the length of "$rtl1" and "$rtl2".
-const String rtl1 = 'واحدة';
-const String rtl2 = 'ثنتان';
+import '../html/paragraph/helper.dart';
 
 void main() {
   internalBootstrapBrowserTest(() => testMain);
 }
 
 Future<void> testMain() async {
-  group('$getDirectionalBlockEnd', () {
+  group('$BidiFragmenter', () {
+    test('empty string', () {
+      expect(split(''), <_Bidi>[
+        _Bidi('', null, ffPrevious),
+      ]);
+    });
 
     test('basic cases', () {
-      const String text = 'Lorem 12 $rtl1   ipsum34';
-      const LineBreakResult start = LineBreakResult.sameIndex(0, LineBreakType.prohibited);
-      const LineBreakResult end = LineBreakResult.sameIndex(text.length, LineBreakType.endOfText);
-      const LineBreakResult loremMiddle = LineBreakResult.sameIndex(3, LineBreakType.prohibited);
-      const LineBreakResult loremEnd = LineBreakResult.sameIndex(5, LineBreakType.prohibited);
-      const LineBreakResult twelveStart = LineBreakResult(6, 6, 5, LineBreakType.opportunity);
-      const LineBreakResult twelveEnd = LineBreakResult.sameIndex(8, LineBreakType.prohibited);
-      const LineBreakResult rtl1Start = LineBreakResult(9, 9, 8, LineBreakType.opportunity);
-      const LineBreakResult rtl1End = LineBreakResult.sameIndex(14, LineBreakType.prohibited);
-      const LineBreakResult ipsumStart = LineBreakResult(17, 17, 15, LineBreakType.opportunity);
-      const LineBreakResult ipsumEnd = LineBreakResult.sameIndex(22, LineBreakType.prohibited);
+      expect(split('Lorem 11 $rtlWord1  22 ipsum'), <_Bidi>[
+        _Bidi('Lorem', ltr, ffLtr),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi('11', ltr, ffPrevious),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi(rtlWord1, rtl, ffRtl),
+        _Bidi('  ', null, ffSandwich),
+        _Bidi('22', ltr, ffPrevious),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi('ipsum', ltr, ffLtr),
+      ]);
+    });
 
-      DirectionalPosition blockEnd;
+    test('text and digits', () {
+      expect(split('Lorem11 ${rtlWord1}22 33ipsum44dolor ${rtlWord2}55$rtlWord1'), <_Bidi>[
+        _Bidi('Lorem11', ltr, ffLtr),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi(rtlWord1, rtl, ffRtl),
+        _Bidi('22', ltr, ffPrevious),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi('33ipsum44dolor', ltr, ffLtr),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi(rtlWord2, rtl, ffRtl),
+        _Bidi('55', ltr, ffPrevious),
+        _Bidi(rtlWord1, rtl, ffRtl),
+      ]);
+    });
 
-      blockEnd = getDirectionalBlockEnd(text, start, end);
-      expect(blockEnd.isSpaceOnly, isFalse);
-      expect(blockEnd.textDirection, TextDirection.ltr);
-      expect(blockEnd.lineBreak, loremEnd);
+    test('Mashriqi digits', () {
+      expect(split('foo ١١ ٢٢ bar'), <_Bidi>[
+        _Bidi('foo', ltr, ffLtr),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi('١١', ltr, ffRtl),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi('٢٢', ltr, ffRtl),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi('bar', ltr, ffLtr),
+      ]);
 
-      blockEnd = getDirectionalBlockEnd(text, start, loremMiddle);
-      expect(blockEnd.isSpaceOnly, isFalse);
-      expect(blockEnd.textDirection, TextDirection.ltr);
-      expect(blockEnd.lineBreak, loremMiddle);
+      expect(split('$rtlWord1 ١١ ٢٢ $rtlWord2'), <_Bidi>[
+        _Bidi(rtlWord1, rtl, ffRtl),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi('١١', ltr, ffRtl),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi('٢٢', ltr, ffRtl),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi(rtlWord2, rtl, ffRtl),
+      ]);
+    });
 
-      blockEnd = getDirectionalBlockEnd(text, loremMiddle, loremEnd);
-      expect(blockEnd.isSpaceOnly, isFalse);
-      expect(blockEnd.textDirection, TextDirection.ltr);
-      expect(blockEnd.lineBreak, loremEnd);
+    test('spaces', () {
+      expect(split('    '), <_Bidi>[
+        _Bidi('    ', null, ffSandwich),
+      ]);
+    });
 
-      blockEnd = getDirectionalBlockEnd(text, loremEnd, twelveStart);
-      expect(blockEnd.isSpaceOnly, isTrue);
-      expect(blockEnd.textDirection, isNull);
-      expect(blockEnd.lineBreak, twelveStart);
+    test('symbols', () {
+      expect(split('Calculate 2.2 + 4.5 and write the result'), <_Bidi>[
+        _Bidi('Calculate', ltr, ffLtr),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi('2', ltr, ffPrevious),
+        _Bidi('.', null, ffSandwich),
+        _Bidi('2', ltr, ffPrevious),
+        _Bidi(' + ', null, ffSandwich),
+        _Bidi('4', ltr, ffPrevious),
+        _Bidi('.', null, ffSandwich),
+        _Bidi('5', ltr, ffPrevious),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi('and', ltr, ffLtr),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi('write', ltr, ffLtr),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi('the', ltr, ffLtr),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi('result', ltr, ffLtr),
+      ]);
 
-      blockEnd = getDirectionalBlockEnd(text, twelveStart, rtl1Start);
-      expect(blockEnd.isSpaceOnly, isFalse);
-      expect(blockEnd.textDirection, isNull);
-      expect(blockEnd.lineBreak, twelveEnd);
+      expect(split('Calculate $rtlWord1 2.2 + 4.5 and write the result'), <_Bidi>[
+        _Bidi('Calculate', ltr, ffLtr),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi(rtlWord1, rtl, ffRtl),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi('2', ltr, ffPrevious),
+        _Bidi('.', null, ffSandwich),
+        _Bidi('2', ltr, ffPrevious),
+        _Bidi(' + ', null, ffSandwich),
+        _Bidi('4', ltr, ffPrevious),
+        _Bidi('.', null, ffSandwich),
+        _Bidi('5', ltr, ffPrevious),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi('and', ltr, ffLtr),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi('write', ltr, ffLtr),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi('the', ltr, ffLtr),
+        _Bidi(' ', null, ffSandwich),
+        _Bidi('result', ltr, ffLtr),
+      ]);
 
-      blockEnd = getDirectionalBlockEnd(text, rtl1Start, end);
-      expect(blockEnd.isSpaceOnly, isFalse);
-      expect(blockEnd.textDirection, TextDirection.rtl);
-      expect(blockEnd.lineBreak, rtl1End);
-
-      blockEnd = getDirectionalBlockEnd(text, ipsumStart, end);
-      expect(blockEnd.isSpaceOnly, isFalse);
-      expect(blockEnd.textDirection, TextDirection.ltr);
-      expect(blockEnd.lineBreak, ipsumEnd);
-
-      blockEnd = getDirectionalBlockEnd(text, ipsumEnd, end);
-      expect(blockEnd.isSpaceOnly, isFalse);
-      expect(blockEnd.textDirection, isNull);
-      expect(blockEnd.lineBreak, end);
+      expect(split('12 + 24 = 36'), <_Bidi>[
+        _Bidi('12', ltr, ffPrevious),
+        _Bidi(' + ', null, ffSandwich),
+        _Bidi('24', ltr, ffPrevious),
+        _Bidi(' = ', null, ffSandwich),
+        _Bidi('36', ltr, ffPrevious),
+      ]);
     });
 
     test('handles new lines', () {
-      const String text = 'Lorem\n12\nipsum  \n';
-      const LineBreakResult start = LineBreakResult.sameIndex(0, LineBreakType.prohibited);
-      const LineBreakResult end = LineBreakResult(
-        text.length,
-        text.length - 1,
-        text.length - 3,
-        LineBreakType.mandatory,
-      );
-      const LineBreakResult loremEnd = LineBreakResult.sameIndex(5, LineBreakType.prohibited);
-      const LineBreakResult twelveStart = LineBreakResult(6, 5, 5, LineBreakType.mandatory);
-      const LineBreakResult twelveEnd = LineBreakResult.sameIndex(8, LineBreakType.prohibited);
-      const LineBreakResult ipsumStart = LineBreakResult(9, 8, 8, LineBreakType.mandatory);
-      const LineBreakResult ipsumEnd = LineBreakResult.sameIndex(14, LineBreakType.prohibited);
+      expect(split('Lorem\n12\nipsum  \n'), <_Bidi>[
+        _Bidi('Lorem', ltr, ffLtr),
+        _Bidi('\n', null, ffSandwich),
+        _Bidi('12', ltr, ffPrevious),
+        _Bidi('\n', null, ffSandwich),
+        _Bidi('ipsum', ltr, ffLtr),
+        _Bidi('  \n', null, ffSandwich),
+      ]);
 
-      DirectionalPosition blockEnd;
+      expect(split('$rtlWord1\n  $rtlWord2 \n'), <_Bidi>[
+        _Bidi(rtlWord1, rtl, ffRtl),
+        _Bidi('\n  ', null, ffSandwich),
+        _Bidi(rtlWord2, rtl, ffRtl),
+        _Bidi(' \n', null, ffSandwich),
+      ]);
+    });
 
-      blockEnd = getDirectionalBlockEnd(text, start, twelveStart);
-      expect(blockEnd.isSpaceOnly, isFalse);
-      expect(blockEnd.textDirection, TextDirection.ltr);
-      expect(blockEnd.lineBreak, twelveStart);
-
-      blockEnd = getDirectionalBlockEnd(text, loremEnd, twelveStart);
-      expect(blockEnd.isSpaceOnly, isTrue);
-      expect(blockEnd.textDirection, isNull);
-      expect(blockEnd.lineBreak, twelveStart);
-
-      blockEnd = getDirectionalBlockEnd(text, twelveStart, ipsumStart);
-      expect(blockEnd.isSpaceOnly, isFalse);
-      expect(blockEnd.textDirection, isNull);
-      expect(blockEnd.lineBreak, ipsumStart);
-
-      blockEnd = getDirectionalBlockEnd(text, twelveEnd, ipsumStart);
-      expect(blockEnd.isSpaceOnly, isTrue);
-      expect(blockEnd.textDirection, isNull);
-      expect(blockEnd.lineBreak, ipsumStart);
-
-      blockEnd = getDirectionalBlockEnd(text, ipsumStart, end);
-      expect(blockEnd.isSpaceOnly, isFalse);
-      expect(blockEnd.textDirection, TextDirection.ltr);
-      expect(blockEnd.lineBreak, ipsumEnd);
-
-      blockEnd = getDirectionalBlockEnd(text, ipsumEnd, end);
-      expect(blockEnd.isSpaceOnly, isTrue);
-      expect(blockEnd.textDirection, isNull);
-      expect(blockEnd.lineBreak, end);
+    test('surrogates', () {
+      expect(split('A\u{1F600}'), <_Bidi>[
+        _Bidi('A', ltr, ffLtr),
+        _Bidi('\u{1F600}', null, ffSandwich),
+      ]);
     });
   });
 }
+
+/// Holds information about how a bidi region was split from a string.
+class _Bidi {
+  _Bidi(this.text, this.textDirection, this.fragmentFlow);
+
+  factory _Bidi.fromBidiFragment(String text, BidiFragment bidiFragment) {
+    return _Bidi(
+      text.substring(bidiFragment.start, bidiFragment.end),
+      bidiFragment.textDirection,
+      bidiFragment.fragmentFlow,
+    );
+  }
+
+  final String text;
+  final TextDirection? textDirection;
+  final FragmentFlow fragmentFlow;
+
+  @override
+  int get hashCode => Object.hash(text, textDirection);
+
+  @override
+  bool operator ==(Object other) {
+    return other is _Bidi &&
+        other.text == text &&
+        other.textDirection == textDirection &&
+        other.fragmentFlow == fragmentFlow;
+  }
+
+  @override
+  String toString() {
+    return '"$text" ($textDirection | $fragmentFlow)';
+  }
+}
+
+List<_Bidi> split(String text) {
+  return <_Bidi>[
+    for (final BidiFragment bidiFragment in BidiFragmenter(text).fragment())
+      _Bidi.fromBidiFragment(text, bidiFragment)
+  ];
+}