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);
}
}
}