diff --git a/src/SixLabors.Fonts/TextLayout.cs b/src/SixLabors.Fonts/TextLayout.cs index 24f26901..2763a948 100644 --- a/src/SixLabors.Fonts/TextLayout.cs +++ b/src/SixLabors.Fonts/TextLayout.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Numerics; using SixLabors.Fonts.Tables.AdvancedTypographic; using SixLabors.Fonts.Unicode; @@ -1181,149 +1182,117 @@ VerticalOrientationType.Rotate or lineBreaks.Add(lineBreakEnumerator.Current); } - // Then split the line at the line breaks. - int lineBreakIndex = 0; - int maxLineBreakIndex = lineBreaks.Count - 1; - LineBreak lastLineBreak = lineBreaks[lineBreakIndex]; - LineBreak currentLineBreak = lineBreaks[lineBreakIndex]; - float lineAdvance = 0; - - for (int i = 0; i < textLine.Count; i++) + int usedOffset = 0; + while (textLine.Count > 0) { - int max = textLine.Count - 1; - TextLine.GlyphLayoutData glyph = textLine[i]; - codePointIndex = glyph.CodePointIndex; - int graphemeCodePointIndex = glyph.GraphemeCodePointIndex; - - if (graphemeCodePointIndex == 0 && textLine.Count > 0) + LineBreak? bestBreak = null; + foreach (LineBreak lineBreak in lineBreaks) { - lineAdvance += glyph.ScaledAdvance; + // Adjust the break index relative to the current position in the original line + int measureAt = lineBreak.PositionMeasure - usedOffset; + + // Skip breaks that are already behind the trimmed portion + if (measureAt < 0) + { + continue; + } - if (codePointIndex == currentLineBreak.PositionWrap && currentLineBreak.Required) + // Measure the text up to the adjusted break point + float measure = textLine.MeasureAt(measureAt); + if (measure > wrappingLength) { - // Mandatory line break at index. - TextLine remaining = textLine.SplitAt(i); + // Stop and use the best break so far + bestBreak ??= lineBreak; + break; + } + + // Update the best break + bestBreak = lineBreak; - if (shouldWrap && textLine.ScaledLineAdvance - glyph.ScaledAdvance > wrappingLength) + // If it's a mandatory break, stop immediately + if (lineBreak.Required) + { + break; + } + } + + if (bestBreak != null) + { + if (breakAll) + { + // Break-all works differently to the other modes. + // It will break at any character so we simply toggle the breaking operation depending + // on whether the break is required. + TextLine? remaining; + if (bestBreak.Value.Required) { - // We've overshot the wrapping length so we need to split the line - // at the previous break and add both lines. - TextLine overflow = textLine.SplitAt(lastLineBreak, keepAll); - if (overflow != textLine) + if (textLine.TrySplitAt(bestBreak.Value, keepAll, out remaining)) { + usedOffset += textLine.Count; textLines.Add(textLine.Finalize(options)); - textLine = overflow; + textLine = remaining; } - + } + else if (textLine.TrySplitAt(wrappingLength, out remaining)) + { + usedOffset += textLine.Count; textLines.Add(textLine.Finalize(options)); textLine = remaining; - i = -1; - lineAdvance = 0; } else { - textLines.Add(textLine.Finalize(options)); - textLine = remaining; - i = -1; - lineAdvance = 0; + usedOffset += textLine.Count; } } - else if (shouldWrap) + else { - if (lineAdvance >= wrappingLength) + // Split the current line at the adjusted break index + if (textLine.TrySplitAt(bestBreak.Value, keepAll, out TextLine? remaining)) { - if (breakAll) + usedOffset += textLine.Count; + if (breakWord) { - // Insert a forced break. - TextLine remaining = textLine.SplitAt(i); - if (remaining != textLine) + // A break was found, but we need to check if the line is too long + // and break if required. + if (textLine.ScaledLineAdvance > wrappingLength && + textLine.TrySplitAt(wrappingLength, out TextLine? overflow)) { - textLines.Add(textLine.Finalize(options)); - textLine = remaining; - i = -1; - lineAdvance = 0; + // Reinsert the overflow at the beginning of the remaining line + usedOffset -= overflow.Count; + remaining.InsertAt(0, overflow); } } - else if (codePointIndex == currentLineBreak.PositionWrap || i == max) - { - LineBreak lineBreak = lineAdvance == wrappingLength - ? currentLineBreak - : lastLineBreak; - if (i > 0) - { - // If the current break is a space, and the line minus the space - // is less than the wrapping length, we can break using the current break. - float previousAdvance = lineAdvance - glyph.ScaledAdvance; - TextLine.GlyphLayoutData lastGlyph = textLine[i - 1]; - if (CodePoint.IsWhiteSpace(lastGlyph.CodePoint)) - { - previousAdvance -= lastGlyph.ScaledAdvance; - if (previousAdvance <= wrappingLength) - { - lineBreak = currentLineBreak; - } - } - } - - // If we are at the position wrap we can break here. - // Split the line at the appropriate break. - // CJK characters will not be split if 'keepAll' is true. - TextLine remaining = textLine.SplitAt(lineBreak, keepAll); - - if (remaining != textLine) - { - if (breakWord) - { - // If the line is too long, insert a forced break. - if (textLine.ScaledLineAdvance > wrappingLength) - { - TextLine overflow = textLine.SplitAt(wrappingLength); - if (overflow != textLine) - { - remaining.InsertAt(0, overflow); - } - } - } - - textLines.Add(textLine.Finalize(options)); - textLine = remaining; - i = -1; - lineAdvance = 0; - } - } + // Add the split part to the list and continue processing. + textLines.Add(textLine.Finalize(options)); + textLine = remaining; + } + else + { + usedOffset += textLine.Count; } } } - - // Find the next line break. - if (lineBreakIndex < maxLineBreakIndex && - (currentLineBreak.PositionWrap == codePointIndex)) - { - lastLineBreak = currentLineBreak; - currentLineBreak = lineBreaks[++lineBreakIndex]; - } - } - - // Add the final line. - if (textLine.Count > 0) - { - if (shouldWrap && (breakWord || breakAll)) + else { - while (textLine.ScaledLineAdvance > wrappingLength) + // If no valid break is found, add the remaining line and exit + if (breakWord || breakAll) { - TextLine overflow = textLine.SplitAt(wrappingLength); - if (overflow == textLine) + while (textLine.ScaledLineAdvance > wrappingLength) { - break; - } + if (!textLine.TrySplitAt(wrappingLength, out TextLine? overflow)) + { + break; + } - textLines.Add(textLine.Finalize(options)); - textLine = overflow; + textLines.Add(textLine.Finalize(options)); + textLine = overflow; + } } - } - textLines.Add(textLine.Finalize(options)); + textLines.Add(textLine.Finalize(options)); + break; + } } return new TextBox(textLines); @@ -1381,7 +1350,7 @@ public void Add( { // Reset metrics. // We track the maximum metrics for each line to ensure glyphs can be aligned. - if (graphemeIndex == 0) + if (graphemeCodePointIndex == 0) { this.ScaledLineAdvance += scaledAdvance; } @@ -1406,31 +1375,36 @@ public void Add( stringIndex)); } - public TextLine InsertAt(int index, TextLine textLine) + public void InsertAt(int index, TextLine textLine) { this.data.InsertRange(index, textLine.data); RecalculateLineMetrics(this); - return this; } - public TextLine SplitAt(int index) + public float MeasureAt(int index) { - if (index == 0 || index >= this.Count) + if (index >= this.data.Count) { - return this; + index = this.data.Count - 1; } - int count = this.data.Count - index; - TextLine result = new(count); - result.data.AddRange(this.data.GetRange(index, count)); - RecalculateLineMetrics(result); + while (index >= 0 && CodePoint.IsWhiteSpace(this.data[index].CodePoint)) + { + // If the index is whitespace, we need to measure at the previous + // non-whitespace glyph to ensure we don't break too early. + index--; + } - this.data.RemoveRange(index, count); - RecalculateLineMetrics(this); - return result; + float advance = 0; + for (int i = 0; i <= index; i++) + { + advance += this.data[i].ScaledAdvance; + } + + return advance; } - public TextLine SplitAt(float length) + public bool TrySplitAt(float length, [NotNullWhen(true)] out TextLine? result) { float advance = this.data[0].ScaledAdvance; @@ -1449,20 +1423,21 @@ public TextLine SplitAt(float length) if (advance >= length) { int count = this.data.Count - i; - TextLine result = new(count); + result = new(count); result.data.AddRange(this.data.GetRange(i, count)); RecalculateLineMetrics(result); this.data.RemoveRange(i, count); RecalculateLineMetrics(this); - return result; + return true; } } - return this; + result = null; + return false; } - public TextLine SplitAt(LineBreak lineBreak, bool keepAll) + public bool TrySplitAt(LineBreak lineBreak, bool keepAll, [NotNullWhen(true)] out TextLine? result) { int index = this.data.Count; GlyphLayoutData glyphWrap = default; @@ -1475,14 +1450,12 @@ public TextLine SplitAt(LineBreak lineBreak, bool keepAll) } } - if (index == 0) - { - return this; - } - // Word breaks should not be used for Chinese/Japanese/Korean (CJK) text // when word-breaking mode is keep-all. - if (!lineBreak.Required && keepAll && UnicodeUtility.IsCJKCodePoint((uint)glyphWrap.CodePoint.Value)) + if (index > 0 + && !lineBreak.Required + && keepAll + && UnicodeUtility.IsCJKCodePoint((uint)glyphWrap.CodePoint.Value)) { // Loop through previous glyphs to see if there is // a non CJK codepoint we can break at. @@ -1495,23 +1468,25 @@ public TextLine SplitAt(LineBreak lineBreak, bool keepAll) break; } } + } - if (index == 0) - { - return this; - } + if (index == 0) + { + result = null; + return false; } // Create a new line ensuring we capture the initial metrics. int count = this.data.Count - index; - TextLine result = new(count); + result = new(count); result.data.AddRange(this.data.GetRange(index, count)); RecalculateLineMetrics(result); // Remove those items from this line. this.data.RemoveRange(index, count); RecalculateLineMetrics(this); - return result; + + return true; } private void TrimTrailingWhitespace() diff --git a/tests/Images/ReferenceOutput/Issue_444_A__WrappingLength_860_.png b/tests/Images/ReferenceOutput/Issue_444_A__WrappingLength_860_.png new file mode 100644 index 00000000..937d464a --- /dev/null +++ b/tests/Images/ReferenceOutput/Issue_444_A__WrappingLength_860_.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d9e91720c34f6f0ee14f72bed9ff665fe2cdecc7995647a699b7d49bb0dff67 +size 34462 diff --git a/tests/Images/ReferenceOutput/Issue_444_B__WrappingLength_860_.png b/tests/Images/ReferenceOutput/Issue_444_B__WrappingLength_860_.png new file mode 100644 index 00000000..8f768c82 --- /dev/null +++ b/tests/Images/ReferenceOutput/Issue_444_B__WrappingLength_860_.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:79555d074f39abbac4267029c8c52685895cfe1ef809c7c965970c7f4a78bb0e +size 34311 diff --git a/tests/Images/ReferenceOutput/Issue_444_C__WrappingLength_860_.png b/tests/Images/ReferenceOutput/Issue_444_C__WrappingLength_860_.png new file mode 100644 index 00000000..d413e3ae --- /dev/null +++ b/tests/Images/ReferenceOutput/Issue_444_C__WrappingLength_860_.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc94a31aaad8a2d12c7b404ac3dc230785f17ce20f16d78d1dca9296b49c8844 +size 33972 diff --git a/tests/Images/ReferenceOutput/Issue_444_D__WrappingLength_860_.png b/tests/Images/ReferenceOutput/Issue_444_D__WrappingLength_860_.png new file mode 100644 index 00000000..d413e3ae --- /dev/null +++ b/tests/Images/ReferenceOutput/Issue_444_D__WrappingLength_860_.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc94a31aaad8a2d12c7b404ac3dc230785f17ce20f16d78d1dca9296b49c8844 +size 33972 diff --git a/tests/Images/ReferenceOutput/Issue_444_E__WrappingLength_860_.png b/tests/Images/ReferenceOutput/Issue_444_E__WrappingLength_860_.png new file mode 100644 index 00000000..8362a14e --- /dev/null +++ b/tests/Images/ReferenceOutput/Issue_444_E__WrappingLength_860_.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4a5bf28e4a08d43461fb5d185873c45de1fc98f25113e5a77183cdcd9532a93 +size 33983 diff --git a/tests/Images/ReferenceOutput/ShouldBreakIntoTwoLinesA__WrappingLength_1000_.png b/tests/Images/ReferenceOutput/ShouldBreakIntoTwoLinesA__WrappingLength_1000_.png new file mode 100644 index 00000000..54010b77 --- /dev/null +++ b/tests/Images/ReferenceOutput/ShouldBreakIntoTwoLinesA__WrappingLength_1000_.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a32b597cf1ea04b83158342c8bd1568b275c02b1e9a4be8bf495416f9a8d0822 +size 21857 diff --git a/tests/Images/ReferenceOutput/ShouldBreakIntoTwoLinesB__WrappingLength_100_.png b/tests/Images/ReferenceOutput/ShouldBreakIntoTwoLinesB__WrappingLength_100_.png new file mode 100644 index 00000000..4ade5296 --- /dev/null +++ b/tests/Images/ReferenceOutput/ShouldBreakIntoTwoLinesB__WrappingLength_100_.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:42ce51c800b662e60a474b91c135be4d2f734c477d209b413d9e2c80edd2cead +size 2314 diff --git a/tests/SixLabors.Fonts.Tests/Fonts/CharisSIL-Regular.ttf b/tests/SixLabors.Fonts.Tests/Fonts/CharisSIL-Regular.ttf new file mode 100644 index 00000000..38830fc5 Binary files /dev/null and b/tests/SixLabors.Fonts.Tests/Fonts/CharisSIL-Regular.ttf differ diff --git a/tests/SixLabors.Fonts.Tests/Issues/Issues_443.cs b/tests/SixLabors.Fonts.Tests/Issues/Issues_443.cs new file mode 100644 index 00000000..fa1b54af --- /dev/null +++ b/tests/SixLabors.Fonts.Tests/Issues/Issues_443.cs @@ -0,0 +1,42 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.Fonts.Tests.Issues; +public class Issues_443 +{ + [Fact] + public void ShouldBreakIntoTwoLinesA() + { + if (SystemFonts.TryGet("Arial", out FontFamily family)) + { + Font font = family.CreateFont(100); + + TextLayoutTestUtilities.TestLayout( + "This text should break into two lines", + new TextOptions(font) + { + LineSpacing = 1.1499023f, + WrappingLength = 1000, + WordBreaking = WordBreaking.BreakWord + }); + } + } + + [Fact] + public void ShouldBreakIntoTwoLinesB() + { + if (SystemFonts.TryGet("Arial", out FontFamily family)) + { + Font font = family.CreateFont(50); + + TextLayoutTestUtilities.TestLayout( + "ABCDEF", + new TextOptions(font) + { + LineSpacing = 1.1499023f, + WrappingLength = 100, + WordBreaking = WordBreaking.BreakWord + }); + } + } +} diff --git a/tests/SixLabors.Fonts.Tests/Issues/Issues_444.cs b/tests/SixLabors.Fonts.Tests/Issues/Issues_444.cs new file mode 100644 index 00000000..e4b63fad --- /dev/null +++ b/tests/SixLabors.Fonts.Tests/Issues/Issues_444.cs @@ -0,0 +1,84 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Numerics; + +namespace SixLabors.Fonts.Tests.Issues; + +public class Issues_444 +{ + private readonly FontFamily charisSIL = new FontCollection().Add(TestFonts.CharisSILRegular); + + [Fact] + public void Issue_444_A() + { + if (SystemFonts.TryGet("Arial", out FontFamily family)) + { + Font font = family.CreateFont(92); + + TextLayoutTestUtilities.TestLayout( + "- Bill Clinton\r\n- Richard Nixon\r\n- Lyndon B. Johnson\r\n- John F. Kennedy", + new TextOptions(font) + { + Origin = new Vector2(50, 20), + WrappingLength = 860, + }); + } + } + + [Fact] + public void Issue_444_B() + { + if (SystemFonts.TryGet("Arial", out FontFamily family)) + { + Font font = family.CreateFont(92); + + TextLayoutTestUtilities.TestLayout( + "- Bill Clinton\r\n- John F. Kennedy\r\n- Richard Nixon\r\n- Lyndon B. Johnson", + new TextOptions(font) + { + Origin = new Vector2(50, 20), + WrappingLength = 860, + }); + } + } + + [Fact] + public void Issue_444_C() + { + Font font = this.charisSIL.CreateFont(85); + TextLayoutTestUtilities.TestLayout( + "⇒ Bill Clinton\n⇒ Richard Nixon\n⇒ Lyndon B. Johnson\n⇒ John F. Kennedy", + new TextOptions(font) + { + Origin = new Vector2(50, 20), + WrappingLength = 860, + }); + } + + [Fact] + public void Issue_444_D() + { + Font font = this.charisSIL.CreateFont(85); + TextLayoutTestUtilities.TestLayout( + "⇒ Bill Clinton\r\n⇒ Richard Nixon\r\n⇒ Lyndon B. Johnson\r\n⇒ John F. Kennedy", + new TextOptions(font) + { + Origin = new Vector2(50, 20), + WrappingLength = 860, + }); + } + + [Fact] + public void Issue_444_E() + { + Font font = this.charisSIL.CreateFont(85); + TextLayoutTestUtilities.TestLayout( + "⇒ Bill Clinton\n⇒ Richard Nixon\n⇒ John F. Kennedy\n⇒ Lyndon B. Johnson", + new TextOptions(font) + { + Origin = new Vector2(50, 20), + WrappingLength = 860, + }); + } +} diff --git a/tests/SixLabors.Fonts.Tests/TestFonts.cs b/tests/SixLabors.Fonts.Tests/TestFonts.cs index 931e09fc..54187cb1 100644 --- a/tests/SixLabors.Fonts.Tests/TestFonts.cs +++ b/tests/SixLabors.Fonts.Tests/TestFonts.cs @@ -259,6 +259,8 @@ public static class TestFonts public static string NotoSansRegular => GetFullPath("NotoSans-Regular.ttf"); + public static string CharisSILRegular => GetFullPath("CharisSIL-Regular.ttf"); + public static Stream TwemojiMozillaData() => OpenStream(TwemojiMozillaFile); public static Stream SegoeuiEmojiData() => OpenStream(SegoeuiEmojiFile);