Skip to content

Commit cbdfdee

Browse files
Do not justify paragraph-final lines
1 parent 2ad382f commit cbdfdee

10 files changed

Lines changed: 227 additions & 9 deletions

src/SixLabors.Fonts/TextLayout.cs

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public static IReadOnlyList<TextRun> BuildTextRuns(ReadOnlySpan<char> text, Text
4343
int start = 0;
4444
int end = text.GetGraphemeCount();
4545
List<TextRun> textRuns = [];
46-
foreach (TextRun textRun in options.TextRuns!.OrderBy(x => x.Start))
46+
foreach (TextRun textRun in options.TextRuns.OrderBy(x => x.Start))
4747
{
4848
// Fill gaps within runs.
4949
if (textRun.Start > start)
@@ -1490,7 +1490,7 @@ VerticalOrientationType.Rotate or
14901490
if (textLine.TrySplitAt(breakAt, keepAll, out remaining))
14911491
{
14921492
processed = breakAt.PositionWrap;
1493-
textLines.Add(textLine.Finalize());
1493+
textLines.Add(textLine.Finalize(true));
14941494
textLine = remaining;
14951495
}
14961496
}
@@ -1529,7 +1529,7 @@ VerticalOrientationType.Rotate or
15291529
}
15301530

15311531
// Add the split part to the list and continue processing.
1532-
textLines.Add(textLine.Finalize());
1532+
textLines.Add(textLine.Finalize(breakAt.Required));
15331533
textLine = remaining;
15341534
}
15351535
else
@@ -1556,17 +1556,19 @@ VerticalOrientationType.Rotate or
15561556
}
15571557
}
15581558

1559-
textLines.Add(textLine.Finalize());
1559+
textLines.Add(textLine.Finalize(true));
15601560
break;
15611561
}
15621562
}
15631563

1564-
// Finally we justify each line except the last one
1565-
// The method itself determines the justification based on the options.
1566-
for (int i = 0; i < textLines.Count - 1; i++)
1564+
// Finally we justify each line that does not end a paragraph.
1565+
for (int i = 0; i < textLines.Count; i++)
15671566
{
15681567
TextLine line = textLines[i];
1569-
line.Justify(options);
1568+
if (!line.SkipJustification)
1569+
{
1570+
line.Justify(options);
1571+
}
15701572
}
15711573

15721574
return new TextBox(textLines);
@@ -1704,6 +1706,8 @@ internal sealed class TextLine
17041706

17051707
public int Count => this.data.Count;
17061708

1709+
public bool SkipJustification { get; private set; }
1710+
17071711
public float ScaledLineAdvance { get; private set; }
17081712

17091713
public float ScaledMaxLineHeight { get; private set; } = -1;
@@ -1941,8 +1945,9 @@ private void TrimTrailingWhitespace()
19411945
}
19421946
}
19431947

1944-
public TextLine Finalize()
1948+
public TextLine Finalize(bool skipJustification = false)
19451949
{
1950+
this.SkipJustification = skipJustification;
19461951
this.TrimTrailingWhitespace();
19471952
this.BidiReOrder();
19481953
RecalculateLineMetrics(this);
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading

tests/SixLabors.Fonts.Tests/TextLayoutTests.cs

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,174 @@ public void TextJustification_InterCharacter_Horizontal(TextDirection direction)
694694
}
695695
}
696696

697+
[Theory]
698+
[InlineData(TextDirection.LeftToRight, TextJustification.InterCharacter)]
699+
[InlineData(TextDirection.LeftToRight, TextJustification.InterWord)]
700+
[InlineData(TextDirection.RightToLeft, TextJustification.InterCharacter)]
701+
[InlineData(TextDirection.RightToLeft, TextJustification.InterWord)]
702+
public void TextJustification_MultiParagraph_Horizontal_DoesNotJustifyParagraphFinalLines(TextDirection direction, TextJustification justification)
703+
{
704+
const string paragraph = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc ornare maximus vehicula. Duis nisi velit, dictum id mauris vitae, lobortis pretium quam. Quisque sed nisi pulvinar, consequat justo id, feugiat leo. Cras eu elementum dui.";
705+
string text = $"{paragraph}\n{paragraph}";
706+
const float wrappingLength = 400;
707+
const float pointSize = 12;
708+
Font font = CreateRenderingFont(pointSize);
709+
TextOptions options = new(font)
710+
{
711+
TextDirection = direction,
712+
WrappingLength = wrappingLength,
713+
TextJustification = justification
714+
};
715+
716+
TextLayoutTestUtilities.TestLayout(text, options, properties: new { direction, justification });
717+
718+
// Compare justified and non-justified layouts line-by-line.
719+
// This lets us prove that some wrapped lines are still stretched, while
720+
// paragraph-final lines remain unchanged even when they are not the last
721+
// line of the overall text box.
722+
IReadOnlyList<IReadOnlyList<GlyphLayout>> justifiedLines = CollectLines(TextLayout.GenerateLayout(text.AsSpan(), options));
723+
724+
options.TextJustification = TextJustification.None;
725+
IReadOnlyList<IReadOnlyList<GlyphLayout>> unJustifiedLines = CollectLines(TextLayout.GenerateLayout(text.AsSpan(), options));
726+
727+
Assert.Equal(unJustifiedLines.Count, justifiedLines.Count);
728+
729+
bool foundUnchangedNonLastLine = false;
730+
bool foundJustifiedNonParagraphLine = false;
731+
for (int i = 0; i < justifiedLines.Count; i++)
732+
{
733+
TextMeasurer.TryGetCharacterAdvances(justifiedLines[i], options.Dpi, out ReadOnlySpan<GlyphBounds> justifiedCharacterBounds);
734+
TextMeasurer.TryGetCharacterAdvances(unJustifiedLines[i], options.Dpi, out ReadOnlySpan<GlyphBounds> unJustifiedCharacterBounds);
735+
736+
GlyphBounds[] justified = justifiedCharacterBounds.ToArray();
737+
GlyphBounds[] unJustified = unJustifiedCharacterBounds.ToArray();
738+
739+
bool isLastLine = i == justifiedLines.Count - 1;
740+
bool linesMatch = justified.Length == unJustified.Length;
741+
742+
// A paragraph-final line should be byte-for-byte equivalent in terms of
743+
// measured per-character advances, so we first test whether every glyph
744+
// advance is unchanged relative to the non-justified layout.
745+
if (linesMatch)
746+
{
747+
for (int j = 0; j < justified.Length; j++)
748+
{
749+
if (justified[j].Bounds.Width != unJustified[j].Bounds.Width)
750+
{
751+
linesMatch = false;
752+
break;
753+
}
754+
}
755+
}
756+
757+
if (isLastLine)
758+
{
759+
// The trailing line in the text box must never be justified.
760+
Assert.True(linesMatch);
761+
}
762+
else
763+
{
764+
float justifiedWidth = justified.Sum(x => x.Bounds.Width);
765+
float unJustifiedWidth = unJustified.Sum(x => x.Bounds.Width);
766+
767+
// At least one earlier line should stay unchanged, proving that a
768+
// paragraph-final line created by the explicit newline was not justified.
769+
foundUnchangedNonLastLine |= linesMatch;
770+
771+
// At least one other earlier line should still widen, proving that we
772+
// did not disable justification for all wrapped lines.
773+
foundJustifiedNonParagraphLine |= justifiedWidth > unJustifiedWidth;
774+
}
775+
}
776+
777+
// We expect both behaviors in the same layout: one unchanged paragraph-final
778+
// line before the end, and one earlier wrapped line that still stretches.
779+
Assert.True(foundUnchangedNonLastLine);
780+
Assert.True(foundJustifiedNonParagraphLine);
781+
}
782+
783+
[Theory]
784+
[InlineData(TextDirection.LeftToRight, TextJustification.InterCharacter)]
785+
[InlineData(TextDirection.LeftToRight, TextJustification.InterWord)]
786+
[InlineData(TextDirection.RightToLeft, TextJustification.InterCharacter)]
787+
[InlineData(TextDirection.RightToLeft, TextJustification.InterWord)]
788+
public void TextJustification_MultiParagraph_Vertical_DoesNotJustifyParagraphFinalLines(TextDirection direction, TextJustification justification)
789+
{
790+
const string paragraph = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc ornare maximus vehicula. Duis nisi velit, dictum id mauris vitae, lobortis pretium quam. Quisque sed nisi pulvinar, consequat justo id, feugiat leo. Cras eu elementum dui.";
791+
string text = $"{paragraph}\n{paragraph}";
792+
const float wrappingLength = 400;
793+
const float pointSize = 12;
794+
Font font = CreateRenderingFont(pointSize);
795+
TextOptions options = new(font)
796+
{
797+
LayoutMode = LayoutMode.VerticalLeftRight,
798+
TextDirection = direction,
799+
WrappingLength = wrappingLength,
800+
TextJustification = justification
801+
};
802+
803+
TextLayoutTestUtilities.TestLayout(text, options, properties: new { direction, justification });
804+
805+
// Same coverage as the horizontal test, but in vertical flow where the
806+
// effective line extent is measured along height rather than width.
807+
IReadOnlyList<IReadOnlyList<GlyphLayout>> justifiedLines = CollectLines(TextLayout.GenerateLayout(text.AsSpan(), options));
808+
809+
options.TextJustification = TextJustification.None;
810+
IReadOnlyList<IReadOnlyList<GlyphLayout>> unJustifiedLines = CollectLines(TextLayout.GenerateLayout(text.AsSpan(), options));
811+
812+
Assert.Equal(unJustifiedLines.Count, justifiedLines.Count);
813+
814+
bool foundUnchangedNonLastLine = false;
815+
bool foundJustifiedNonParagraphLine = false;
816+
for (int i = 0; i < justifiedLines.Count; i++)
817+
{
818+
TextMeasurer.TryGetCharacterAdvances(justifiedLines[i], options.Dpi, out ReadOnlySpan<GlyphBounds> justifiedCharacterBounds);
819+
TextMeasurer.TryGetCharacterAdvances(unJustifiedLines[i], options.Dpi, out ReadOnlySpan<GlyphBounds> unJustifiedCharacterBounds);
820+
821+
GlyphBounds[] justified = justifiedCharacterBounds.ToArray();
822+
GlyphBounds[] unJustified = unJustifiedCharacterBounds.ToArray();
823+
824+
bool isLastLine = i == justifiedLines.Count - 1;
825+
bool linesMatch = justified.Length == unJustified.Length;
826+
827+
// Paragraph-final lines should preserve every per-glyph advance from the
828+
// non-justified layout, so unchanged per-character heights are the signal
829+
// that a line was intentionally skipped by the justification pass.
830+
if (linesMatch)
831+
{
832+
for (int j = 0; j < justified.Length; j++)
833+
{
834+
if (justified[j].Bounds.Height != unJustified[j].Bounds.Height)
835+
{
836+
linesMatch = false;
837+
break;
838+
}
839+
}
840+
}
841+
842+
if (isLastLine)
843+
{
844+
// The trailing line in the text box must remain ragged in vertical layout too.
845+
Assert.True(linesMatch);
846+
}
847+
else
848+
{
849+
float justifiedHeight = justified.Sum(x => x.Bounds.Height);
850+
float unJustifiedHeight = unJustified.Sum(x => x.Bounds.Height);
851+
852+
// This captures a non-last line that still behaves like a paragraph end.
853+
foundUnchangedNonLastLine |= linesMatch;
854+
855+
// This captures a wrapped line that continues to justify normally.
856+
foundJustifiedNonParagraphLine |= justifiedHeight > unJustifiedHeight;
857+
}
858+
}
859+
860+
// Both conditions are required for the test to be meaningful.
861+
Assert.True(foundUnchangedNonLastLine);
862+
Assert.True(foundJustifiedNonParagraphLine);
863+
}
864+
697865
[Theory]
698866
[InlineData(TextDirection.LeftToRight)]
699867
[InlineData(TextDirection.RightToLeft)]
@@ -1569,6 +1737,27 @@ private static List<GlyphLayout> CollectFirstLine(IReadOnlyList<GlyphLayout> gly
15691737
return line;
15701738
}
15711739

1740+
private static IReadOnlyList<IReadOnlyList<GlyphLayout>> CollectLines(IReadOnlyList<GlyphLayout> glyphs)
1741+
{
1742+
List<IReadOnlyList<GlyphLayout>> lines = [];
1743+
List<GlyphLayout>? current = null;
1744+
1745+
// Re-slice the flat glyph list into visual lines using IsStartOfLine so the
1746+
// tests can compare whole lines instead of reasoning about glyph indices.
1747+
for (int i = 0; i < glyphs.Count; i++)
1748+
{
1749+
if (glyphs[i].IsStartOfLine || current is null)
1750+
{
1751+
current = [];
1752+
lines.Add(current);
1753+
}
1754+
1755+
current.Add(glyphs[i]);
1756+
}
1757+
1758+
return lines;
1759+
}
1760+
15721761
#if OS_WINDOWS
15731762
[Fact]
15741763
public void BenchmarkTest()

0 commit comments

Comments
 (0)