add `debugPaintTextLayoutBoxes` for debugging text layout (#168876)

Text vertical alignment is a common complaint and one of the most common
causes is different type faces being used in the same paragraph (for
instance, when there's English / Arabic in the same paragraph so the
default font doesn't contain the required glyphs for some characters).

Hopefully this flag is going to make debugging alignment issues a bit
easier, and in some github issues attaching screenshots with the flag on
will be helpful for diagnosing the issue.

### What the layout boxes look like 
```dart
final testText =
    'Quick brown fox, نص, trailing spaces${" " * 100}'
    'new line at the end.\n\n\n\n';

Column([
              Text(testText, style: TextStyle(height: kTextHeightNone)),
              Text(testText, style: TextStyle(height: 1.0)),
              Text(
                testText,
                style: TextStyle(height: kTextHeightNone),
                strutStyle: StrutStyle(fontSize: 30),
              ),
])
```
<img width="410" alt="image"
src="https://github.com/user-attachments/assets/788b8089-373f-4af0-9144-306b3e755614"
/>

## Pre-launch Checklist

- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [ ] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart
index af1ff10..c0ca1b0 100644
--- a/packages/flutter/lib/src/painting/text_painter.dart
+++ b/packages/flutter/lib/src/painting/text_painter.dart
@@ -1291,6 +1291,22 @@
     _layoutCache = newLayoutCache;
   }
 
+  /// Causes the paragraph to paint the layout boxes of the text.
+  ///
+  /// {@template flutter.painting.textPainter.debugPaintTextLayoutBoxes}
+  /// Each painted box illustrates how the encompassed text contributes to the
+  /// overall text layout. For instance, for paragraphs whose [StrutStyle] is
+  /// disabled, the line height of a line is the smallest vertical extent that
+  /// covers all text boxes on that line.
+  ///
+  /// Typically, only characters with a non-zero horizontal advance produce
+  /// these boxes. No boxes will be painted for lines that only consist of a new
+  /// line character.
+  /// {@endtemplate}
+  ///
+  /// The [paint] method reads this flag only in debug mode.
+  bool debugPaintTextLayoutBoxes = false;
+
   /// Paints the text onto the given canvas at the given offset.
   ///
   /// Valid only after [layout] has been called.
@@ -1335,9 +1351,31 @@
       assert(debugSize == size);
     }
     assert(!_rebuildParagraphForPaint);
+
+    assert(
+      !debugPaintTextLayoutBoxes || _debugPaintCharacterLayoutBoxes(canvas, layoutCache, offset),
+    );
     canvas.drawParagraph(layoutCache.paragraph, offset + layoutCache.paintOffset);
   }
 
+  bool _debugPaintCharacterLayoutBoxes(
+    Canvas canvas,
+    _TextPainterLayoutCacheWithOffset layout,
+    Offset offset,
+  ) {
+    final Paint paint = Paint()
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 1.0
+      ..color = const Color(0xFF00FFFF);
+    final List<TextBox> textBoxes = getBoxesForSelection(
+      TextSelection(baseOffset: 0, extentOffset: plainText.length),
+    );
+    for (final TextBox textBox in textBoxes) {
+      canvas.drawRect(textBox.toRect().shift(offset), paint);
+    }
+    return true;
+  }
+
   // Returns true if value falls in the valid range of the UTF16 encoding.
   static bool _isUTF16(int value) {
     return value >= 0x0 && value <= 0xFFFFF;
@@ -1441,17 +1479,22 @@
     return Offset(adjustedDx, rawOffset.dy + layoutCache.paintOffset.dy);
   }
 
+  // The condition is derived from
+  // https://github.com/google/skia/blob/0086a17e0d4cc676cf88cae671ba5ee967eb7241/modules/skparagraph/src/TextLine.cpp#L1244-L1246
+  // which is set here:
+  // https://github.com/flutter/engine/blob/a821b8790c9fd0e095013cd5bd1f20273bc1ee47/third_party/txt/src/skia/paragraph_builder_skia.cc#L134
+  bool get _strutDisabled => switch (strutStyle) {
+    null || StrutStyle.disabled => true,
+    StrutStyle(:final double? fontSize) => fontSize == 0.0,
+  };
+
   /// {@template flutter.painting.textPainter.getFullHeightForCaret}
   /// Returns the strut bounded height of the glyph at the given `position`.
   /// {@endtemplate}
   ///
   /// Valid only after [layout] has been called.
   double getFullHeightForCaret(TextPosition position, Rect caretPrototype) {
-    // The if condition is derived from
-    // https://github.com/google/skia/blob/0086a17e0d4cc676cf88cae671ba5ee967eb7241/modules/skparagraph/src/TextLine.cpp#L1244-L1246
-    // which is set here:
-    // https://github.com/flutter/engine/blob/a821b8790c9fd0e095013cd5bd1f20273bc1ee47/third_party/txt/src/skia/paragraph_builder_skia.cc#L134
-    if (strutStyle == null || strutStyle == StrutStyle.disabled || strutStyle?.fontSize == 0.0) {
+    if (_strutDisabled) {
       final double? heightFromCaretMetrics = _computeCaretMetrics(position)?.height;
       if (heightFromCaretMetrics != null) {
         return heightFromCaretMetrics;
diff --git a/packages/flutter/lib/src/rendering/debug.dart b/packages/flutter/lib/src/rendering/debug.dart
index 1066cc4..448ca2f 100644
--- a/packages/flutter/lib/src/rendering/debug.dart
+++ b/packages/flutter/lib/src/rendering/debug.dart
@@ -38,6 +38,15 @@
 /// Causes each RenderBox to paint a line at each of its baselines.
 bool debugPaintBaselinesEnabled = false;
 
+/// Causes each RenderParagraph to paint the layout boxes of its text.
+///
+/// {@macro flutter.painting.textPainter.debugPaintTextLayoutBoxes}
+///
+/// See also:
+///
+///  * [debugPaintBaselinesEnabled] which helps debug text alignment.
+bool debugPaintTextLayoutBoxes = false;
+
 /// Causes each Layer to paint a box around its bounds.
 bool debugPaintLayerBordersEnabled = false;
 
@@ -321,6 +330,7 @@
     if (debugPaintSizeEnabled ||
         debugPaintBaselinesEnabled ||
         debugPaintLayerBordersEnabled ||
+        debugPaintTextLayoutBoxes ||
         debugPaintPointersEnabled ||
         debugRepaintRainbowEnabled ||
         debugRepaintTextRainbowEnabled ||
diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart
index f3df3a5..e8834cc 100644
--- a/packages/flutter/lib/src/rendering/paragraph.dart
+++ b/packages/flutter/lib/src/rendering/paragraph.dart
@@ -1007,6 +1007,11 @@
       }
     }
 
+    assert(() {
+      _textPainter.debugPaintTextLayoutBoxes = debugPaintTextLayoutBoxes;
+      return true;
+    }());
+
     _textPainter.paint(context.canvas, offset);
 
     paintInlineChildren(context, offset);
diff --git a/packages/flutter/test/painting/text_painter_test.dart b/packages/flutter/test/painting/text_painter_test.dart
index 7df7658..357f46b 100644
--- a/packages/flutter/test/painting/text_painter_test.dart
+++ b/packages/flutter/test/painting/text_painter_test.dart
@@ -1957,6 +1957,30 @@
     expect(painter.height, 10);
   });
 
+  test('debugPaintTextLayoutBoxes', () {
+    const TextSpan span = TextSpan(
+      text: 'M',
+      // ascent = 96, descent = 32
+      style: TextStyle(fontSize: 128),
+      children: <InlineSpan>[TextSpan(text: 'M', style: TextStyle(fontSize: 64))],
+    );
+
+    final TextPainter painter = TextPainter()
+      ..textDirection = TextDirection.ltr
+      ..text = span
+      ..layout();
+    expect(
+      (Canvas canvas) {
+        painter.debugPaintTextLayoutBoxes = true;
+        painter.paint(canvas, Offset.zero);
+        painter.debugPaintTextLayoutBoxes = false;
+      },
+      paints
+        ..rect(rect: Offset.zero & const Size.square(128))
+        ..rect(rect: const Offset(128, 96 - 48) & const Size.square(64)),
+    );
+  });
+
   test('TextPainter dispatches memory events', () async {
     await expectLater(
       await memoryEvents(() => TextPainter().dispose(), TextPainter),