A cross-platform UI framework for .NET
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

1626 lines
60 KiB

using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using Avalonia.Controls;
using Avalonia.Media.TextFormatting;
using Avalonia.Utilities;
namespace Avalonia.Media
{
/// <summary>
/// The FormattedText class is targeted at programmers needing to add some simple text to a MIL visual.
/// </summary>
public class FormattedText
{
public const double DefaultRealToIdeal = 28800.0 / 96;
public const double DefaultIdealToReal = 1 / DefaultRealToIdeal;
public const int IdealInfiniteWidth = 0x3FFFFFFE;
public const double RealInfiniteWidth = IdealInfiniteWidth * DefaultIdealToReal;
public const double GreatestMultiplierOfEm = 100;
private const double MaxFontEmSize = RealInfiniteWidth / GreatestMultiplierOfEm;
// properties and format runs
private ReadOnlySlice<char> _text;
private readonly SpanVector _formatRuns = new SpanVector(null);
private SpanPosition _latestPosition;
private GenericTextParagraphProperties _defaultParaProps;
private double _maxTextWidth = double.PositiveInfinity;
private double[]? _maxTextWidths;
private double _maxTextHeight = double.PositiveInfinity;
private int _maxLineCount = int.MaxValue;
private TextTrimming _trimming = TextTrimming.WordEllipsis;
// text source callbacks
private TextSourceImplementation? _textSourceImpl;
// cached metrics
private CachedMetrics? _metrics;
/// <summary>
/// Construct a FormattedText object.
/// </summary>
/// <param name="textToFormat">String of text to be displayed.</param>
/// <param name="culture">Culture of text.</param>
/// <param name="flowDirection">Flow direction of text.</param>
/// <param name="typeface">Type face used to display text.</param>
/// <param name="emSize">Font em size in visual units (1/96 of an inch).</param>
/// <param name="foreground">Foreground brush used to render text.</param>
public FormattedText(
string textToFormat,
CultureInfo culture,
FlowDirection flowDirection,
Typeface typeface,
double emSize,
IBrush? foreground)
{
if (culture is null)
{
throw new ArgumentNullException(nameof(culture));
}
ValidateFlowDirection(flowDirection, nameof(flowDirection));
ValidateFontSize(emSize);
_text = textToFormat != null ?
new ReadOnlySlice<char>(textToFormat.AsMemory()) :
throw new ArgumentNullException(nameof(textToFormat));
var runProps = new GenericTextRunProperties(
typeface,
emSize,
null, // decorations
foreground,
null, // highlight background
BaselineAlignment.Baseline,
culture
);
_latestPosition = _formatRuns.SetValue(0, _text.Length, runProps, _latestPosition);
_defaultParaProps = new GenericTextParagraphProperties(
flowDirection,
TextAlignment.Left,
false,
false,
runProps,
TextWrapping.WrapWithOverflow,
0, // line height not specified
0, // indentation not specified
0
);
InvalidateMetrics();
}
private static void ValidateFontSize(double emSize)
{
if (emSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(emSize), "The parameter value must be greater than zero.");
}
if (emSize > MaxFontEmSize)
{
throw new ArgumentOutOfRangeException(nameof(emSize), $"The parameter value cannot be greater than '{MaxFontEmSize}'");
}
if (double.IsNaN(emSize))
{
throw new ArgumentOutOfRangeException(nameof(emSize), "The parameter value must be a number.");
}
}
private static void ValidateFlowDirection(FlowDirection flowDirection, string parameterName)
{
if ((int)flowDirection < 0 || (int)flowDirection > (int)FlowDirection.RightToLeft)
{
throw new InvalidEnumArgumentException(parameterName, (int)flowDirection, typeof(FlowDirection));
}
}
private int ValidateRange(int startIndex, int count)
{
if (startIndex < 0 || startIndex > _text.Length)
{
throw new ArgumentOutOfRangeException(nameof(startIndex));
}
var limit = startIndex + count;
if (count < 0 || limit < startIndex || limit > _text.Length)
{
throw new ArgumentOutOfRangeException(nameof(count));
}
return limit;
}
private void InvalidateMetrics()
{
_metrics = null;
}
/// <summary>
/// Sets foreground brush used for drawing text
/// </summary>
/// <param name="foregroundBrush">Foreground brush</param>
public void SetForegroundBrush(IBrush foregroundBrush)
{
SetForegroundBrush(foregroundBrush, 0, _text.Length);
}
/// <summary>
/// Sets foreground brush used for drawing text
/// </summary>
/// <param name="foregroundBrush">Foreground brush</param>
/// <param name="startIndex">The start index of initial character to apply the change to.</param>
/// <param name="count">The number of characters the change should be applied to.</param>
public void SetForegroundBrush(IBrush? foregroundBrush, int startIndex, int count)
{
var limit = ValidateRange(startIndex, count);
for (var i = startIndex; i < limit;)
{
var formatRider = new SpanRider(_formatRuns, _latestPosition, i);
i = Math.Min(limit, i + formatRider.Length);
#pragma warning disable 6506
// Presharp warns that runProps is not validated, but it can never be null
// because the rider is already checked to be in range
if (!(formatRider.CurrentElement is GenericTextRunProperties runProps))
{
throw new NotSupportedException($"{nameof(runProps)} can not be null.");
}
if (runProps.ForegroundBrush == foregroundBrush)
{
continue;
}
var newProps = new GenericTextRunProperties(
runProps.Typeface,
runProps.FontRenderingEmSize,
runProps.TextDecorations,
foregroundBrush,
runProps.BackgroundBrush,
runProps.BaselineAlignment,
runProps.CultureInfo
);
#pragma warning restore 6506
_latestPosition = _formatRuns.SetValue(formatRider.CurrentPosition, i - formatRider.CurrentPosition,
newProps, formatRider.SpanPosition);
}
}
/// <summary>
/// Sets or changes the font family for the text object
/// </summary>
/// <param name="fontFamily">Font family name</param>
public void SetFontFamily(string fontFamily)
{
SetFontFamily(fontFamily, 0, _text.Length);
}
/// <summary>
/// Sets or changes the font family for the text object
/// </summary>
/// <param name="fontFamily">Font family name</param>
/// <param name="startIndex">The start index of initial character to apply the change to.</param>
/// <param name="count">The number of characters the change should be applied to.</param>
public void SetFontFamily(string fontFamily, int startIndex, int count)
{
if (fontFamily == null)
{
throw new ArgumentNullException(nameof(fontFamily));
}
SetFontFamily(new FontFamily(fontFamily), startIndex, count);
}
/// <summary>
/// Sets or changes the font family for the text object
/// </summary>
/// <param name="fontFamily">Font family</param>
public void SetFontFamily(FontFamily fontFamily)
{
SetFontFamily(fontFamily, 0, _text.Length);
}
/// <summary>
/// Sets or changes the font family for the text object
/// </summary>
/// <param name="fontFamily">Font family</param>
/// <param name="startIndex">The start index of initial character to apply the change to.</param>
/// <param name="count">The number of characters the change should be applied to.</param>
public void SetFontFamily(FontFamily fontFamily, int startIndex, int count)
{
if (fontFamily == null)
{
throw new ArgumentNullException(nameof(fontFamily));
}
var limit = ValidateRange(startIndex, count);
for (var i = startIndex; i < limit;)
{
var formatRider = new SpanRider(_formatRuns, _latestPosition, i);
i = Math.Min(limit, i + formatRider.Length);
#pragma warning disable 6506
// Presharp warns that runProps is not validated, but it can never be null
// because the rider is already checked to be in range
if (!(formatRider.CurrentElement is GenericTextRunProperties runProps))
{
throw new NotSupportedException($"{nameof(runProps)} can not be null.");
}
var oldTypeface = runProps.Typeface;
if (fontFamily.Equals(oldTypeface.FontFamily))
{
continue;
}
var newProps = new GenericTextRunProperties(
new Typeface(fontFamily, oldTypeface.Style, oldTypeface.Weight),
runProps.FontRenderingEmSize,
runProps.TextDecorations,
runProps.ForegroundBrush,
runProps.BackgroundBrush,
runProps.BaselineAlignment,
runProps.CultureInfo
);
#pragma warning restore 6506
_latestPosition = _formatRuns.SetValue(formatRider.CurrentPosition, i - formatRider.CurrentPosition,
newProps, formatRider.SpanPosition);
InvalidateMetrics();
}
}
/// <summary>
/// Sets or changes the font em size measured in MIL units
/// </summary>
/// <param name="emSize">Font em size</param>
public void SetFontSize(double emSize)
{
SetFontSize(emSize, 0, _text.Length);
}
/// <summary>
/// Sets or changes the font em size measured in MIL units
/// </summary>
/// <param name="emSize">Font em size</param>
/// <param name="startIndex">The start index of initial character to apply the change to.</param>
/// <param name="count">The number of characters the change should be applied to.</param>
public void SetFontSize(double emSize, int startIndex, int count)
{
ValidateFontSize(emSize);
var limit = ValidateRange(startIndex, count);
for (var i = startIndex; i < limit;)
{
var formatRider = new SpanRider(_formatRuns, _latestPosition, i);
i = Math.Min(limit, i + formatRider.Length);
#pragma warning disable 6506
// Presharp warns that runProps is not validated, but it can never be null
// because the rider is already checked to be in range
if (!(formatRider.CurrentElement is GenericTextRunProperties runProps))
{
throw new NotSupportedException($"{nameof(runProps)} can not be null.");
}
if (runProps.FontRenderingEmSize == emSize)
{
continue;
}
var newProps = new GenericTextRunProperties(
runProps.Typeface,
emSize,
runProps.TextDecorations,
runProps.ForegroundBrush,
runProps.BackgroundBrush,
runProps.BaselineAlignment,
runProps.CultureInfo
);
_latestPosition = _formatRuns.SetValue(formatRider.CurrentPosition, i - formatRider.CurrentPosition,
newProps, formatRider.SpanPosition);
#pragma warning restore 6506
InvalidateMetrics();
}
}
/// <summary>
/// Sets or changes the culture for the text object.
/// </summary>
/// <param name="culture">The new culture for the text object.</param>
public void SetCulture(CultureInfo culture)
{
SetCulture(culture, 0, _text.Length);
}
/// <summary>
/// Sets or changes the culture for the text object.
/// </summary>
/// <param name="culture">The new culture for the text object.</param>
/// <param name="startIndex">The start index of initial character to apply the change to.</param>
/// <param name="count">The number of characters the change should be applied to.</param>
public void SetCulture(CultureInfo culture, int startIndex, int count)
{
if (culture is null)
{
throw new ArgumentNullException(nameof(culture));
}
var limit = ValidateRange(startIndex, count);
for (var i = startIndex; i < limit;)
{
var formatRider = new SpanRider(_formatRuns, _latestPosition, i);
i = Math.Min(limit, i + formatRider.Length);
#pragma warning disable 6506
// Presharp warns that runProps is not validated, but it can never be null
// because the rider is already checked to be in range
if (!(formatRider.CurrentElement is GenericTextRunProperties runProps))
{
throw new NotSupportedException($"{nameof(runProps)} can not be null.");
}
if (runProps.CultureInfo == culture)
{
continue;
}
var newProps = new GenericTextRunProperties(
runProps.Typeface,
runProps.FontRenderingEmSize,
runProps.TextDecorations,
runProps.ForegroundBrush,
runProps.BackgroundBrush,
runProps.BaselineAlignment,
culture
);
#pragma warning restore 6506
_latestPosition = _formatRuns.SetValue(formatRider.CurrentPosition, i - formatRider.CurrentPosition,
newProps, formatRider.SpanPosition);
InvalidateMetrics();
}
}
/// <summary>
/// Sets or changes the font weight
/// </summary>
/// <param name="weight">Font weight</param>
public void SetFontWeight(FontWeight weight)
{
SetFontWeight(weight, 0, _text.Length);
}
/// <summary>
/// Sets or changes the font weight
/// </summary>
/// <param name="weight">Font weight</param>
/// <param name="startIndex">The start index of initial character to apply the change to.</param>
/// <param name="count">The number of characters the change should be applied to.</param>
public void SetFontWeight(FontWeight weight, int startIndex, int count)
{
var limit = ValidateRange(startIndex, count);
for (var i = startIndex; i < limit;)
{
var formatRider = new SpanRider(_formatRuns, _latestPosition, i);
i = Math.Min(limit, i + formatRider.Length);
#pragma warning disable 6506
// Presharp warns that runProps is not validated, but it can never be null
// because the rider is already checked to be in range
if (!(formatRider.CurrentElement is GenericTextRunProperties runProps))
{
throw new NotSupportedException($"{nameof(runProps)} can not be null.");
}
var oldTypeface = runProps.Typeface;
if (oldTypeface.Weight == weight)
{
continue;
}
var newProps = new GenericTextRunProperties(
new Typeface(oldTypeface.FontFamily, oldTypeface.Style, weight),
runProps.FontRenderingEmSize,
runProps.TextDecorations,
runProps.ForegroundBrush,
runProps.BackgroundBrush,
runProps.BaselineAlignment,
runProps.CultureInfo
);
#pragma warning restore 6506
_latestPosition = _formatRuns.SetValue(formatRider.CurrentPosition, i - formatRider.CurrentPosition, newProps, formatRider.SpanPosition);
InvalidateMetrics();
}
}
/// <summary>
/// Sets or changes the font style
/// </summary>
/// <param name="style">Font style</param>
public void SetFontStyle(FontStyle style)
{
SetFontStyle(style, 0, _text.Length);
}
/// <summary>
/// Sets or changes the font style
/// </summary>
/// <param name="style">Font style</param>
/// <param name="startIndex">The start index of initial character to apply the change to.</param>
/// <param name="count">The number of characters the change should be applied to.</param>
public void SetFontStyle(FontStyle style, int startIndex, int count)
{
var limit = ValidateRange(startIndex, count);
for (var i = startIndex; i < limit;)
{
var formatRider = new SpanRider(_formatRuns, _latestPosition, i);
i = Math.Min(limit, i + formatRider.Length);
#pragma warning disable 6506
// Presharp warns that runProps is not validated, but it can never be null
// because the rider is already checked to be in range
if (!(formatRider.CurrentElement is GenericTextRunProperties runProps))
{
throw new NotSupportedException($"{nameof(runProps)} can not be null.");
}
var oldTypeface = runProps.Typeface;
if (oldTypeface.Style == style)
{
continue;
}
var newProps = new GenericTextRunProperties(
new Typeface(oldTypeface.FontFamily, style, oldTypeface.Weight),
runProps.FontRenderingEmSize,
runProps.TextDecorations,
runProps.ForegroundBrush,
runProps.BackgroundBrush,
runProps.BaselineAlignment,
runProps.CultureInfo
);
#pragma warning restore 6506
_latestPosition = _formatRuns.SetValue(formatRider.CurrentPosition, i - formatRider.CurrentPosition, newProps, formatRider.SpanPosition);
InvalidateMetrics(); // invalidate cached metrics
}
}
/// <summary>
/// Sets or changes the type face
/// </summary>
/// <param name="typeface">Typeface</param>
public void SetFontTypeface(Typeface typeface)
{
SetFontTypeface(typeface, 0, _text.Length);
}
/// <summary>
/// Sets or changes the type face
/// </summary>
/// <param name="typeface">Typeface</param>
/// <param name="startIndex">The start index of initial character to apply the change to.</param>
/// <param name="count">The number of characters the change should be applied to.</param>
public void SetFontTypeface(Typeface typeface, int startIndex, int count)
{
var limit = ValidateRange(startIndex, count);
for (var i = startIndex; i < limit;)
{
var formatRider = new SpanRider(_formatRuns, _latestPosition, i);
i = Math.Min(limit, i + formatRider.Length);
#pragma warning disable 6506
// Presharp warns that runProps is not validated, but it can never be null
// because the rider is already checked to be in range
if (!(formatRider.CurrentElement is GenericTextRunProperties runProps))
{
throw new NotSupportedException($"{nameof(runProps)} can not be null.");
}
if (runProps.Typeface == typeface)
{
continue;
}
var newProps = new GenericTextRunProperties(
typeface,
runProps.FontRenderingEmSize,
runProps.TextDecorations,
runProps.ForegroundBrush,
runProps.BackgroundBrush,
runProps.BaselineAlignment,
runProps.CultureInfo
);
#pragma warning restore 6506
_latestPosition = _formatRuns.SetValue(formatRider.CurrentPosition, i - formatRider.CurrentPosition,
newProps, formatRider.SpanPosition);
InvalidateMetrics();
}
}
/// <summary>
/// Sets or changes the text decorations
/// </summary>
/// <param name="textDecorations">Text decorations</param>
public void SetTextDecorations(TextDecorationCollection textDecorations)
{
SetTextDecorations(textDecorations, 0, _text.Length);
}
/// <summary>
/// Sets or changes the text decorations
/// </summary>
/// <param name="textDecorations">Text decorations</param>
/// <param name="startIndex">The start index of initial character to apply the change to.</param>
/// <param name="count">The number of characters the change should be applied to.</param>
public void SetTextDecorations(TextDecorationCollection textDecorations, int startIndex, int count)
{
var limit = ValidateRange(startIndex, count);
for (var i = startIndex; i < limit;)
{
var formatRider = new SpanRider(_formatRuns, _latestPosition, i);
i = Math.Min(limit, i + formatRider.Length);
#pragma warning disable 6506
// Presharp warns that runProps is not validated, but it can never be null
// because the rider is already checked to be in range
if (!(formatRider.CurrentElement is GenericTextRunProperties runProps))
{
throw new NotSupportedException($"{nameof(runProps)} can not be null.");
}
if (runProps.TextDecorations == textDecorations)
{
continue;
}
var newProps = new GenericTextRunProperties(
runProps.Typeface,
runProps.FontRenderingEmSize,
textDecorations,
runProps.ForegroundBrush,
runProps.BackgroundBrush,
runProps.BaselineAlignment,
runProps.CultureInfo
);
#pragma warning restore 6506
_latestPosition = _formatRuns.SetValue(formatRider.CurrentPosition, i - formatRider.CurrentPosition,
newProps, formatRider.SpanPosition);
}
}
/// Note: enumeration is temporarily made private
/// because of PS #828532
///
/// <summary>
/// Strongly typed enumerator used for enumerating text lines
/// </summary>
private struct LineEnumerator : IEnumerator, IDisposable
{
private int _lineCount;
private double _totalHeight;
private TextLine? _nextLine;
private readonly TextFormatter _formatter;
private readonly FormattedText _that;
private readonly ITextSource _textSource;
// these are needed because _currentLine can be disposed before the next MoveNext() call
private double _previousHeight;
// line break before _currentLine, needed in case we have to reformat it with collapsing symbol
private TextLineBreak? _previousLineBreak;
private int _position;
private int _length;
internal LineEnumerator(FormattedText text)
{
_previousHeight = 0;
_length = 0;
_previousLineBreak = null;
_position = 0;
_lineCount = 0;
_totalHeight = 0;
Current = null;
_nextLine = null;
_formatter = TextFormatter.Current;
_that = text;
_textSource = _that._textSourceImpl ??= new TextSourceImplementation(_that);
}
public void Dispose()
{
Current = null;
_nextLine = null;
}
public int Position
{
get => _position;
private set => _position = value;
}
public int Length
{
get => _length;
private set => _length = value;
}
/// <summary>
/// Gets the current text line in the collection
/// </summary>
public TextLine? Current { get; private set; }
/// <summary>
/// Gets the current text line in the collection
/// </summary>
object? IEnumerator.Current => Current;
/// <summary>
/// Gets the paragraph width used to format the current text line
/// </summary>
internal double CurrentParagraphWidth
{
get
{
return MaxLineLength(_lineCount);
}
}
private double MaxLineLength(int line)
{
if (_that._maxTextWidths == null)
return _that._maxTextWidth;
return _that._maxTextWidths[Math.Min(line, _that._maxTextWidths.Length - 1)];
}
/// <summary>
/// Advances the enumerator to the next text line of the collection
/// </summary>
/// <returns>true if the enumerator was successfully advanced to the next element;
/// false if the enumerator has passed the end of the collection</returns>
public bool MoveNext()
{
if (Current == null)
{ // this is the first line
if (_that._text.Length == 0)
{
return false;
}
Current = FormatLine(
_textSource,
Position,
MaxLineLength(_lineCount),
_that._defaultParaProps!,
null // no previous line break
);
// check if this line fits the text height
if (_totalHeight + Current.Height > _that._maxTextHeight)
{
Current = null;
return false;
}
Debug.Assert(_nextLine == null);
}
else
{
// there is no next line or it didn't fit
// either way we're finished
if (_nextLine == null)
{
return false;
}
_totalHeight += _previousHeight;
Position += Length;
++_lineCount;
Current = _nextLine;
_nextLine = null;
}
var currentLineBreak = Current.TextLineBreak;
// this line is guaranteed to fit the text height
Debug.Assert(_totalHeight + Current.Height <= _that._maxTextHeight);
// now, check if the next line fits, we need to do this on this iteration
// because we might need to add ellipsis to the current line
// as a result of the next line measurement
// maybe there is no next line at all
if (Position + Current.Length < _that._text.Length)
{
bool nextLineFits;
if (_lineCount + 1 >= _that._maxLineCount)
{
nextLineFits = false;
}
else
{
_nextLine = FormatLine(
_textSource,
Position + Current.Length,
MaxLineLength(_lineCount + 1),
_that._defaultParaProps,
currentLineBreak
);
nextLineFits = (_totalHeight + Current.Height + _nextLine.Height <= _that._maxTextHeight);
}
if (!nextLineFits)
{
_nextLine = null;
if (_that._trimming != TextTrimming.None && !Current.HasCollapsed)
{
// recreate the current line with ellipsis added
// Note: Paragraph ellipsis is not supported today. We'll workaround
// it here by faking a non-wrap text on finite column width.
var currentWrap = _that._defaultParaProps!.TextWrapping;
_that._defaultParaProps.SetTextWrapping(TextWrapping.NoWrap);
Current = FormatLine(
_that._textSourceImpl!,
Position,
MaxLineLength(_lineCount),
_that._defaultParaProps,
_previousLineBreak
);
currentLineBreak = Current.TextLineBreak;
_that._defaultParaProps.SetTextWrapping(currentWrap);
}
}
}
_previousHeight = Current.Height;
Length = Current.Length;
_previousLineBreak = currentLineBreak;
return true;
}
/// <summary>
/// Wrapper of TextFormatter.FormatLine that auto-collapses the line if needed.
/// </summary>
private TextLine FormatLine(ITextSource textSource, int textSourcePosition, double maxLineLength, TextParagraphProperties paraProps, TextLineBreak? lineBreak)
{
var line = _formatter.FormatLine(
textSource,
textSourcePosition,
maxLineLength,
paraProps,
lineBreak
);
if (_that._trimming != TextTrimming.None && line.HasOverflowed && line.Length > 0)
{
// what I really need here is the last displayed text run of the line
// textSourcePosition + line.Length - 1 works except the end of paragraph case,
// where line length includes the fake paragraph break run
Debug.Assert(_that._text.Length > 0 && textSourcePosition + line.Length <= _that._text.Length + 1);
var thatFormatRider = new SpanRider(
_that._formatRuns,
_that._latestPosition,
Math.Min(textSourcePosition + line.Length - 1, _that._text.Length - 1)
);
var lastRunProps = (GenericTextRunProperties)thatFormatRider.CurrentElement!;
TextCollapsingProperties collapsingProperties = _that._trimming.CreateCollapsingProperties(new TextCollapsingCreateInfo(maxLineLength, lastRunProps));
var collapsedLine = line.Collapse(collapsingProperties);
line = collapsedLine;
}
return line;
}
/// <summary>
/// Sets the enumerator to its initial position,
/// which is before the first element in the collection
/// </summary>
public void Reset()
{
Position = 0;
_lineCount = 0;
_totalHeight = 0;
Current = null;
_nextLine = null;
}
}
/// <summary>
/// Returns an enumerator that can iterate through the text line collection
/// </summary>
private LineEnumerator GetEnumerator()
{
return new LineEnumerator(this);
}
#if NEVER
/// <summary>
/// Returns an enumerator that can iterate through the text line collection
/// </summary>
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
#endif
private void AdvanceLineOrigin(ref Point lineOrigin, TextLine currentLine)
{
var height = currentLine.Height;
// advance line origin according to the flow direction
switch (_defaultParaProps.FlowDirection)
{
case FlowDirection.LeftToRight:
case FlowDirection.RightToLeft:
lineOrigin = lineOrigin.WithY(lineOrigin.Y + height);
break;
}
}
private class CachedMetrics
{
// vertical
public double Height;
public double Baseline;
// horizontal
public double Width;
public double WidthIncludingTrailingWhitespace;
// vertical bounding box metrics
public double Extent;
public double OverhangAfter;
// horizontal bounding box metrics
public double OverhangLeading;
public double OverhangTrailing;
}
/// <summary>
/// Defines the flow direction
/// </summary>
public FlowDirection FlowDirection
{
set
{
ValidateFlowDirection(value, "value");
_defaultParaProps.SetFlowDirection(value);
InvalidateMetrics();
}
get
{
return _defaultParaProps.FlowDirection;
}
}
/// <summary>
/// Defines the alignment of text within the column
/// </summary>
public TextAlignment TextAlignment
{
set
{
_defaultParaProps.SetTextAlignment(value);
InvalidateMetrics();
}
get
{
return _defaultParaProps.TextAlignment;
}
}
/// <summary>
/// Gets or sets the height of, or the spacing between, each line where
/// zero represents the default line height.
/// </summary>
public double LineHeight
{
set
{
if (value < 0)
{
throw new ArgumentOutOfRangeException(nameof(value), "Parameter must be greater than or equal to zero.");
}
_defaultParaProps.SetLineHeight(value);
InvalidateMetrics();
}
get
{
return _defaultParaProps.LineHeight;
}
}
/// <summary>
/// The MaxTextWidth property defines the alignment edges for the FormattedText.
/// For example, left aligned text is wrapped such that the leftmost glyph alignment point
/// on each line falls exactly on the left edge of the rectangle.
/// Note that for many fonts, especially in italic style, some glyph strokes may extend beyond the edges of the alignment rectangle.
/// For this reason, it is recommended that clients draw text with at least 1/6 em (i.e of the font size) unused margin space either side.
/// Zero value of MaxTextWidth is equivalent to the maximum possible paragraph width.
/// </summary>
public double MaxTextWidth
{
set
{
if (value < 0)
{
throw new ArgumentOutOfRangeException(nameof(value), "Parameter must be greater than or equal to zero.");
}
_maxTextWidth = value;
InvalidateMetrics();
}
get
{
return _maxTextWidth;
}
}
/// <summary>
/// Sets the array of lengths,
/// which will be applied to each line of text in turn.
/// If the text covers more lines than there are entries in the length array,
/// the last entry is reused as many times as required.
/// The maxTextWidths array overrides the MaxTextWidth property.
/// </summary>
/// <param name="maxTextWidths">The max text width array</param>
public void SetMaxTextWidths(double[] maxTextWidths)
{
if (maxTextWidths == null || maxTextWidths.Length <= 0)
{
throw new ArgumentNullException(nameof(maxTextWidths));
}
_maxTextWidths = maxTextWidths;
InvalidateMetrics();
}
/// <summary>
/// Obtains a copy of the array of lengths,
/// which will be applied to each line of text in turn.
/// If the text covers more lines than there are entries in the length array,
/// the last entry is reused as many times as required.
/// The maxTextWidths array overrides the MaxTextWidth property.
/// </summary>
/// <returns>The copy of max text width array</returns>
public double[] GetMaxTextWidths()
{
return _maxTextWidths != null ? (double[])_maxTextWidths.Clone() : Array.Empty<double>();
}
/// <summary>
/// Sets the maximum length of a column of text.
/// The last line of text displayed is the last whole line that will fit within this limit,
/// or the nth line as specified by MaxLineCount, whichever occurs first.
/// Use the Trimming property to control how the omission of text is indicated.
/// </summary>
public double MaxTextHeight
{
set
{
if (value <= 0)
{
throw new ArgumentOutOfRangeException(nameof(value), $"'{nameof(MaxTextHeight)}' property value must be greater than zero.");
}
if (double.IsNaN(value))
{
throw new ArgumentOutOfRangeException(nameof(value), $"'{nameof(MaxTextHeight)}' property value cannot be NaN.");
}
_maxTextHeight = value;
InvalidateMetrics();
}
get
{
return _maxTextHeight;
}
}
/// <summary>
/// Defines the maximum number of lines to display.
/// The last line of text displayed is the lineCount-1'th line,
/// or the last whole line that will fit within the count set by MaxTextHeight,
/// whichever occurs first.
/// Use the Trimming property to control how the omission of text is indicated
/// </summary>
public int MaxLineCount
{
set
{
if (value <= 0)
{
throw new ArgumentOutOfRangeException(nameof(value), "The parameter value must be greater than zero.");
}
_maxLineCount = value;
InvalidateMetrics();
}
get
{
return _maxLineCount;
}
}
/// <summary>
/// Defines how omission of text is indicated.
/// CharacterEllipsis trimming allows partial words to be displayed,
/// while WordEllipsis removes whole words to fit.
/// Both guarantee to include an ellipsis ('...') at the end of the lines
/// where text has been trimmed as a result of line and column limits.
/// </summary>
public TextTrimming Trimming
{
set
{
_trimming = value;
_defaultParaProps.SetTextWrapping(_trimming == TextTrimming.None ?
TextWrapping.Wrap :
TextWrapping.WrapWithOverflow);
InvalidateMetrics();
}
get
{
return _trimming;
}
}
/// <summary>
/// Lazily initializes the cached metrics EXCEPT for black box metrics and
/// returns the CachedMetrics structure.
/// </summary>
private CachedMetrics Metrics
{
get
{
return _metrics ??= DrawAndCalculateMetrics(
null, // drawing context
new Point(), // drawing offset
false);
}
}
/// <summary>
/// Lazily initializes the cached metrics INCLUDING black box metrics and
/// returns the CachedMetrics structure.
/// </summary>
private CachedMetrics BlackBoxMetrics
{
get
{
if (_metrics == null || double.IsNaN(_metrics.Extent))
{
// We need to obtain the metrics, including black box metrics.
_metrics = DrawAndCalculateMetrics(
null, // drawing context
new Point(), // drawing offset
true); // calculate black box metrics
}
return _metrics;
}
}
/// <summary>
/// The distance from the top of the first line to the bottom of the last line.
/// </summary>
public double Height
{
get
{
return Metrics.Height;
}
}
/// <summary>
/// The distance from the topmost black pixel of the first line
/// to the bottommost black pixel of the last line.
/// </summary>
public double Extent
{
get
{
return BlackBoxMetrics.Extent;
}
}
/// <summary>
/// The distance from the top of the first line to the baseline of the first line.
/// </summary>
public double Baseline
{
get
{
return Metrics.Baseline;
}
}
/// <summary>
/// The distance from the bottom of the last line to the extent bottom.
/// </summary>
public double OverhangAfter
{
get
{
return BlackBoxMetrics.OverhangAfter;
}
}
/// <summary>
/// The maximum distance from the leading black pixel to the leading alignment point of a line.
/// </summary>
public double OverhangLeading
{
get
{
return BlackBoxMetrics.OverhangLeading;
}
}
/// <summary>
/// The maximum distance from the trailing black pixel to the trailing alignment point of a line.
/// </summary>
public double OverhangTrailing
{
get
{
return BlackBoxMetrics.OverhangTrailing;
}
}
/// <summary>
/// The maximum advance width between the leading and trailing alignment points of a line,
/// excluding the width of whitespace characters at the end of the line.
/// </summary>
public double Width
{
get
{
return Metrics.Width;
}
}
/// <summary>
/// The maximum advance width between the leading and trailing alignment points of a line,
/// including the width of whitespace characters at the end of the line.
/// </summary>
public double WidthIncludingTrailingWhitespace
{
get
{
return Metrics.WidthIncludingTrailingWhitespace;
}
}
/// <summary>
/// Obtains geometry for the text, including underlines and strikethroughs.
/// </summary>
/// <param name="origin">The left top origin of the resulting geometry.</param>
/// <returns>The geometry returned contains the combined geometry
/// of all of the glyphs, underlines and strikeThroughs that represent the formatted text.
/// Overlapping contours are merged by performing a Boolean union operation.</returns>
public Geometry? BuildGeometry(Point origin)
{
GeometryGroup? accumulatedGeometry = null;
var lineOrigin = origin;
DrawingGroup drawing = new DrawingGroup();
using (var ctx = drawing.Open())
{
using (var enumerator = GetEnumerator())
{
while (enumerator.MoveNext())
{
var currentLine = enumerator.Current;
if (currentLine != null)
{
currentLine.Draw(ctx, lineOrigin);
AdvanceLineOrigin(ref lineOrigin, currentLine);
}
}
}
}
Transform? transform = new TranslateTransform(origin.X, origin.Y);
// recursively go down the DrawingGroup to build up the geometry
CombineGeometryRecursive(drawing, ref transform, ref accumulatedGeometry);
return accumulatedGeometry;
}
/// <summary>
/// Builds a highlight geometry object.
/// </summary>
/// <param name="origin">The origin of the highlight region</param>
/// <returns>Geometry that surrounds the text.</returns>
public Geometry? BuildHighlightGeometry(Point origin)
{
return BuildHighlightGeometry(origin, 0, _text.Length);
}
/// <summary>
/// Builds a highlight geometry object for a given character range.
/// </summary>
/// <param name="origin">The origin of the highlight region.</param>
/// <param name="startIndex">The start index of initial character the bounds should be obtained for.</param>
/// <param name="count">The number of characters the bounds should be obtained for.</param>
/// <returns>Geometry that surrounds the specified character range.</returns>
public Geometry? BuildHighlightGeometry(Point origin, int startIndex, int count)
{
ValidateRange(startIndex, count);
Geometry? accumulatedBounds = null;
using (var enumerator = GetEnumerator())
{
var lineOrigin = origin;
while (enumerator.MoveNext())
{
var currentLine = enumerator.Current!;
int x0 = Math.Max(enumerator.Position, startIndex);
int x1 = Math.Min(enumerator.Position + enumerator.Length, startIndex + count);
// check if this line is intersects with the specified character range
if (x0 < x1)
{
var highlightBounds = currentLine.GetTextBounds(x0,x1 - x0);
if (highlightBounds != null)
{
foreach (var bound in highlightBounds)
{
var rect = bound.Rectangle;
if (FlowDirection == FlowDirection.RightToLeft)
{
// Convert logical units (which extend leftward from the right edge
// of the paragraph) to physical units.
//
// Note that since rect is in logical units, rect.Right corresponds to
// the visual *left* edge of the rectangle in the RTL case. Specifically,
// is the distance leftward from the right edge of the formatting rectangle
// whose width is the paragraph width passed to FormatLine.
//
rect = rect.WithX(enumerator.CurrentParagraphWidth - rect.Right);
}
rect = new Rect(new Point(rect.X + lineOrigin.X, rect.Y + lineOrigin.Y), rect.Size);
RectangleGeometry rectangleGeometry = new RectangleGeometry(rect);
if (accumulatedBounds == null)
{
accumulatedBounds = rectangleGeometry;
}
else
{
accumulatedBounds = Geometry.Combine(accumulatedBounds, rectangleGeometry, GeometryCombineMode.Union);
}
}
}
}
AdvanceLineOrigin(ref lineOrigin, currentLine);
}
}
if (accumulatedBounds?.PlatformImpl == null || accumulatedBounds.PlatformImpl.Bounds.IsEmpty)
{
return null;
}
return accumulatedBounds;
}
/// <summary>
/// Draws the text object
/// </summary>
internal void Draw(DrawingContext drawingContext, Point origin)
{
var lineOrigin = origin;
if (_metrics != null && !double.IsNaN(_metrics.Extent))
{
// we can't use foreach because it requires GetEnumerator and associated classes to be public
// foreach (TextLine currentLine in this)
using (var enumerator = GetEnumerator())
{
while (enumerator.MoveNext())
{
var currentLine = enumerator.Current!;
currentLine.Draw(drawingContext, lineOrigin);
AdvanceLineOrigin(ref lineOrigin, currentLine);
}
}
}
else
{
// Calculate metrics as we draw to avoid formatting again if we need metrics later; we compute
// black box metrics too because these are already known as a side-effect of drawing
_metrics = DrawAndCalculateMetrics(drawingContext, origin, true);
}
}
private void CombineGeometryRecursive(Drawing drawing, ref Transform? transform, ref GeometryGroup? accumulatedGeometry)
{
if (drawing is DrawingGroup group)
{
transform = group.Transform;
if (group.Children is DrawingCollection children)
{
// recursively go down for DrawingGroup
foreach (var child in children)
{
CombineGeometryRecursive(child, ref transform, ref accumulatedGeometry);
}
}
}
else
{
if (drawing is GlyphRunDrawing glyphRunDrawing)
{
// process glyph run
var glyphRun = glyphRunDrawing.GlyphRun;
if (glyphRun != null)
{
var glyphRunGeometry = glyphRun.BuildGeometry();
glyphRunGeometry.Transform = transform;
if (accumulatedGeometry == null)
{
accumulatedGeometry = new GeometryGroup
{
FillRule = FillRule.NonZero
};
}
accumulatedGeometry.Children.Add(glyphRunGeometry);
}
}
else
{
if (drawing is GeometryDrawing geometryDrawing)
{
// process geometry (i.e. TextDecoration on the line)
var geometry = geometryDrawing.Geometry;
if (geometry != null)
{
geometry.Transform = transform;
if (geometry is LineGeometry lineGeometry)
{
// For TextDecoration drawn by DrawLine(), the geometry is a LineGeometry which has no
// bounding area. So this line won't show up. Work aroud it by increase the Bounding rect
// to be Pen's thickness
var bounds = lineGeometry.Bounds;
if (bounds.Height == 0)
{
bounds = bounds.WithHeight(geometryDrawing.Pen?.Thickness ?? 0);
}
else if (bounds.Width == 0)
{
bounds = bounds.WithWidth(geometryDrawing.Pen?.Thickness ?? 0);
}
// convert the line geometry into a rectangle geometry
// we lost line cap info here
geometry = new RectangleGeometry(bounds);
}
if (accumulatedGeometry == null)
{
accumulatedGeometry = new GeometryGroup
{
FillRule = FillRule.NonZero
};
}
accumulatedGeometry.Children.Add(geometry);
}
}
}
}
}
private CachedMetrics DrawAndCalculateMetrics(DrawingContext? drawingContext, Point drawingOffset, bool getBlackBoxMetrics)
{
var metrics = new CachedMetrics();
if (_text.Length == 0)
{
return metrics;
}
// we can't use foreach because it requires GetEnumerator and associated classes to be public
// foreach (TextLine currentLine in this)
using (var enumerator = GetEnumerator())
{
var first = true;
double accBlackBoxLeft, accBlackBoxTop, accBlackBoxRight, accBlackBoxBottom;
accBlackBoxLeft = accBlackBoxTop = double.MaxValue;
accBlackBoxRight = accBlackBoxBottom = double.MinValue;
var origin = new Point(0, 0);
// Holds the TextLine.Start of the longest line. Thus it will hold the minimum value
// of TextLine.Start among all the lines that forms the text. The overhangs (leading and trailing)
// are calculated with an offset as a result of the same issue with TextLine.Start.
// So, we compute this offset and remove it later from the values of the overhangs.
var lineStartOfLongestLine = double.MaxValue;
while (enumerator.MoveNext())
{
// enumerator will dispose the currentLine
var currentLine = enumerator.Current!;
// if we're drawing, do it first as this will compute black box metrics as a side-effect
if (drawingContext != null)
{
currentLine.Draw(drawingContext,
new Point(origin.X + drawingOffset.X, origin.Y + drawingOffset.Y));
}
if (getBlackBoxMetrics)
{
var blackBoxLeft = origin.X + currentLine.Start + currentLine.OverhangLeading;
var blackBoxRight = origin.X + currentLine.Start + currentLine.Width - currentLine.OverhangTrailing;
var blackBoxBottom = origin.Y + currentLine.Height + currentLine.OverhangAfter;
var blackBoxTop = blackBoxBottom - currentLine.Extent;
accBlackBoxLeft = Math.Min(accBlackBoxLeft, blackBoxLeft);
accBlackBoxRight = Math.Max(accBlackBoxRight, blackBoxRight);
accBlackBoxBottom = Math.Max(accBlackBoxBottom, blackBoxBottom);
accBlackBoxTop = Math.Min(accBlackBoxTop, blackBoxTop);
metrics.OverhangAfter = currentLine.OverhangAfter;
}
metrics.Height += currentLine.Height;
metrics.Width = Math.Max(metrics.Width, currentLine.Width);
metrics.WidthIncludingTrailingWhitespace = Math.Max(metrics.WidthIncludingTrailingWhitespace, currentLine.WidthIncludingTrailingWhitespace);
lineStartOfLongestLine = Math.Min(lineStartOfLongestLine, currentLine.Start);
if (first)
{
metrics.Baseline = currentLine.Baseline;
first = false;
}
AdvanceLineOrigin(ref origin, currentLine);
}
if (getBlackBoxMetrics)
{
metrics.Extent = accBlackBoxBottom - accBlackBoxTop;
metrics.OverhangLeading = accBlackBoxLeft - lineStartOfLongestLine;
metrics.OverhangTrailing = metrics.Width - (accBlackBoxRight - lineStartOfLongestLine);
}
else
{
// indicate that black box metrics are not known
metrics.Extent = double.NaN;
}
}
return metrics;
}
private class TextSourceImplementation : ITextSource
{
private readonly FormattedText _that;
public TextSourceImplementation(FormattedText text)
{
_that = text;
}
/// <inheritdoc/>
public TextRun? GetTextRun(int textSourceCharacterIndex)
{
if (textSourceCharacterIndex >= _that._text.Length)
{
return null;
}
var thatFormatRider = new SpanRider(_that._formatRuns, _that._latestPosition, textSourceCharacterIndex);
TextRunProperties properties = (GenericTextRunProperties)thatFormatRider.CurrentElement!;
var textCharacters = new TextCharacters(_that._text, textSourceCharacterIndex, thatFormatRider.Length,
properties);
return textCharacters;
}
}
}
}