using System; using System.Collections.Generic; using System.IO; using System.Linq; using Glass.Typesetting; using UnitTestSharp; using Conformance = Glass.UnitTests.TextLayoutTests.WordWrapUax14Conformance; using GlyphDefinition = Glass.SpriteFontDefinition.GlyphDefinition; namespace Glass.UnitTests.Typesetting { public class WordMeasurerTests : TestFixture { public const char SoftHyphen = LineBreakIterator.SoftHyphen; public const char LineSeparator = (char)0x2028; public const int LetterWidth = 10; public const int SpaceWidth = 4; public const int HyphenWidth = 3; public const int InvalidGlyphWidth = 7; public static readonly string Emoji = char.ConvertFromUtf32(0x1F600); public static readonly string LanguageTag = char.ConvertFromUtf32(0xE0001); public static (short, short) Pair(char left, char right) => ((short)left, (short)right); /// /// Lowercase letters, a space and a hyphen, with a few kerning pairs whose amounts all differ. /// public static SpriteFontDefinition TestFont(Dictionary glyphs = null, short kerningBetweenSpaces = 0) { glyphs ??= new Dictionary(); for (char letter = 'a'; letter <= 'z'; ++letter) { glyphs[letter] = new GlyphDefinition { Width = LetterWidth }; } glyphs[' '] = new GlyphDefinition { Width = SpaceWidth }; glyphs['-'] = new GlyphDefinition { Width = HyphenWidth }; var kernings = new Dictionary<(short, short), short> { [Pair('a', 'b')] = -1, [Pair('b', ' ')] = -2, [Pair(' ', 'c')] = -3, [Pair('b', '-')] = -1, [Pair('a', 'c')] = -5, }; if (kerningBetweenSpaces != 0) { kernings[Pair(' ', ' ')] = kerningBetweenSpaces; } return new SpriteFontDefinition(new SpriteFontDefinition.Data { GlyphDefinitions = glyphs, InvalidGlyph = new GlyphDefinition { Width = InvalidGlyphWidth }, Kernings = kernings, }); } public static string Describe(int start, in MeasuredWord word, in WordEndMetrics ends) { return $"{start}..{word.ContentEnd}..{word.End}{(word.IsMandatory ? "!" : "")} " + $"lead {word.LeadingKerning} content {word.ContentWidth} trailing {ends.TrailingWidthOf(word)} " + $"hyphen {ends.HyphenWidthOf(word)}"; } public static List Words(StringView text, IFontMetrics font = null) { font ??= TestFont(); var ends = new WordEndMetrics(font); var returnMe = new List(); var measurer = new WordMeasurer(font, new LineBreakIterator(text)); while (measurer.MoveNext()) { returnMe.Add(Describe(measurer.CurrentStart, measurer.Current, ends)); } return returnMe; } /// /// Printable ASCII in assorted widths, with kerning between the characters LineBreakTest.txt uses most. /// public static SpriteFontDefinition ConformanceFont() { var glyphs = new Dictionary(); for (char character = ' '; character <= '~'; ++character) { glyphs[character] = new GlyphDefinition { Width = character % 7 + 3 }; } var kernings = new Dictionary<(short, short), short>(); const string kerned = " #%()0,-.a"; foreach (char left in kerned) { foreach (char right in kerned) { kernings[Pair(left, right)] = (short)((left * 3 + right) % 5 - 2); } } return new SpriteFontDefinition(new SpriteFontDefinition.Data { GlyphDefinitions = glyphs, InvalidGlyph = new GlyphDefinition { Width = InvalidGlyphWidth }, Kernings = kernings, }); } /// /// Whether the old engine measures `text` the way words are, so their widths should add up to its width. /// public static bool OldEngineMeasuresAlike(string text) { return !text.Any(character => LineBreakIteratorTests.IsHardBreak(character) || character == SoftHyphen || char.IsSurrogate(character)); } public class CtorTests : TestFixture { public void NullFont_Throws() { CheckThrow(typeof(ArgumentNullException)); new WordMeasurer(null, new LineBreakIterator("ab")); } public void KeepsFontAndText() { SpriteFontDefinition font = TestFont(); var measurer = new WordMeasurer(font, new LineBreakIterator("ab cd")); CheckEqual(font, measurer.Font); CheckEqual("ab cd", measurer.Text.ToString()); } public void ExposesTheFontsAlreadyLookedUpMetricsKerningTableAndSpace() { SpriteFontDefinition font = TestFont(); var measurer = new WordMeasurer(font, new LineBreakIterator("ab")); CheckEqual(font.MetricsTable, measurer.MetricsTable); CheckEqual(font.KerningTable, measurer.KerningTable); CheckEqual(font.MetricsTable[' '], measurer.Space); } } public class MoveNextTests : TestFixture { [CpuTimeout(3000)] public void OnLineBreakTest_EndsAtEveryBreakAndAddsUpToTheOldEnginesWidth() { SpriteFontDefinition font = ConformanceFont(); var endMetrics = new WordEndMetrics(font); var mismatches = new List(); foreach (string line in File.ReadLines(Conformance.TestFilePath)) { if (line.Length == 0 || line[0] == '#') { continue; } string text = Conformance.Parse(line).Text; var ends = new List(); int width = 0; var measurer = new WordMeasurer(font, new LineBreakIterator(text)); while (measurer.MoveNext()) { ends.Add(measurer.Current.End); width += measurer.Current.LeadingKerning + measurer.Current.ContentWidth + endMetrics.TrailingWidthOf(measurer.Current); } bool endsMatch = ends.SequenceEqual(LineBreakIteratorTests.PositionsIn(text)); bool widthMatches = !OldEngineMeasuresAlike(text) || width == (int)TextLayout.CalculateTextWidth(font, text); if ((!endsMatch || !widthMatches) && mismatches.Count < 10) { mismatches.Add($"[{LineBreakIteratorTests.CodePointsOf(text)}] " + $"ends {string.Join(",", ends)}, width {width}"); } } CheckEqual("", string.Join("; ", mismatches)); } public void EmptyText_HasNoWords() { CheckEqual(new string[0], Words("")); } public void OneWord_IsMandatoryAndKeepsItsInteriorKerning() { CheckEqual(new[] { "0..2..2! lead 0 content 19 trailing 0 hyphen 0" }, Words("ab")); } public void Space_HangsWithTheKerningIntoIt() { CheckEqual("0..2..3 lead 0 content 19 trailing 2 hyphen 0", Words("ab c")[0]); } public void KerningAcrossABreak_IsTheNextWordsLeadingKerning() { CheckEqual("3..4..4! lead -3 content 10 trailing 0 hyphen 0", Words("ab c")[1]); } public void SeveralSpaces_AllHang() { CheckEqual("0..1..4 lead 0 content 10 trailing 12 hyphen 0", Words("a b")[0]); } public void SeveralSpaces_KernBetweenEachOther() { CheckEqual("0..1..4 lead 0 content 10 trailing 10 hyphen 0", Words("a b", TestFont(kerningBetweenSpaces: -1))[0]); } public void SpacesAtTheStart_AreAWordWithNoContent() { CheckEqual(new[] { "0..0..2 lead 0 content 0 trailing 8 hyphen 0", "2..4..4! lead 0 content 19 trailing 0 hyphen 0", }, Words(" ab")); } public void LineFeed_HangsWithNoWidth_AndTheNextWordHasNoLeadingKerning() { CheckEqual(new[] { "0..1..3! lead 0 content 10 trailing 2 hyphen 0", "3..4..4! lead 0 content 10 trailing 0 hyphen 0", }, Words("b \nc")); } public void CarriageReturnLineFeed_EndsOneWord() { CheckEqual("0..1..3! lead 0 content 10 trailing 0 hyphen 0", Words("a\r\nb")[0]); } public void LineSeparator_IsAHardBreak() { CheckEqual("0..1..2! lead 0 content 10 trailing 0 hyphen 0", Words($"a{LineSeparator}b")[0]); } public void SoftHyphen_TakesNoWidthAndKerningCarriesAcrossIt() { CheckEqual(new[] { "0..2..2 lead 0 content 10 trailing 0 hyphen 3", "2..3..3! lead -5 content 10 trailing 0 hyphen 0", }, Words($"a{SoftHyphen}c")); } public void SoftHyphen_HyphenWidthIncludesTheKerningIntoTheHyphen() { CheckEqual("0..2..2 lead 0 content 10 trailing 0 hyphen 2", Words($"b{SoftHyphen}c")[0]); } public void SoftHyphenEndingTheText_NeedsNoHyphen() { CheckEqual(new[] { "0..3..3! lead 0 content 19 trailing 0 hyphen 0" }, Words($"ab{SoftHyphen}")); } public void SoftHyphenBeforeASpace_NeedsNoHyphen() { CheckEqual("0..2..3 lead 0 content 10 trailing 4 hyphen 0", Words($"a{SoftHyphen} b")[0]); } public void AstralCharacter_MeasuresAsOneInvalidGlyph() { CheckEqual(new[] { "0..2..2! lead 0 content 7 trailing 0 hyphen 0" }, Words(Emoji)); } public void AstralDefaultIgnorable_TakesNoWidthAndKerningCarriesAcrossIt() { CheckEqual(new[] { "0..4..4! lead 0 content 15 trailing 0 hyphen 0" }, Words($"a{LanguageTag}c")); } public void LoneSurrogate_MeasuresAsTheInvalidGlyph() { CheckEqual(new[] { "0..1..1! lead 0 content 7 trailing 0 hyphen 0" }, Words(WordMeasurer.LoneSurrogate.ToString())); } public void View_PositionsAreRelativeToItsStart() { CheckEqual("0..2..3 lead 0 content 19 trailing 2 hyphen 0", Words(((StringView)"xxab cd")[2, 6])[0]); } } public class MeasureAllTests : TestFixture { public const string ThreeWords = "ab cd\nef"; /// /// One-letter words, far more of them than the first guess at how many a text this long holds. /// public static readonly string ManyShortWords = string.Join(" ", new string('a', 40).ToCharArray()); public void HoldsWhatMoveNextYields() { CheckEqual(Words(ThreeWords), MeasuredWordsTests.ReadAll(WordMeasurer.MeasureAll(TestFont(), ThreeWords))); } public void MoreWordsThanFirstGuessed_AreAllKept() { CheckEqual(Words(ManyShortWords), MeasuredWordsTests.ReadAll(WordMeasurer.MeasureAll(TestFont(), ManyShortWords))); } public void EmptyText_HasNoWords() { CheckEqual(0, WordMeasurer.MeasureAll(TestFont(), "").Count); } public void KeepsTheFontAndText() { SpriteFontDefinition font = TestFont(); MeasuredWords words = WordMeasurer.MeasureAll(font, ThreeWords); CheckEqual(font, words.Font); CheckEqual(ThreeWords, words.Text.ToString()); } } } }