using System; using System.Collections; using System.ComponentModel; using System.Diagnostics; using System.Globalization; using Avalonia.Media.TextFormatting; using Avalonia.Utilities; namespace Avalonia.Media { /// /// The FormattedText class is targeted at programmers needing to add some simple text to a MIL visual. /// 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 _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; /// /// Construct a FormattedText object. /// /// String of text to be displayed. /// Culture of text. /// Flow direction of text. /// Type face used to display text. /// Font em size in visual units (1/96 of an inch). /// Foreground brush used to render text. 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(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 ); 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; } /// /// Sets foreground brush used for drawing text /// /// Foreground brush public void SetForegroundBrush(IBrush foregroundBrush) { SetForegroundBrush(foregroundBrush, 0, _text.Length); } /// /// Sets foreground brush used for drawing text /// /// Foreground brush /// The start index of initial character to apply the change to. /// The number of characters the change should be applied to. 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); } } /// /// Sets or changes the font family for the text object /// /// Font family name public void SetFontFamily(string fontFamily) { SetFontFamily(fontFamily, 0, _text.Length); } /// /// Sets or changes the font family for the text object /// /// Font family name /// The start index of initial character to apply the change to. /// The number of characters the change should be applied to. public void SetFontFamily(string fontFamily, int startIndex, int count) { if (fontFamily == null) { throw new ArgumentNullException(nameof(fontFamily)); } SetFontFamily(new FontFamily(fontFamily), startIndex, count); } /// /// Sets or changes the font family for the text object /// /// Font family public void SetFontFamily(FontFamily fontFamily) { SetFontFamily(fontFamily, 0, _text.Length); } /// /// Sets or changes the font family for the text object /// /// Font family /// The start index of initial character to apply the change to. /// The number of characters the change should be applied to. 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(); } } /// /// Sets or changes the font em size measured in MIL units /// /// Font em size public void SetFontSize(double emSize) { SetFontSize(emSize, 0, _text.Length); } /// /// Sets or changes the font em size measured in MIL units /// /// Font em size /// The start index of initial character to apply the change to. /// The number of characters the change should be applied to. 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(); } } /// /// Sets or changes the culture for the text object. /// /// The new culture for the text object. public void SetCulture(CultureInfo culture) { SetCulture(culture, 0, _text.Length); } /// /// Sets or changes the culture for the text object. /// /// The new culture for the text object. /// The start index of initial character to apply the change to. /// The number of characters the change should be applied to. 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(); } } /// /// Sets or changes the font weight /// /// Font weight public void SetFontWeight(FontWeight weight) { SetFontWeight(weight, 0, _text.Length); } /// /// Sets or changes the font weight /// /// Font weight /// The start index of initial character to apply the change to. /// The number of characters the change should be applied to. 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(); } } /// /// Sets or changes the font style /// /// Font style public void SetFontStyle(FontStyle style) { SetFontStyle(style, 0, _text.Length); } /// /// Sets or changes the font style /// /// Font style /// The start index of initial character to apply the change to. /// The number of characters the change should be applied to. 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 } } /// /// Sets or changes the type face /// /// Typeface public void SetFontTypeface(Typeface typeface) { SetFontTypeface(typeface, 0, _text.Length); } /// /// Sets or changes the type face /// /// Typeface /// The start index of initial character to apply the change to. /// The number of characters the change should be applied to. 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(); } } /// /// Sets or changes the text decorations /// /// Text decorations public void SetTextDecorations(TextDecorationCollection textDecorations) { SetTextDecorations(textDecorations, 0, _text.Length); } /// /// Sets or changes the text decorations /// /// Text decorations /// The start index of initial character to apply the change to. /// The number of characters the change should be applied to. 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 /// /// /// Strongly typed enumerator used for enumerating text lines /// 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; 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; } private int Position { get; set; } private int Length { get; set; } /// /// Gets the current text line in the collection /// public TextLine? Current { get; private set; } /// /// Gets the current text line in the collection /// object? IEnumerator.Current => Current; /// /// Gets the paragraph width used to format the current text line /// 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)]; } /// /// Advances the enumerator to the next text line of the collection /// /// true if the enumerator was successfully advanced to the next element; /// false if the enumerator has passed the end of the collection 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; } /// /// Wrapper of TextFormatter.FormatLine that auto-collapses the line if needed. /// 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; } /// /// Sets the enumerator to its initial position, /// which is before the first element in the collection /// public void Reset() { Position = 0; _lineCount = 0; _totalHeight = 0; Current = null; _nextLine = null; } } /// /// Returns an enumerator that can iterate through the text line collection /// private LineEnumerator GetEnumerator() { return new LineEnumerator(this); } #if NEVER /// /// Returns an enumerator that can iterate through the text line collection /// 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; } /// /// Defines the flow direction /// public FlowDirection FlowDirection { set { ValidateFlowDirection(value, "value"); _defaultParaProps.SetFlowDirection(value); InvalidateMetrics(); } get { return _defaultParaProps.FlowDirection; } } /// /// Defines the alignment of text within the column /// public TextAlignment TextAlignment { set { _defaultParaProps.SetTextAlignment(value); InvalidateMetrics(); } get { return _defaultParaProps.TextAlignment; } } /// /// Gets or sets the height of, or the spacing between, each line where /// zero represents the default line height. /// 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; } } /// /// 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. /// 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; } } /// /// 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. /// /// The max text width array public void SetMaxTextWidths(double[] maxTextWidths) { if (maxTextWidths == null || maxTextWidths.Length <= 0) { throw new ArgumentNullException(nameof(maxTextWidths)); } _maxTextWidths = maxTextWidths; InvalidateMetrics(); } /// /// 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. /// /// The copy of max text width array public double[] GetMaxTextWidths() { return _maxTextWidths != null ? (double[])_maxTextWidths.Clone() : Array.Empty(); } /// /// 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. /// 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; } } /// /// 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 /// 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; } } /// /// 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. /// public TextTrimming Trimming { set { _trimming = value; _defaultParaProps.SetTextWrapping(_trimming == TextTrimming.None ? TextWrapping.Wrap : TextWrapping.WrapWithOverflow); InvalidateMetrics(); } get { return _trimming; } } /// /// Lazily initializes the cached metrics EXCEPT for black box metrics and /// returns the CachedMetrics structure. /// private CachedMetrics Metrics { get { return _metrics ??= DrawAndCalculateMetrics( null, // drawing context new Point(), // drawing offset false); } } /// /// Lazily initializes the cached metrics INCLUDING black box metrics and /// returns the CachedMetrics structure. /// 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; } } /// /// The distance from the top of the first line to the bottom of the last line. /// public double Height { get { return Metrics.Height; } } /// /// The distance from the topmost black pixel of the first line /// to the bottommost black pixel of the last line. /// public double Extent { get { return BlackBoxMetrics.Extent; } } /// /// The distance from the top of the first line to the baseline of the first line. /// public double Baseline { get { return Metrics.Baseline; } } /// /// The distance from the bottom of the last line to the extent bottom. /// public double OverhangAfter { get { return BlackBoxMetrics.OverhangAfter; } } /// /// The maximum distance from the leading black pixel to the leading alignment point of a line. /// public double OverhangLeading { get { return BlackBoxMetrics.OverhangLeading; } } /// /// The maximum distance from the trailing black pixel to the trailing alignment point of a line. /// public double OverhangTrailing { get { return BlackBoxMetrics.OverhangTrailing; } } /// /// 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. /// public double Width { get { return Metrics.Width; } } /// /// 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. /// public double WidthIncludingTrailingWhitespace { get { return Metrics.WidthIncludingTrailingWhitespace; } } /// /// Obtains geometry for the text, including underlines and strikethroughs. /// /// The left top origin of the resulting geometry. /// 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. 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; } /// /// Draws the text object /// 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; } /// 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; } } } }