measureNextPage method

Future<PageData?> measureNextPage()

Measures the next page of text and returns its data without rasterizing. This is useful for previewing content or getting layout information.

Implementation

Future<PageData?> measureNextPage() async {
  if (_remainingText.isEmpty) return null;

  final List<_LineData> lines = [];
  String textToLayout = _remainingText;
  double currentY = layout.startY;

  final double estimatedLineHeight = layout.fontSize * 1.4;

  while (textToLayout.isNotEmpty && currentY < layout.height) {
    // Get the available width for a line at the current Y position.
    var lineLayout = layout.getLineLayout(currentY, estimatedLineHeight);

    if (lineLayout == null) {
      // We have moved outside the drawable area, so this page is done.
      break;
    }

    // Use a Paragraph to measure the text for the current line.
    final paragraphBuilder = ui.ParagraphBuilder(ui.ParagraphStyle(
      textAlign: layout.textAlign,
      fontFamily: layout.fontFamily,
      fontSize: layout.fontSize.toDouble(),
      textDirection: ui.TextDirection.ltr,
    ));
    paragraphBuilder.addText(textToLayout);
    final paragraph = paragraphBuilder.build();
    paragraph.layout(ui.ParagraphConstraints(width: lineLayout.width.toDouble()));

    // Get the metrics for the first line that fits within the constraints.
    final lineMetrics = paragraph.computeLineMetrics();
    if (lineMetrics.isEmpty) break; // No more text fits.

    final firstLine = lineMetrics.first;
    final double actualLineHeight = firstLine.height;

    // Final check to ensure this line actually fits vertically.
    if (currentY + actualLineHeight > layout.height) {
      break;
    }

    // Re-check the layout with the actual height for better accuracy, especially for circles.
    var finalLineLayout = layout.getLineLayout(currentY, actualLineHeight) ?? lineLayout;

    // Get the character range for the first line. This is the most reliable way
    // to determine where Flutter decided to break the line.
    final lineBreak = paragraph.getLineBoundary(const ui.TextPosition(offset: 0));
    int endIndex = lineBreak.end;

    if (endIndex == 0 && textToLayout.isNotEmpty) {
      // Failsafe for cases where not even one character fits. Consume one to prevent loops.
      endIndex = 1;
    }

    String lineText = textToLayout.substring(0, endIndex);

    // Refine word wrapping: if the line breaks in the middle of a word,
    // try to break at the previous space instead.
    if (endIndex < textToLayout.length) {
        final nextChar = textToLayout[endIndex];
        if (lineText.isNotEmpty && !_isWhitespace(nextChar) && !_isWhitespace(lineText[lineText.length - 1])) {
            int lastSpace = lineText.trimRight().lastIndexOf(' ');
            if (lastSpace != -1) {
                // Re-measure endIndex to the character after the space.
                endIndex = textToLayout.substring(0, lastSpace).length + 1;
                lineText = textToLayout.substring(0, endIndex);
            }
        }
    }

    // Only add the line if it contains non-whitespace characters.
    final trimmedLine = lineText.trim();
    if (trimmedLine.isNotEmpty) {
      lines.add(_LineData(
        text: trimmedLine,
        width: finalLineLayout.width,
        xOffset: finalLineLayout.xOffset,
        yOffset: currentY.toInt(),
        lineHeight: actualLineHeight.toInt(),
      ));
    }

    // Advance Y position and update remaining text.
    currentY += actualLineHeight;
    textToLayout = textToLayout.substring(endIndex).trimLeft();
  }

  if (lines.isEmpty && _remainingText.isNotEmpty) {
    // This can happen if the first word is too long to fit on any line.
    // To prevent an infinite loop, we consume the text that was attempted.
    _remainingText = textToLayout;
    return null;
  }

  _remainingText = textToLayout;
  return PageData._(lines: lines, layout: layout);
}