using Amphora; using Azimuth; using Azimuth.Polynomials; using Glass.Typesetting; using System; namespace Glass.PerformanceTests { /// /// Glass's typesetting engine before Glass.Typesetting replaced it, frozen here as the benchmark's baseline. /// public static class TextLayout { public const char EllipsisGlyph = '\u2026'; public static readonly StringView HorizontalEllipsis = "\u2026"; public static readonly StringView ThreePeriods = "..."; /// /// The marker to end truncated text with, preferring a real ellipsis over three periods. /// public static StringView TruncationMarkerFor(IFontMetrics font) { if (font == null) { throw new ArgumentNullException(nameof(font)); } return font.HasGlyph(EllipsisGlyph) ? HorizontalEllipsis : ThreePeriods; } public static Scalar CalculateTextWidth(IFontMetrics font, in StringView text) { KerningClass previousLeftClass = KerningClass.None; return CalculateTextWidth(font, text, ref previousLeftClass); } /// /// Calculates the width of `text`. /// public static Scalar CalculateTextWidth( IFontMetrics font, in StringView text, ref KerningClass previousLeftClass) { if (font == null) { throw new ArgumentNullException(nameof(font)); } GlyphMetricsTable metricsTable = font.MetricsTable; KerningTable kerningTable = font.KerningTable; Scalar textWidth = 0; foreach (char character in text) { GlyphMetrics metrics = metricsTable[character]; textWidth += kerningTable.SpaceBetweenClasses(previousLeftClass, metrics.RightKerningClass) + metrics.Width; previousLeftClass = metrics.LeftKerningClass; } return textWidth; } /// /// Splits `text` at the natural line break. Note that this doesn't do anything special around word wrap, /// newline characters, etc., it just finds the character that would cause the text to go passed /// `widthRemaining` and splits there. /// public static (StringView, StringView) SplitAtLineBreak(in StringView text, IFontMetrics font, KerningClass previousLeftClass, Scalar widthRemaining, out Scalar widthUsed) { if (font == null) { throw new ArgumentNullException(nameof(font)); } if (widthRemaining < 0) { throw new ArgumentOutOfRangeException( nameof(widthRemaining), $"widthRemaining must be non-negative, but was {widthRemaining}"); } GlyphMetricsTable metricsTable = font.MetricsTable; KerningTable kerningTable = font.KerningTable; widthUsed = 0; int i; for (i = 0; i < text.Length; ++i) { GlyphMetrics metrics = metricsTable[text[i]]; Scalar spaceBetweenGlyphs = kerningTable.SpaceBetweenClasses(previousLeftClass, metrics.RightKerningClass); previousLeftClass = metrics.LeftKerningClass; Scalar glyphWidth = metrics.Width; if (widthUsed + spaceBetweenGlyphs + glyphWidth <= widthRemaining) { widthUsed += spaceBetweenGlyphs + glyphWidth; } else { break; } } return text.SplitAt(i); } /// /// `width` (as measured against `previousLeftClass`) with that leading kerning pair removed, leaving only /// the part of the measurement that stays correct no matter what ends up actually preceding `text`. /// private static Scalar WithoutLeadingKerning(IFontMetrics font, Scalar width, KerningClass previousLeftClass, in StringView text) { return text.Empty ? width : width - font.KerningTable.SpaceBetweenClasses( previousLeftClass, font.MetricsTable[text[0]].RightKerningClass); } public delegate void LinePlacementCallback( ref TPayload payload, in StringView text, Scalar lineWidth, Vector startingPosition); public struct LinePlacementCalculator { public Scalar LineWidth; public Scalar MaxUsedWidth; public readonly Scalar MaxWidth; public readonly Scalar MaxHeight; public Scalar CumulativeHeight; public readonly IFontMetrics Font; public readonly LinePlacementCallback Callback; public readonly StringView Text; /// /// Handed back to the callback on every line, and passed by reference so the callback can accumulate /// into it. /// public TPayload Payload; public readonly TextOverflow OverflowBehavior; /// /// A hard cap on how many lines may be placed, or null for no cap. Must be 1 or larger if set. /// public readonly int? MaxLines; public int LinesEmitted; /// /// Where the current line starts, relative to `Text`. /// public int lineStartIndex; /// /// How far into `Text` has been consumed. /// public int currentIndex; public Vector CurrentSize => new Vector(MaxUsedWidth, CumulativeHeight); public LinePlacementCalculator(StringView text, IFontMetrics font, Scalar maxWidth, LinePlacementCallback callback, TPayload payload = default, int? maxLines = null) : this(text, font, maxWidth, Scalar.PositiveInfinity, TextOverflow.Clip, callback, payload, maxLines) { } public LinePlacementCalculator(StringView text, IFontMetrics font, Scalar maxWidth, Scalar maxHeight, TextOverflow overflow, LinePlacementCallback callback, TPayload payload = default, int? maxLines = null) { if (maxLines != null && maxLines < 1) { throw new ArgumentOutOfRangeException( nameof(maxLines), $"maxLines must be at least 1, was {maxLines}"); } Payload = payload; CumulativeHeight = font.LineAscent; Font = font; LineWidth = 0; MaxUsedWidth = 0; MaxWidth = maxWidth; MaxHeight = maxHeight; Callback = callback; Text = text; OverflowBehavior = overflow; Truncation = null; MaxLines = maxLines; LinesEmitted = 0; lineStartIndex = 0; currentIndex = 0; } /// /// How text was dropped to make it fit, or null if the text fit without truncation. /// public TextOverflow? Truncation; private void MarkClipped() { Truncation = Truncation ?? TextOverflow.Clip; } public Scalar LineSpacing => Font.LineGap + Font.LineAscent + Font.LineDescent; public Scalar EndOfCurrentLine => CumulativeHeight + Font.LineDescent; public Scalar StartOfCurrentLine => CumulativeHeight - Font.LineAscent; public bool OnFirstLine => CumulativeHeight == Font.LineAscent; /// /// currentIndex's index into the underlying string. /// public int AbsoluteCurrentIndex => Text.StartIndex + currentIndex; /// /// Whether this line gets placed but the one after it would not, making this the one to mark. /// public bool LastLine => CanEmitMoreText && ((MaxLines != null && LinesEmitted + 1 >= MaxLines) || EndOfCurrentLine + LineSpacing > MaxHeight); /// /// Whether any visible text lies from `start` up to, not including, `end`. /// private bool HasVisibleText(int start, int end) { for (int index = start; index < end; ++index) { if (!char.IsWhiteSpace(Text[index])) { return true; } } return false; } /// /// Whether anything visible is left to place after the break being taken. /// public bool MoreTextFollows => HasVisibleText(currentIndex, Text.Length); /// /// Whether the line about to be placed still has somewhere to go. /// public bool CanEmitMoreText => (MaxLines == null || LinesEmitted < MaxLines) && (OnFirstLine || // A marker needs a whole line to sit on, so this one's bottom has to be inside the budget. (OverflowBehavior == TextOverflow.Ellipsis && EndOfCurrentLine <= MaxHeight) || // Only the top need be inside: a partly visible line still draws and the clip window cuts it. (OverflowBehavior == TextOverflow.Clip && StartOfCurrentLine < MaxHeight)); public void SizeLastLine( out StringView lastText, out Scalar textWidth, out StringView ellipsis, out Scalar ellipsisWidth) { StringView text = Text[lineStartIndex, currentIndex]; ellipsis = TruncationMarkerFor(Font); // Cut trailing whitespace so the ellipsis isn't floating. int endIndex = text.Length; while (endIndex > 0 && char.IsWhiteSpace(text[endIndex - 1])) { --endIndex; } // Running out of text lets a marker too wide for the line overrun and get clipped. KerningClass markerPreviousLeftClass = KerningClass.None; int keptLength = 0; textWidth = 0; ellipsisWidth = CalculateTextWidth(Font, ellipsis, ref markerPreviousLeftClass); // Kerning can make a shorter prefix wider, so every prefix is tried and the longest that fits wins. GlyphMetricsTable metricsTable = Font.MetricsTable; KerningTable kerningTable = Font.KerningTable; KerningClass previousLeftClass = KerningClass.None; Scalar prefixWidth = 0; for (int prefixLength = 1; prefixLength <= endIndex; ++prefixLength) { GlyphMetrics metrics = metricsTable[text[prefixLength - 1]]; prefixWidth += kerningTable.SpaceBetweenClasses(previousLeftClass, metrics.RightKerningClass) + metrics.Width; previousLeftClass = metrics.LeftKerningClass; markerPreviousLeftClass = previousLeftClass; Scalar markerWidth = CalculateTextWidth(Font, ellipsis, ref markerPreviousLeftClass); if (prefixWidth + markerWidth <= MaxWidth) { keptLength = prefixLength; textWidth = prefixWidth; ellipsisWidth = markerWidth; } } lastText = text[0, keptLength]; } /// /// Places the current line as text plus a trailing marker, giving text back until the marker fits. /// public void EmitMarkedLine() { SizeLastLine(out StringView lastText, out Scalar textWidth, out StringView ellipsis, out Scalar ellipsisWidth); Truncation = TextOverflow.Ellipsis; LineWidth = textWidth + ellipsisWidth; if (textWidth > 0) { Callback?.Invoke(ref Payload, lastText, LineWidth, new Vector(0, CumulativeHeight)); } Scalar markerOffset = LineWidth - CalculateTextWidth(Font, ellipsis); Callback?.Invoke(ref Payload, ellipsis, LineWidth, new Vector(markerOffset, CumulativeHeight)); } public void Newline() { if (CanEmitMoreText) { if (OverflowBehavior == TextOverflow.Ellipsis && LastLine && MoreTextFollows) { EmitMarkedLine(); } else { Callback?.Invoke(ref Payload, Text[lineStartIndex, currentIndex], LineWidth, new Vector(0, CumulativeHeight)); } CumulativeHeight += LineSpacing; MaxUsedWidth = Math.Max(MaxUsedWidth, LineWidth); LinesEmitted++; } else if (HasVisibleText(lineStartIndex, currentIndex)) { MarkClipped(); } LineWidth = 0; lineStartIndex = currentIndex; } /// /// Appends `word`, splitting it across lines if it's too wide to fit as a whole. /// /// /// `word`'s width excluding the kerning between `previousLeftClass` and its first character. /// public void CharacterWrapWord(in StringView word, KerningClass previousLeftClass, Scalar? knownWidth = null) { if (word.Empty) { return; } if (word.StartIndex != AbsoluteCurrentIndex) { throw new InvalidOperationException("Appending words out of order. Current index " + $"{AbsoluteCurrentIndex} doesn't match word start index {word.StartIndex}"); } if (LineWidth > MaxWidth) { Newline(); } if (knownWidth.HasValue) { Scalar fullWidth = knownWidth.Value + Font.KerningTable.SpaceBetweenClasses( previousLeftClass, Font.MetricsTable[word[0]].RightKerningClass); if (LineWidth + fullWidth <= MaxWidth) { LineWidth += fullWidth; currentIndex += word.Length; return; } } (StringView lhs, StringView rhs) = SplitAtLineBreak( word, Font, previousLeftClass, MaxWidth - LineWidth, out Scalar widthUsed); if (lhs.Empty && !rhs.Empty && LineWidth > 0) { Newline(); } else { LineWidth += widthUsed; currentIndex += lhs.Length; } while (rhs.Length > 0) { if (widthUsed > 0) { Newline(); } (lhs, rhs) = SplitAtLineBreak(rhs, Font, previousLeftClass: KerningClass.None, MaxWidth, out widthUsed); if (lhs.Empty) { // MaxWidth isn't actually wide enough to allow even one character, but we force one character // through to keep things moving. (lhs, rhs) = rhs.SplitAt(1); GlyphMetrics metrics = Font.MetricsTable[lhs[0]]; widthUsed = Font.KerningTable.SpaceBetweenClasses(KerningClass.None, metrics.RightKerningClass) + metrics.Width; } LineWidth += widthUsed; currentIndex += lhs.Length; } } public void AppendWord(in StringView word, Scalar wordWidth) { if (word.Text != Text.Text) { throw new ArgumentException($"{word} is not a string view of {Text}", nameof(word)); } if (word.StartIndex != AbsoluteCurrentIndex) { throw new InvalidOperationException("Appending words out of order. Current index " + $"{AbsoluteCurrentIndex} doesn't match word start index {word.StartIndex}"); } if (LineWidth + wordWidth > MaxWidth) { throw new ArgumentException( $"Word is {wordWidth} wide, but only {MaxWidth - LineWidth} space is left."); } // We can just append the word as normal. LineWidth += wordWidth; currentIndex += word.Length; } /// /// Given a text string, will return the end of the current word (non-whitespace characters) and the whitespace /// after that. Returns the rest of the text. /// public (StringView, StringView) PeekNextWordAndSpace() { int wordStart = currentIndex; int wordEnd = wordStart; // Indexes Text.Text directly for performance reasons. while (wordEnd < Text.Length && !char.IsWhiteSpace(Text.Text[Text.StartIndex + wordEnd])) { ++wordEnd; } int spacesEnd = wordEnd; while (spacesEnd < Text.Length) { char character = Text.Text[Text.StartIndex + spacesEnd]; if (!char.IsWhiteSpace(character) || character == '\r' || character == '\n') { break; } ++spacesEnd; } return (Text[wordStart, wordEnd], Text[wordEnd, spacesEnd]); } public bool MaybeConsumeOneNewline() { if (currentIndex >= Text.Length) { return false; } if (Text[currentIndex] != '\r' && Text[currentIndex] != '\n') { return false; } Newline(); // Treat \n and \r\n both as a single newline. if (currentIndex < Text.Length && (Text[currentIndex] == '\r')) { ++currentIndex; } if (currentIndex < Text.Length && (Text[currentIndex] == '\n')) { ++currentIndex; } lineStartIndex = currentIndex; return true; } public void Finish() { if (LineWidth == 0 || !CanEmitMoreText) { if (HasVisibleText(lineStartIndex, Text.Length)) { MarkClipped(); } CumulativeHeight -= Font.LineAscent + Font.LineGap; } else { Callback?.Invoke(ref Payload, Text[lineStartIndex, null], LineWidth, new Vector(0, CumulativeHeight)); CumulativeHeight += Font.LineDescent; MaxUsedWidth = Math.Max(MaxUsedWidth, LineWidth); LinesEmitted++; } LineWidth = 0; // Narrower than a single glyph overflows sideways instead of dropping lines. if (MaxUsedWidth > MaxWidth) { MarkClipped(); } } } public static Vector WordWrap(IFontMetrics font, in StringView text, Scalar maxWidth, Scalar? maxHeight, TextOverflow overflow, ref TPayload payload, out TextOverflow? truncation, int? maxLines, LinePlacementCallback linePlacementCallback) { truncation = null; if (text.Empty) { return Vector.Zero; } if (font == null) { return Vector.Zero; } LinePlacementCalculator calculator = new LinePlacementCalculator( text, font, maxWidth, maxHeight ?? Scalar.PositiveInfinity, overflow, linePlacementCallback, payload, maxLines); KerningClass previousLeftClass = KerningClass.None; while (calculator.currentIndex < calculator.Text.Length && calculator.CanEmitMoreText) { (StringView word, StringView whitespace) = calculator.PeekNextWordAndSpace(); KerningClass wordPreviousLeftClass = previousLeftClass; Scalar wordWidth = CalculateTextWidth(font, word, ref previousLeftClass); KerningClass whitespacePreviousLeftClass = previousLeftClass; Scalar whitespaceWidth = CalculateTextWidth(font, whitespace, ref previousLeftClass); if (wordWidth + whitespaceWidth <= maxWidth) { if (calculator.LineWidth + wordWidth + whitespaceWidth <= maxWidth) { calculator.AppendWord(word, wordWidth); calculator.AppendWord(whitespace, whitespaceWidth); } else { // Start a new line and put these two on it. Both are already known to fit it whole, since // together they fit maxWidth on an empty line. calculator.Newline(); calculator.CharacterWrapWord(word, KerningClass.None, WithoutLeadingKerning(font, wordWidth, wordPreviousLeftClass, word)); calculator.CharacterWrapWord(whitespace, whitespacePreviousLeftClass, WithoutLeadingKerning(font, whitespaceWidth, whitespacePreviousLeftClass, whitespace)); } } else if (wordWidth <= maxWidth) { if (calculator.LineWidth + wordWidth <= maxWidth) { // Put the word on the current line, put as much whitespace as will fit, then make new line(s) // for the remaining whitespace. calculator.AppendWord(word, wordWidth); calculator.CharacterWrapWord(whitespace, whitespacePreviousLeftClass); } else { // Put the new word and as much whitespace as will fit on the next line, then make new line(s) // for the remaining whitespace. calculator.Newline(); calculator.CharacterWrapWord(word, KerningClass.None, WithoutLeadingKerning(font, wordWidth, wordPreviousLeftClass, word)); calculator.CharacterWrapWord(whitespace, whitespacePreviousLeftClass); } } else { // As a final fallback, put as much of the word as will fit on the line, then put the remaining // word + whitespace characters on new line(s). calculator.CharacterWrapWord(word, wordPreviousLeftClass); calculator.CharacterWrapWord(whitespace, whitespacePreviousLeftClass); } if (calculator.MaybeConsumeOneNewline()) { previousLeftClass = KerningClass.None; } } calculator.Finish(); truncation = calculator.Truncation; payload = calculator.Payload; return calculator.CurrentSize; } public static Vector CalculateSize(IFontMetrics font, in StringView text, Scalar? maxWidth = null, Scalar? maxHeight = null, TextOverflow overflow = TextOverflow.Clip, int? maxLines = null) { return CalculateSize(font, in text, maxWidth, maxHeight, overflow, out _, maxLines); } private struct EmptyPayload { } /// /// Measures the text, also reporting what it had to do to fit. /// public static Vector CalculateSize(IFontMetrics font, in StringView text, Scalar? maxWidth, Scalar? maxHeight, TextOverflow overflow, out TextOverflow? truncation, int? maxLines = null) { EmptyPayload payload = default; return WordWrap(font, in text, maxWidth ?? Scalar.PositiveInfinity, maxHeight, overflow, ref payload, out truncation, maxLines, linePlacementCallback: null); } public static Vector StartingLinePosition(Vector? textSize, Vector containerSize, Scalar lineWidth, Alignment.Horizontal horizontalAlignment, Alignment.Vertical verticalAlignment) { if (textSize == null && verticalAlignment != Alignment.Vertical.Top) { throw new ArgumentNullException(nameof(textSize), "Must pass in textSize when vertical alignment is not top."); } Scalar y; switch(verticalAlignment) { case Alignment.Vertical.Top: y = 0; break; case Alignment.Vertical.Bottom: y = containerSize.Y - textSize.Value.Y; break; case Alignment.Vertical.Center: y = containerSize.Y / 2 - textSize.Value.Y / 2; break; default: throw new InvalidOperationException($"Unknown vertical alignment {verticalAlignment}"); } Scalar x; switch (horizontalAlignment) { case Alignment.Horizontal.Left: x = 0; break; case Alignment.Horizontal.Right: x = containerSize.X - lineWidth; break; case Alignment.Horizontal.Center: x = containerSize.X / 2 - lineWidth / 2; break; default: throw new InvalidOperationException($"Unknown horizontal alignment {horizontalAlignment}"); } // Content bigger than its container has no slack left to align within, so it anchors to the top left // rather than hanging off it — text cut at those edges reads as a rendering fault, where text cut at // the bottom or the right reads as there being more of it. return new Vector(Math.Max(0, x), Math.Max(0, y)); } /// /// `origin` is the left most point on the baseline to render the glyph at /// public delegate void GlyphPlacementCallback(ref TPayload payload, char glyph, Vector origin); private struct GlyphRun { public IFontMetrics Font; public Vector ContainerSize; public Alignment.Horizontal HorizontalAlignment; public GlyphPlacementCallback Callback; public TPayload Payload; } /// /// Places every glyph as though top-aligned, and returns the vertical offset the caller must separately /// apply (e.g. by translating the whole run's transform) to honor a non-top . /// public static Vector PlaceGlyphs(IFontMetrics font, in StringView text, Vector containerSize, Alignment.Horizontal horizontalAlignment, Alignment.Vertical verticalAlignment, Scalar? maxHeight, TextOverflow overflow, ref TPayload payload, int? maxLines, GlyphPlacementCallback glyphPlacementCallback) { GlyphRun run = new GlyphRun { Font = font, ContainerSize = containerSize, HorizontalAlignment = horizontalAlignment, Callback = glyphPlacementCallback, Payload = payload, }; Vector textSize = WordWrap(font, in text, containerSize.X, maxHeight, overflow, ref run, out _, maxLines, static (ref GlyphRun run, in StringView text, Scalar lineWidth, Vector startingPosition) => { // Top forced here: the real vertical alignment is applied once, to the whole run, below. (Scalar x, Scalar y) = startingPosition + StartingLinePosition( textSize: null, run.ContainerSize, lineWidth, run.HorizontalAlignment, Alignment.Vertical.Top); y -= run.Font.LineAscent; GlyphMetricsTable metricsTable = run.Font.MetricsTable; KerningTable kerningTable = run.Font.KerningTable; KerningClass previousLeftClass = KerningClass.None; foreach (char glyph in text) { GlyphMetrics metrics = metricsTable[glyph]; x += kerningTable.SpaceBetweenClasses(previousLeftClass, metrics.RightKerningClass); run.Callback?.Invoke(ref run.Payload, glyph, new Vector(x, y)); x += metrics.Width; previousLeftClass = metrics.LeftKerningClass; } }); payload = run.Payload; return new Vector(0, VerticalOffsetFor(verticalAlignment, containerSize.Y, textSize.Y)); } /// /// The offset a text block needs to honor , clamped so a block taller /// than its container anchors to the top rather than hanging off it. /// public static Scalar VerticalOffsetFor(Alignment.Vertical verticalAlignment, Scalar containerHeight, Scalar textHeight) { Scalar offset; switch (verticalAlignment) { case Alignment.Vertical.Top: offset = 0; break; case Alignment.Vertical.Bottom: offset = containerHeight - textHeight; break; case Alignment.Vertical.Center: offset = containerHeight / 2 - textHeight / 2; break; default: throw new InvalidOperationException($"Unknown vertical alignment {verticalAlignment}"); } return Math.Max(0, offset); } } }