ImageSharp/SixLabors.Fonts/TextLineBreakEnumerator.cs
2026-08-03 22:31:27 +02:00

342 lines
13 KiB
C#

// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using SixLabors.Fonts.Unicode;
using System;
using System.Collections.Generic;
namespace SixLabors.Fonts {
/// <summary>
/// Breaks a prepared logical line one visual line at a time.
/// </summary>
internal sealed class TextLineBreakEnumerator
{
private readonly LogicalTextLine logicalLine;
private readonly TextOptions options;
private readonly bool breakAll;
private readonly bool keepAll;
private readonly bool breakWord;
private readonly bool normalizeDecomposedAdvances;
private readonly int maxLines;
private readonly CodePoint? ellipsisMarkerCodePoint;
private readonly IReadOnlyList<LineBreak> lineBreaks;
private TextLine textLine;
private int processed;
private int lineCount;
private TextLine? current;
/// <summary>
/// Initializes a new instance of the <see cref="TextLineBreakEnumerator"/> class.
/// </summary>
/// <param name="logicalLine">The logical line to break.</param>
/// <param name="options">The text options used for layout.</param>
public TextLineBreakEnumerator(in LogicalTextLine logicalLine, TextOptions options)
{
this.logicalLine = logicalLine;
this.options = options;
this.breakAll = options.WordBreaking == WordBreaking.BreakAll;
this.keepAll = options.WordBreaking == WordBreaking.KeepAll;
this.breakWord = options.WordBreaking == WordBreaking.BreakWord;
this.normalizeDecomposedAdvances = options.LayoutMode.IsVertical();
this.maxLines = options.MaxLines;
this.ellipsisMarkerCodePoint = TextLayout.GetEllipsisMarkerCodePoint(options);
this.lineBreaks = logicalLine.LineBreaks;
// The breaker mutates the remaining line as it advances, so each cursor owns
// a clone of the immutable prepared line held by TextBlock.
this.textLine = new(logicalLine.TextLine);
}
/// <summary>
/// Gets the current finalized visual line.
/// </summary>
public TextLine Current => this.current!;
/// <summary>
/// Advances to the next visual line using the supplied wrapping length.
/// </summary>
/// <param name="wrappingLength">The wrapping length in pixels.</param>
/// <returns><see langword="true"/> when a line was produced.</returns>
public bool MoveNext(float wrappingLength)
{
if (this.textLine.Count == 0)
{
return false;
}
bool shouldWrap = wrappingLength > 0;
// Wrapping length is always provided in pixels. Convert to inches for comparison.
float scaledWrappingLength = shouldWrap ? wrappingLength / this.options.Dpi : float.MaxValue;
while (this.textLine.Count > 0)
{
LineBreak? bestBreak = null;
foreach (LineBreak lineBreak in this.lineBreaks)
{
// Skip breaks that are already behind the processed portion.
if (lineBreak.PositionWrap <= this.processed)
{
continue;
}
// Measure the text up to the adjusted break point.
int measureIndex = lineBreak.PositionMeasure - this.processed;
float advance = this.textLine.MeasureAt(measureIndex);
if (lineBreak.IsHyphenationBreak)
{
advance += this.textLine.GetHyphenationMarkerAdvance(
measureIndex - 1,
this.logicalLine.HyphenationMarkers);
}
if (advance >= scaledWrappingLength)
{
bestBreak ??= lineBreak;
break;
}
// If it's a mandatory break, stop immediately.
if (lineBreak.Required)
{
bestBreak = lineBreak;
break;
}
// Update the best break.
bestBreak = lineBreak;
}
if (bestBreak != null)
{
if (this.BreakAt(bestBreak.Value, scaledWrappingLength))
{
return true;
}
continue;
}
return this.BreakLastLine(scaledWrappingLength);
}
return false;
}
/// <summary>
/// Breaks the current remaining line at the supplied break opportunity.
/// </summary>
/// <param name="breakAt">The selected line break opportunity.</param>
/// <param name="scaledWrappingLength">The wrapping length in inches.</param>
/// <returns><see langword="true"/> when a visual line was produced.</returns>
private bool BreakAt(LineBreak breakAt, float scaledWrappingLength)
{
if (this.breakAll)
{
return this.BreakAtAnyGlyph(breakAt, scaledWrappingLength);
}
int hyphenationMarkerIndex = breakAt.PositionMeasure - this.processed - 1;
// Split the current line at the adjusted break index.
if (this.textLine.TrySplitAt(breakAt, this.keepAll, out TextLine? remaining))
{
if (breakAt.IsHyphenationBreak)
{
this.textLine.ApplyHyphenationMarker(
hyphenationMarkerIndex,
this.logicalLine.HyphenationMarkers);
}
if (breakAt.Required
&& this.options.TextInteractionMode == TextInteractionMode.Editor
&& remaining.Count > 0
&& remaining[0].IsNewLine
&& this.textLine.TrySplitTerminalHardBreak(out TextLine? blankLine))
{
// Consecutive hard breaks need an editable blank line for the break that
// ended this segment, plus the next break still waiting in the remainder.
remaining.InsertAt(0, blankLine);
}
// If 'keepAll' is true then the break could be later than expected.
this.processed = this.keepAll
? this.processed + Math.Max(this.textLine.Count, breakAt.PositionWrap - this.processed)
: breakAt.PositionWrap;
if (this.breakWord)
{
// A break was found, but we need to check if the line is too long
// and break if required.
if (this.textLine.ScaledLineAdvance > scaledWrappingLength &&
this.textLine.TrySplitAt(scaledWrappingLength, out TextLine? overflow))
{
// Reinsert the overflow at the beginning of the remaining line.
this.processed -= overflow.Count;
remaining.InsertAt(0, overflow);
}
}
bool stopLayout = this.SetCurrent(
this.textLine,
breakAt.Required,
remaining.Count > 0,
scaledWrappingLength);
this.textLine = stopLayout ? new TextLine() : remaining;
return true;
}
this.processed += this.textLine.Count;
return false;
}
/// <summary>
/// Breaks the current remaining line using CSS <see cref="WordBreaking.BreakAll"/> behavior.
/// </summary>
/// <param name="breakAt">The selected line break opportunity.</param>
/// <param name="scaledWrappingLength">The wrapping length in inches.</param>
/// <returns><see langword="true"/> when a visual line was produced.</returns>
private bool BreakAtAnyGlyph(LineBreak breakAt, float scaledWrappingLength)
{
TextLine? remaining;
if (breakAt.Required)
{
if (this.textLine.TrySplitAt(breakAt, this.keepAll, out remaining))
{
this.processed = breakAt.PositionWrap;
bool stopLayout = this.SetCurrent(
this.textLine,
true,
remaining.Count > 0,
scaledWrappingLength);
this.textLine = stopLayout ? new TextLine() : remaining;
return true;
}
}
else if (this.textLine.TrySplitAt(scaledWrappingLength, out remaining))
{
this.processed += this.textLine.Count;
bool stopLayout = this.SetCurrent(
this.textLine,
false,
remaining.Count > 0,
scaledWrappingLength);
this.textLine = stopLayout ? new TextLine() : remaining;
return true;
}
else
{
this.processed += this.textLine.Count;
}
return false;
}
/// <summary>
/// Breaks and finalizes the last remaining line.
/// </summary>
/// <param name="scaledWrappingLength">The wrapping length in inches.</param>
/// <returns><see langword="true"/> when a visual line was produced.</returns>
private bool BreakLastLine(float scaledWrappingLength)
{
if (this.breakWord || this.breakAll)
{
while (this.textLine.ScaledLineAdvance > scaledWrappingLength)
{
if (!this.textLine.TrySplitAt(scaledWrappingLength, out TextLine? overflow))
{
break;
}
bool stopLayout = this.SetCurrent(
this.textLine,
false,
overflow.Count > 0,
scaledWrappingLength);
// Width-based overflow splits do not come from a stored LineBreak, so the
// cursor advances by the consumed entries before the next MoveNext scan.
this.processed += this.textLine.Count;
this.textLine = stopLayout ? new TextLine() : overflow;
return true;
}
}
if (this.options.TextInteractionMode == TextInteractionMode.Editor
&& this.textLine.TrySplitTerminalHardBreak(out TextLine? hardBreakLine))
{
// A terminal Enter has no following glyph for the normal required-break split.
// Editor interaction still needs the next blank line as a caret target.
this.SetCurrent(
this.textLine,
true,
true,
scaledWrappingLength);
this.textLine = hardBreakLine;
return true;
}
this.SetCurrent(
this.textLine,
true,
false,
scaledWrappingLength);
this.textLine = new TextLine();
return true;
}
/// <summary>
/// Finalizes the current line and stores it as the enumerator result.
/// </summary>
/// <param name="line">The line to finalize.</param>
/// <param name="skipJustification">Whether the line should skip justification.</param>
/// <param name="hasOverflow">Whether source text remains after this line.</param>
/// <param name="scaledWrappingLength">The wrapping length in inches.</param>
/// <returns><see langword="true"/> when no further lines should be produced.</returns>
private bool SetCurrent(
TextLine line,
bool skipJustification,
bool hasOverflow,
float scaledWrappingLength)
{
bool isLimitedFinalLine = this.maxLines > -1 && this.lineCount + 1 >= this.maxLines;
if (isLimitedFinalLine && hasOverflow)
{
// A max-lines ellipsis is a final-line transformation: wrapping has already
// chosen the visible line, so the marker replaces the tail of that line and
// the line must behave like a paragraph-final line for justification.
if (this.ellipsisMarkerCodePoint.HasValue)
{
line.ApplyEllipsisMarker(this.ellipsisMarkerCodePoint.Value, scaledWrappingLength, this.options);
}
skipJustification = true;
}
bool preserveTrailingBreakingWhitespace = this.options.TextInteractionMode == TextInteractionMode.Editor;
// Paragraph layout trims trailing breaking whitespace. Editor interaction keeps
// ordinary trailing whitespace addressable so typed spaces can advance the caret.
this.current = line.Finalize(
skipJustification,
this.normalizeDecomposedAdvances,
preserveTrailingBreakingWhitespace);
if (!this.current.SkipJustification)
{
this.current.Justify(this.options);
}
this.lineCount++;
return isLimitedFinalLine;
}
}
}