blob: 57d39440e7dc5319153bcab8fe5403a4ced795a5 [file] [log] [blame]
// 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:html' as html;
import 'dart:math' as math;
import 'package:meta/meta.dart';
import 'package:ui/ui.dart' as ui;
import 'canvas_paragraph.dart';
import 'line_breaker.dart';
import 'measurement.dart';
import 'paragraph.dart';
import 'ruler.dart';
import 'text_direction.dart';
/// Performs layout on a [CanvasParagraph].
///
/// It uses a [html.CanvasElement] to measure text.
class TextLayoutService {
TextLayoutService(this.paragraph);
final CanvasParagraph paragraph;
final html.CanvasRenderingContext2D context = html.CanvasElement().context2D;
// *** Results of layout *** //
// Look at the Paragraph class for documentation of the following properties.
double width = -1.0;
double height = 0.0;
EngineLineMetrics? longestLine;
double minIntrinsicWidth = 0.0;
double maxIntrinsicWidth = 0.0;
double alphabeticBaseline = -1.0;
double ideographicBaseline = -1.0;
bool didExceedMaxLines = false;
final List<EngineLineMetrics> lines = <EngineLineMetrics>[];
// *** Convenient shortcuts used during layout *** //
int? get maxLines => paragraph.paragraphStyle.maxLines;
bool get unlimitedLines => maxLines == null;
String? get ellipsis => paragraph.paragraphStyle.ellipsis;
bool get hasEllipsis => ellipsis != null;
/// Performs the layout on a paragraph given the [constraints].
///
/// The function starts by resetting all layout-related properties. Then it
/// starts looping through the paragraph to calculate all layout metrics.
///
/// It uses a [Spanometer] to perform measurements within spans of the
/// paragraph. It also uses [LineBuilders] to generate [EngineLineMetrics] as
/// it iterates through the paragraph.
///
/// The main loop keeps going until:
///
/// 1. The end of the paragraph is reached (i.e. LineBreakType.endOfText).
/// 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;
longestLine = null;
minIntrinsicWidth = 0.0;
maxIntrinsicWidth = 0.0;
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 *** //
// ************************** //
// 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();
lines.add(currentLine.build());
}
break;
}
// ********************************* //
// *** THE MAIN MEASUREMENT PART *** //
// ********************************* //
final 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);
} 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) {
// TODO(mdebbar): Handle the case when `nextBreak` is just a span end
// that shouldn't extend the line yet.
// 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.isEmpty) {
// The current line is still empty, 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.
lines.add(currentLine.build());
currentLine = currentLine.nextLine();
}
}
// 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}');
}
if (lines.length == maxLines) {
break;
}
}
// ************************************************** //
// *** PARAGRAPH BASELINE & HEIGHT & LONGEST LINE *** //
// ************************************************** //
for (final EngineLineMetrics line in lines) {
height += line.height;
if (alphabeticBaseline == -1.0) {
alphabeticBaseline = line.baseline;
ideographicBaseline = alphabeticBaseline * baselineRatioHack;
}
final double longestLineWidth = longestLine?.width ?? 0.0;
if (longestLineWidth < line.width) {
longestLine = line;
}
}
// ******************************** //
// *** MAX/MIN INTRINSIC WIDTHS *** //
// ******************************** //
spanIndex = 0;
currentLine =
LineBuilder.first(paragraph, spanometer, maxWidth: constraints.width);
while (spanIndex < spanCount) {
final ParagraphSpan span = paragraph.spans[spanIndex];
bool breakToNextLine = false;
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;
}
// Max intrinsic width includes the width of trailing spaces.
if (maxIntrinsicWidth < currentLine.widthIncludingSpace) {
maxIntrinsicWidth = currentLine.widthIncludingSpace;
}
if (breakToNextLine) {
currentLine = currentLine.nextLine();
}
}
}
List<ui.TextBox> getBoxesForPlaceholders() {
final List<ui.TextBox> boxes = <ui.TextBox>[];
for (final EngineLineMetrics line in lines) {
for (final RangeBox box in line.boxes!) {
if (box is PlaceholderBox) {
boxes.add(box.toTextBox(line));
}
}
}
return boxes;
}
List<ui.TextBox> getBoxesForRange(
int start,
int end,
ui.BoxHeightStyle boxHeightStyle,
ui.BoxWidthStyle boxWidthStyle,
) {
// Zero-length ranges and invalid ranges return an empty list.
if (start >= end || start < 0 || end < 0) {
return <ui.TextBox>[];
}
final int length = paragraph.toPlainText().length;
// Ranges that are out of bounds should return an empty list.
if (start > length || end > length) {
return <ui.TextBox>[];
}
final List<ui.TextBox> boxes = <ui.TextBox>[];
for (final EngineLineMetrics 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));
}
}
}
}
return boxes;
}
ui.TextPosition getPositionForOffset(ui.Offset offset) {
// After layout, each line has boxes that contain enough information to make
// it possible to do hit testing. Once we find the box, we look inside that
// box to find where exactly the `offset` is located.
final EngineLineMetrics line = _findLineForY(offset.dy);
// [offset] is to the left of the line.
if (offset.dx <= line.left) {
return ui.TextPosition(
offset: line.startIndex,
affinity: ui.TextAffinity.downstream,
);
}
// [offset] is to the right of the line.
if (offset.dx >= line.left + line.widthWithTrailingSpaces) {
return ui.TextPosition(
offset: line.endIndexWithoutNewlines,
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);
}
}
// Is this ever reachable?
return ui.TextPosition(offset: line.startIndex);
}
EngineLineMetrics _findLineForY(double y) {
// We could do a binary search here but it's not worth it because the number
// of line is typically low, and each iteration is a cheap comparison of
// doubles.
for (final EngineLineMetrics line in lines) {
if (y <= line.height) {
return line;
}
y -= line.height;
}
return lines.last;
}
}
/// 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.width,
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.
final double 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.
ui.TextBox toTextBox(EngineLineMetrics line);
/// 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, placeholder.width, paragraphDirection, boxDirection);
final PlaceholderSpan placeholder;
@override
ui.TextBox toTextBox(EngineLineMetrics line) {
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(
Spanometer spanometer, {
required LineBreakResult start,
required LineBreakResult end,
required double width,
required ui.TextDirection paragraphDirection,
required ui.TextDirection boxDirection,
required this.contentDirection,
required this.isSpaceOnly,
}) : this.spanometer = spanometer, // ignore: unnecessary_this
span = spanometer.currentSpan,
height = spanometer.height,
baseline = spanometer.ascent,
super(start, end, width, 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 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);
}
/// 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.
@override
ui.TextBox toTextBox(EngineLineMetrics line) {
return intersect(line, start.index, end.index);
}
/// 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(EngineLineMetrics line, int start, int end) {
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);
}
final 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;
}
// 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,
affinity: ui.TextAffinity.downstream,
);
} 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 [EngineLineMetrics] 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].
///
/// The line can be extended one or more times before it's built by calling
/// [LineBuilder.build] which generates the [EngineLineMetrics] instace.
///
/// To start building the next line, simply call [LineBuilder.nextLine] which
/// creates a new [LineBuilder] that can be extended and built and so on.
class LineBuilder {
LineBuilder._(
this.paragraph,
this.spanometer, {
required this.maxWidth,
required this.start,
required this.lineNumber,
required this.accumulatedHeight,
}) : end = start;
/// Creates a [LineBuilder] for the first line in a paragraph.
factory LineBuilder.first(
CanvasParagraph paragraph,
Spanometer spanometer, {
required double maxWidth,
}) {
return LineBuilder._(
paragraph,
spanometer,
maxWidth: maxWidth,
lineNumber: 0,
start: const LineBreakResult.sameIndex(0, LineBreakType.prohibited),
accumulatedHeight: 0.0,
);
}
final List<LineSegment> _segments = <LineSegment>[];
final List<RangeBox> _boxes = <RangeBox>[];
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 end;
/// 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;
/// The distance from the top of the line to the alphabetic baseline.
double ascent = 0.0;
/// The distance from the bottom of the line to the alphabetic baseline.
double descent = 0.0;
/// The height of the line so far.
double get height => ascent + descent;
/// The last segment in this line.
LineSegment get lastSegment => _segments.last;
bool get isEmpty => _segments.isEmpty;
bool get isNotEmpty => _segments.isNotEmpty;
/// The horizontal offset necessary for the line to be correctly aligned.
double get alignOffset {
final double emptySpace = maxWidth - width;
final ui.TextAlign textAlign = paragraph.paragraphStyle.effectiveTextAlign;
switch (textAlign) {
case ui.TextAlign.center:
return emptySpace / 2.0;
case ui.TextAlign.right:
return emptySpace;
case ui.TextAlign.start:
return _paragraphDirection == ui.TextDirection.rtl ? emptySpace : 0.0;
case ui.TextAlign.end:
return _paragraphDirection == ui.TextDirection.rtl ? 0.0 : emptySpace;
default:
return 0.0;
}
}
/// 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;
}
return widthOfTrailingSpace + spanometer.measure(end, newEnd);
}
bool get _isLastBoxAPlaceholder {
if (_boxes.isEmpty) {
return false;
}
return _boxes.last is PlaceholderBox;
}
ui.TextDirection get _paragraphDirection =>
paragraph.paragraphStyle.effectiveTextDirection;
late ui.TextDirection _currentBoxDirection = _paragraphDirection;
late ui.TextDirection _currentContentDirection = _paragraphDirection;
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;
}
/// Extends the line by setting a [newEnd].
void extendTo(DirectionalPosition newEnd) {
ascent = math.max(ascent, spanometer.ascent);
descent = math.max(descent, spanometer.descent);
// 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));
} else {
end = endOfText;
}
}
void addPlaceholder(PlaceholderSpan placeholder) {
// Increase the line's height to fit the placeholder, if necessary.
final double ascent, descent;
switch (placeholder.alignment) {
case ui.PlaceholderAlignment.top:
// The placeholder is aligned to the top of text, which means it has the
// same `ascent` as the remaining text. We only need to extend the
// `descent` enough to fit the placeholder.
ascent = this.ascent;
descent = placeholder.height - this.ascent;
break;
case ui.PlaceholderAlignment.bottom:
// The opposite of `top`. The `descent` is the same, but we extend the
// `ascent`.
ascent = placeholder.height - this.descent;
descent = this.descent;
break;
case ui.PlaceholderAlignment.middle:
final double textMidPoint = height / 2;
final double placeholderMidPoint = placeholder.height / 2;
final double diff = placeholderMidPoint - textMidPoint;
ascent = this.ascent + diff;
descent = this.descent + diff;
break;
case ui.PlaceholderAlignment.aboveBaseline:
ascent = placeholder.height;
descent = 0.0;
break;
case ui.PlaceholderAlignment.belowBaseline:
ascent = 0.0;
descent = placeholder.height;
break;
case ui.PlaceholderAlignment.baseline:
ascent = placeholder.baselineOffset;
descent = placeholder.height - ascent;
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;
}
/// 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),
);
}
/// 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);
// 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;
}
}
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,
);
// 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));
}
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..."
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,
);
// 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();
}
_currentBoxStartOffset = widthIncludingSpace;
extendTo(nextBreak.copyWithIndex(breakingPoint));
}
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.
///
/// 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;
}
_boxes.add(SpanBox(
spanometer,
start: boxStart,
end: boxEnd,
width: _currentBoxWidth,
paragraphDirection: _paragraphDirection,
boxDirection: _currentBoxDirection,
contentDirection: _currentContentDirection,
isSpaceOnly: isSpaceOnly,
));
_currentBoxStartOffset = widthIncludingSpace;
}
/// Builds the [EngineLineMetrics] instance that represents this line.
EngineLineMetrics build({String? ellipsis}) {
// At the end of each line, we cut the last box of the line.
createBox();
_positionBoxes();
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;
}
return EngineLineMetrics.rich(
lineNumber,
ellipsis: ellipsis,
startIndex: start.index,
endIndex: end.index,
endIndexWithoutNewlines: endIndexWithoutNewlines,
hardBreak: hardBreak,
width: width + ellipsisWidth,
widthWithTrailingSpaces: widthIncludingSpace + ellipsisWidth,
left: alignOffset,
height: height,
baseline: accumulatedHeight + ascent,
ascent: ascent,
descent: descent,
boxes: _boxes,
);
}
/// Positions the boxes and takes into account their directions, and the
/// paragraph's direction.
void _positionBoxes() {
final List<RangeBox> boxes = _boxes;
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 = width;
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).
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;
}
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 =
_positionBoxesInReverse(boxes, first, last, startOffset: cumulativeWidth);
cumulativeWidth += sequenceWidth;
}
}
/// 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 _positionBoxesInReverse(
List<RangeBox> boxes,
int first,
int last, {
required double startOffset,
}) {
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 = width;
cumulativeWidth += box.width;
}
return cumulativeWidth;
}
/// Finds the next line break after the end of this line.
DirectionalPosition findNextBreak() {
final String text = paragraph.toPlainText();
final int maxEnd = spanometer.currentSpan.end;
final LineBreakResult result = nextLineBreak(text, end.index, maxEnd: maxEnd);
// The current end of the line is the beginning of the next block.
return getDirectionalBlockEnd(text, end, result);
}
/// Creates a new [LineBuilder] to build the next line in the paragraph.
LineBuilder nextLine() {
return LineBuilder._(
paragraph,
spanometer,
maxWidth: maxWidth,
start: end,
lineNumber: lineNumber + 1,
accumulatedHeight: accumulatedHeight + height,
);
}
}
/// Responsible for taking measurements within spans of a paragraph.
///
/// Can't perform measurements across spans. To measure across spans, multiple
/// measurements have to be taken.
///
/// Before performing any measurement, the [currentSpan] has to be set. Once
/// it's set, the [Spanometer] updates the underlying [context] so that
/// subsequent measurements use the correct styles.
class Spanometer {
Spanometer(this.paragraph, this.context);
final CanvasParagraph paragraph;
final html.CanvasRenderingContext2D context;
static RulerHost _rulerHost = RulerHost();
static Map<TextHeightStyle, TextHeightRuler> _rulers =
<TextHeightStyle, TextHeightRuler>{};
@visibleForTesting
static Map<TextHeightStyle, TextHeightRuler> get rulers => _rulers;
/// Clears the cache of rulers that are used for measuring text height and
/// baseline metrics.
static void clearRulersCache() {
_rulers.forEach((TextHeightStyle style, TextHeightRuler ruler) {
ruler.dispose();
});
_rulers.clear();
}
String _cssFontString = '';
double? get letterSpacing => currentSpan.style.letterSpacing;
TextHeightRuler? _currentRuler;
FlatTextSpan? _currentSpan;
FlatTextSpan get currentSpan => _currentSpan!;
set currentSpan(FlatTextSpan? span) {
if (span == _currentSpan) {
return;
}
_currentSpan = span;
// No need to update css font string when `span` is null.
if (span == null) {
_currentRuler = null;
return;
}
// Update the height ruler.
// If the ruler doesn't exist in the cache, create a new one and cache it.
final TextHeightStyle heightStyle = span.style.heightStyle;
TextHeightRuler? ruler = _rulers[heightStyle];
if (ruler == null) {
ruler = TextHeightRuler(heightStyle, _rulerHost);
_rulers[heightStyle] = ruler;
}
_currentRuler = ruler;
// Update the font string if it's different from the previous span.
final String cssFontString = span.style.cssFontString;
if (_cssFontString != cssFontString) {
_cssFontString = cssFontString;
context.font = cssFontString;
}
}
/// Whether the spanometer is ready to take measurements.
bool get isReady => _currentSpan != null;
/// The distance from the top of the current span to the alphabetic baseline.
double get ascent => _currentRuler!.alphabeticBaseline;
/// The distance from the bottom of the current span to the alphabetic baseline.
double get descent => height - ascent;
/// 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);
}
/// 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].
///
/// The [start] and [end] indices have to be within the same text span.
///
/// When [allowEmpty] is true, the result is guaranteed to be at least one
/// character after [start]. But if [allowEmpty] is false and there isn't
/// enough [availableWidth] to fit the first character, then [start] is
/// returned.
///
/// See also:
/// - [LineBuilder.forceBreak].
int forceBreak(
int start,
int end, {
required double availableWidth,
required bool allowEmpty,
}) {
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);
if (availableWidth <= 0.0) {
return allowEmpty ? start : start + 1;
}
int low = start;
int high = end;
do {
final int mid = (low + high) ~/ 2;
final double width = _measure(start, mid);
if (width < availableWidth) {
low = mid;
} else if (width > availableWidth) {
high = mid;
} else {
low = high = mid;
}
} while (high - low > 1);
if (low == start && !allowEmpty) {
low++;
}
return low;
}
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);
final String text = paragraph.toPlainText();
return measureSubstring(
context,
text,
start,
end,
letterSpacing: letterSpacing,
);
}
}