diff --git a/src/SixLabors.Fonts/TextLayout.cs b/src/SixLabors.Fonts/TextLayout.cs index 74a45de8..760428c1 100644 --- a/src/SixLabors.Fonts/TextLayout.cs +++ b/src/SixLabors.Fonts/TextLayout.cs @@ -43,7 +43,7 @@ public static IReadOnlyList BuildTextRuns(ReadOnlySpan text, Text int start = 0; int end = text.GetGraphemeCount(); List textRuns = []; - foreach (TextRun textRun in options.TextRuns!.OrderBy(x => x.Start)) + foreach (TextRun textRun in options.TextRuns.OrderBy(x => x.Start)) { // Fill gaps within runs. if (textRun.Start > start) @@ -1490,14 +1490,14 @@ VerticalOrientationType.Rotate or if (textLine.TrySplitAt(breakAt, keepAll, out remaining)) { processed = breakAt.PositionWrap; - textLines.Add(textLine.Finalize(options)); + textLines.Add(textLine.Finalize(true)); textLine = remaining; } } else if (textLine.TrySplitAt(wrappingLength, out remaining)) { processed += textLine.Count; - textLines.Add(textLine.Finalize(options)); + textLines.Add(textLine.Finalize()); textLine = remaining; } else @@ -1529,7 +1529,7 @@ VerticalOrientationType.Rotate or } // Add the split part to the list and continue processing. - textLines.Add(textLine.Finalize(options)); + textLines.Add(textLine.Finalize(breakAt.Required)); textLine = remaining; } else @@ -1551,16 +1551,26 @@ VerticalOrientationType.Rotate or break; } - textLines.Add(textLine.Finalize(options)); + textLines.Add(textLine.Finalize()); textLine = overflow; } } - textLines.Add(textLine.Finalize(options)); + textLines.Add(textLine.Finalize(true)); break; } } + // Finally we justify each line that does not end a paragraph. + for (int i = 0; i < textLines.Count; i++) + { + TextLine line = textLines[i]; + if (!line.SkipJustification) + { + line.Justify(options); + } + } + return new TextBox(textLines); } @@ -1696,6 +1706,8 @@ internal sealed class TextLine public int Count => this.data.Count; + public bool SkipJustification { get; private set; } + public float ScaledLineAdvance { get; private set; } public float ScaledMaxLineHeight { get; private set; } = -1; @@ -1933,14 +1945,12 @@ private void TrimTrailingWhitespace() } } - public TextLine Finalize(TextOptions options) + public TextLine Finalize(bool skipJustification = false) { + this.SkipJustification = skipJustification; this.TrimTrailingWhitespace(); this.BidiReOrder(); RecalculateLineMetrics(this); - - this.Justify(options); - RecalculateLineMetrics(this); return this; } @@ -1975,6 +1985,11 @@ public void Justify(TextOptions options) } } + if (nonZeroCount == 0) + { + return; + } + float padding = delta / nonZeroCount; for (int i = 0; i < this.data.Count - 1; i++) { @@ -1986,6 +2001,7 @@ public void Justify(TextOptions options) } } + RecalculateLineMetrics(this); return; } @@ -2003,6 +2019,11 @@ public void Justify(TextOptions options) } } + if (whiteSpaceCount == 0) + { + return; + } + float padding = delta / whiteSpaceCount; for (int i = 0; i < this.data.Count - 1; i++) { @@ -2014,6 +2035,8 @@ public void Justify(TextOptions options) } } } + + RecalculateLineMetrics(this); } public void BidiReOrder() diff --git a/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Horizontal_400-_direction_LeftToRight-TextJustification_InterCharacter_.png b/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Horizontal_400-_direction_LeftToRight-TextJustification_InterCharacter_.png index 1be8b4c3..b9c1bc94 100644 --- a/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Horizontal_400-_direction_LeftToRight-TextJustification_InterCharacter_.png +++ b/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Horizontal_400-_direction_LeftToRight-TextJustification_InterCharacter_.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a34f20be58409060a7a0c071de1ad19be52861b47d42af70e814a11605fc6d43 -size 8774 +oid sha256:6b40f3d9e8058620e16a479e30eacee5164d1be6595641eb493c99f0be8dac10 +size 8765 diff --git a/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Horizontal_400-_direction_RightToLeft-TextJustification_InterCharacter_.png b/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Horizontal_400-_direction_RightToLeft-TextJustification_InterCharacter_.png index 1503764f..a7352539 100644 --- a/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Horizontal_400-_direction_RightToLeft-TextJustification_InterCharacter_.png +++ b/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Horizontal_400-_direction_RightToLeft-TextJustification_InterCharacter_.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a9d261076440457a717da33a8681738e06a52182ef5430ea1c874c320a53c0e9 -size 8792 +oid sha256:7ff6cc8db20109e41bc7bcf42dd4b553fde97934e239a7b8d7e9f0b738bc7be1 +size 8798 diff --git a/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Vertical_400-_direction_LeftToRight-TextJustification_InterCharacter_.png b/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Vertical_400-_direction_LeftToRight-TextJustification_InterCharacter_.png index 7f0f8d4d..0283019c 100644 --- a/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Vertical_400-_direction_LeftToRight-TextJustification_InterCharacter_.png +++ b/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Vertical_400-_direction_LeftToRight-TextJustification_InterCharacter_.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:41c2eb051160a94e3b63f789916daba0093af365b54bcd6fbd3c65a0114b295d -size 7507 +oid sha256:4d9aedd390a468df6d1f7fbe5b7544d24d8883d2eea2c21e99d4b5ff27f509a1 +size 7485 diff --git a/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Vertical_400-_direction_RightToLeft-TextJustification_InterCharacter_.png b/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Vertical_400-_direction_RightToLeft-TextJustification_InterCharacter_.png index 7221c2e5..956996ec 100644 --- a/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Vertical_400-_direction_RightToLeft-TextJustification_InterCharacter_.png +++ b/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Vertical_400-_direction_RightToLeft-TextJustification_InterCharacter_.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b8e48085ea9ffbe30b4be7de45aad642645a61edef7a4dd0b19a612c37e66bc7 -size 7432 +oid sha256:f0c63fc526faeffe4914e2cb83f48dd379eb41591b86c41722e7d628ae2d866e +size 7400 diff --git a/tests/Images/ReferenceOutput/TextJustification_InterWord_Horizontal_400-_direction_LeftToRight-TextJustification_InterWord_.png b/tests/Images/ReferenceOutput/TextJustification_InterWord_Horizontal_400-_direction_LeftToRight-TextJustification_InterWord_.png index dd5d86ea..229e5ba7 100644 --- a/tests/Images/ReferenceOutput/TextJustification_InterWord_Horizontal_400-_direction_LeftToRight-TextJustification_InterWord_.png +++ b/tests/Images/ReferenceOutput/TextJustification_InterWord_Horizontal_400-_direction_LeftToRight-TextJustification_InterWord_.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7d6b060baad2258565758b6eebe3cd23061490a861dcb2b7b3b9276714dc5174 -size 8842 +oid sha256:a0ef745b0acafe52305d24f07dd340e22a88ccfd7d149150c71f75340d99f08e +size 8780 diff --git a/tests/Images/ReferenceOutput/TextJustification_InterWord_Horizontal_400-_direction_RightToLeft-TextJustification_InterWord_.png b/tests/Images/ReferenceOutput/TextJustification_InterWord_Horizontal_400-_direction_RightToLeft-TextJustification_InterWord_.png index 141b2191..0b95439b 100644 --- a/tests/Images/ReferenceOutput/TextJustification_InterWord_Horizontal_400-_direction_RightToLeft-TextJustification_InterWord_.png +++ b/tests/Images/ReferenceOutput/TextJustification_InterWord_Horizontal_400-_direction_RightToLeft-TextJustification_InterWord_.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f675cd2d88ed4e69b6710d002e73f9b43530c60a748f1112f39d040840b0f498 -size 8696 +oid sha256:474ca639cd51985cd05a0920cade063dce2f01f6cf47faf6a37d4b8d666dae7d +size 8771 diff --git a/tests/Images/ReferenceOutput/TextJustification_InterWord_Vertical_400-_direction_LeftToRight-TextJustification_InterWord_.png b/tests/Images/ReferenceOutput/TextJustification_InterWord_Vertical_400-_direction_LeftToRight-TextJustification_InterWord_.png index aaacf0d3..07f4d2f4 100644 --- a/tests/Images/ReferenceOutput/TextJustification_InterWord_Vertical_400-_direction_LeftToRight-TextJustification_InterWord_.png +++ b/tests/Images/ReferenceOutput/TextJustification_InterWord_Vertical_400-_direction_LeftToRight-TextJustification_InterWord_.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a8a54b0c1707f57d99afdcd3cfb5518636e96f40ebab213e83cc8ef9a85edb7 -size 6725 +oid sha256:8f2f53663dca17d6e42a97efc39b2a959c299dc6e24dcab832f1eb522a9f5532 +size 6782 diff --git a/tests/Images/ReferenceOutput/TextJustification_InterWord_Vertical_400-_direction_RightToLeft-TextJustification_InterWord_.png b/tests/Images/ReferenceOutput/TextJustification_InterWord_Vertical_400-_direction_RightToLeft-TextJustification_InterWord_.png index 3917ae77..e4287da1 100644 --- a/tests/Images/ReferenceOutput/TextJustification_InterWord_Vertical_400-_direction_RightToLeft-TextJustification_InterWord_.png +++ b/tests/Images/ReferenceOutput/TextJustification_InterWord_Vertical_400-_direction_RightToLeft-TextJustification_InterWord_.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:73d1573b88cc105218d1a557c7b096a50ed7aeca0a76d3719d2243ef7f764020 -size 6721 +oid sha256:ee2fb06ba4a6d6a3dfed2686aad8311099ba08fb510fe3b26a37be1d0ccc6d5f +size 6786 diff --git a/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Horizontal_DoesNotJustifyParagraphFinalLines_400-_direction_LeftToRight-justification_InterCharacter_.png b/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Horizontal_DoesNotJustifyParagraphFinalLines_400-_direction_LeftToRight-justification_InterCharacter_.png new file mode 100644 index 00000000..c0a3bd45 --- /dev/null +++ b/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Horizontal_DoesNotJustifyParagraphFinalLines_400-_direction_LeftToRight-justification_InterCharacter_.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fcf13ea4ae022b42ff875112eba6a5a2e912c5247fe5cd9dba0bfe226246fbce +size 16097 diff --git a/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Horizontal_DoesNotJustifyParagraphFinalLines_400-_direction_LeftToRight-justification_InterWord_.png b/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Horizontal_DoesNotJustifyParagraphFinalLines_400-_direction_LeftToRight-justification_InterWord_.png new file mode 100644 index 00000000..c69d5638 --- /dev/null +++ b/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Horizontal_DoesNotJustifyParagraphFinalLines_400-_direction_LeftToRight-justification_InterWord_.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:025998afda2f119a6fe94a6aad23b7db492da0e7883604e67c8e83dc5cbc87c2 +size 16329 diff --git a/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Horizontal_DoesNotJustifyParagraphFinalLines_400-_direction_RightToLeft-justification_InterCharacter_.png b/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Horizontal_DoesNotJustifyParagraphFinalLines_400-_direction_RightToLeft-justification_InterCharacter_.png new file mode 100644 index 00000000..f0068197 --- /dev/null +++ b/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Horizontal_DoesNotJustifyParagraphFinalLines_400-_direction_RightToLeft-justification_InterCharacter_.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:abc5287220cccd527d74fea9efc1364c2207e8f60e081c3f34342b49373ab57b +size 16157 diff --git a/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Horizontal_DoesNotJustifyParagraphFinalLines_400-_direction_RightToLeft-justification_InterWord_.png b/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Horizontal_DoesNotJustifyParagraphFinalLines_400-_direction_RightToLeft-justification_InterWord_.png new file mode 100644 index 00000000..3cfc2b4d --- /dev/null +++ b/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Horizontal_DoesNotJustifyParagraphFinalLines_400-_direction_RightToLeft-justification_InterWord_.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:90382b8bbcad1e176a4c84554d8810616372f1f9f34285d289f369cd695c0369 +size 16064 diff --git a/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Vertical_DoesNotJustifyParagraphFinalLines_400-_direction_LeftToRight-justification_InterCharacter_.png b/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Vertical_DoesNotJustifyParagraphFinalLines_400-_direction_LeftToRight-justification_InterCharacter_.png new file mode 100644 index 00000000..dbebd4cb --- /dev/null +++ b/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Vertical_DoesNotJustifyParagraphFinalLines_400-_direction_LeftToRight-justification_InterCharacter_.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c39ae15d6e1b573dfe973a0b463adc72c40b40dbb8a533f3e88798f6e008947 +size 8726 diff --git a/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Vertical_DoesNotJustifyParagraphFinalLines_400-_direction_LeftToRight-justification_InterWord_.png b/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Vertical_DoesNotJustifyParagraphFinalLines_400-_direction_LeftToRight-justification_InterWord_.png new file mode 100644 index 00000000..67beb6b6 --- /dev/null +++ b/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Vertical_DoesNotJustifyParagraphFinalLines_400-_direction_LeftToRight-justification_InterWord_.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e84c74a7e66ebe07327beec832dde72337ada89aae49f0e4d515d97a958dfc8 +size 7953 diff --git a/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Vertical_DoesNotJustifyParagraphFinalLines_400-_direction_RightToLeft-justification_InterCharacter_.png b/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Vertical_DoesNotJustifyParagraphFinalLines_400-_direction_RightToLeft-justification_InterCharacter_.png new file mode 100644 index 00000000..2a0109fb --- /dev/null +++ b/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Vertical_DoesNotJustifyParagraphFinalLines_400-_direction_RightToLeft-justification_InterCharacter_.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:79e8ce50c657f4e49c8f6e37e51c108fd23135b7891fc5cd3c7c8f018aa53b5e +size 8764 diff --git a/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Vertical_DoesNotJustifyParagraphFinalLines_400-_direction_RightToLeft-justification_InterWord_.png b/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Vertical_DoesNotJustifyParagraphFinalLines_400-_direction_RightToLeft-justification_InterWord_.png new file mode 100644 index 00000000..14bf3110 --- /dev/null +++ b/tests/Images/ReferenceOutput/TextJustification_MultiParagraph_Vertical_DoesNotJustifyParagraphFinalLines_400-_direction_RightToLeft-justification_InterWord_.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84fa7591a4868359e09314a1b032f123704d7ca68ca1dd6b1296437fc111393f +size 7824 diff --git a/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs b/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs index dc6bc66e..c3df5df7 100644 --- a/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs +++ b/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs @@ -694,6 +694,174 @@ public void TextJustification_InterCharacter_Horizontal(TextDirection direction) } } + [Theory] + [InlineData(TextDirection.LeftToRight, TextJustification.InterCharacter)] + [InlineData(TextDirection.LeftToRight, TextJustification.InterWord)] + [InlineData(TextDirection.RightToLeft, TextJustification.InterCharacter)] + [InlineData(TextDirection.RightToLeft, TextJustification.InterWord)] + public void TextJustification_MultiParagraph_Horizontal_DoesNotJustifyParagraphFinalLines(TextDirection direction, TextJustification justification) + { + 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."; + string text = $"{paragraph}\n{paragraph}"; + const float wrappingLength = 400; + const float pointSize = 12; + Font font = CreateRenderingFont(pointSize); + TextOptions options = new(font) + { + TextDirection = direction, + WrappingLength = wrappingLength, + TextJustification = justification + }; + + TextLayoutTestUtilities.TestLayout(text, options, properties: new { direction, justification }); + + // Compare justified and non-justified layouts line-by-line. + // This lets us prove that some wrapped lines are still stretched, while + // paragraph-final lines remain unchanged even when they are not the last + // line of the overall text box. + IReadOnlyList> justifiedLines = CollectLines(TextLayout.GenerateLayout(text.AsSpan(), options)); + + options.TextJustification = TextJustification.None; + IReadOnlyList> unJustifiedLines = CollectLines(TextLayout.GenerateLayout(text.AsSpan(), options)); + + Assert.Equal(unJustifiedLines.Count, justifiedLines.Count); + + bool foundUnchangedNonLastLine = false; + bool foundJustifiedNonParagraphLine = false; + for (int i = 0; i < justifiedLines.Count; i++) + { + TextMeasurer.TryGetCharacterAdvances(justifiedLines[i], options.Dpi, out ReadOnlySpan justifiedCharacterBounds); + TextMeasurer.TryGetCharacterAdvances(unJustifiedLines[i], options.Dpi, out ReadOnlySpan unJustifiedCharacterBounds); + + GlyphBounds[] justified = justifiedCharacterBounds.ToArray(); + GlyphBounds[] unJustified = unJustifiedCharacterBounds.ToArray(); + + bool isLastLine = i == justifiedLines.Count - 1; + bool linesMatch = justified.Length == unJustified.Length; + + // A paragraph-final line should be byte-for-byte equivalent in terms of + // measured per-character advances, so we first test whether every glyph + // advance is unchanged relative to the non-justified layout. + if (linesMatch) + { + for (int j = 0; j < justified.Length; j++) + { + if (justified[j].Bounds.Width != unJustified[j].Bounds.Width) + { + linesMatch = false; + break; + } + } + } + + if (isLastLine) + { + // The trailing line in the text box must never be justified. + Assert.True(linesMatch); + } + else + { + float justifiedWidth = justified.Sum(x => x.Bounds.Width); + float unJustifiedWidth = unJustified.Sum(x => x.Bounds.Width); + + // At least one earlier line should stay unchanged, proving that a + // paragraph-final line created by the explicit newline was not justified. + foundUnchangedNonLastLine |= linesMatch; + + // At least one other earlier line should still widen, proving that we + // did not disable justification for all wrapped lines. + foundJustifiedNonParagraphLine |= justifiedWidth > unJustifiedWidth; + } + } + + // We expect both behaviors in the same layout: one unchanged paragraph-final + // line before the end, and one earlier wrapped line that still stretches. + Assert.True(foundUnchangedNonLastLine); + Assert.True(foundJustifiedNonParagraphLine); + } + + [Theory] + [InlineData(TextDirection.LeftToRight, TextJustification.InterCharacter)] + [InlineData(TextDirection.LeftToRight, TextJustification.InterWord)] + [InlineData(TextDirection.RightToLeft, TextJustification.InterCharacter)] + [InlineData(TextDirection.RightToLeft, TextJustification.InterWord)] + public void TextJustification_MultiParagraph_Vertical_DoesNotJustifyParagraphFinalLines(TextDirection direction, TextJustification justification) + { + 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."; + string text = $"{paragraph}\n{paragraph}"; + const float wrappingLength = 400; + const float pointSize = 12; + Font font = CreateRenderingFont(pointSize); + TextOptions options = new(font) + { + LayoutMode = LayoutMode.VerticalLeftRight, + TextDirection = direction, + WrappingLength = wrappingLength, + TextJustification = justification + }; + + TextLayoutTestUtilities.TestLayout(text, options, properties: new { direction, justification }); + + // Same coverage as the horizontal test, but in vertical flow where the + // effective line extent is measured along height rather than width. + IReadOnlyList> justifiedLines = CollectLines(TextLayout.GenerateLayout(text.AsSpan(), options)); + + options.TextJustification = TextJustification.None; + IReadOnlyList> unJustifiedLines = CollectLines(TextLayout.GenerateLayout(text.AsSpan(), options)); + + Assert.Equal(unJustifiedLines.Count, justifiedLines.Count); + + bool foundUnchangedNonLastLine = false; + bool foundJustifiedNonParagraphLine = false; + for (int i = 0; i < justifiedLines.Count; i++) + { + TextMeasurer.TryGetCharacterAdvances(justifiedLines[i], options.Dpi, out ReadOnlySpan justifiedCharacterBounds); + TextMeasurer.TryGetCharacterAdvances(unJustifiedLines[i], options.Dpi, out ReadOnlySpan unJustifiedCharacterBounds); + + GlyphBounds[] justified = justifiedCharacterBounds.ToArray(); + GlyphBounds[] unJustified = unJustifiedCharacterBounds.ToArray(); + + bool isLastLine = i == justifiedLines.Count - 1; + bool linesMatch = justified.Length == unJustified.Length; + + // Paragraph-final lines should preserve every per-glyph advance from the + // non-justified layout, so unchanged per-character heights are the signal + // that a line was intentionally skipped by the justification pass. + if (linesMatch) + { + for (int j = 0; j < justified.Length; j++) + { + if (justified[j].Bounds.Height != unJustified[j].Bounds.Height) + { + linesMatch = false; + break; + } + } + } + + if (isLastLine) + { + // The trailing line in the text box must remain ragged in vertical layout too. + Assert.True(linesMatch); + } + else + { + float justifiedHeight = justified.Sum(x => x.Bounds.Height); + float unJustifiedHeight = unJustified.Sum(x => x.Bounds.Height); + + // This captures a non-last line that still behaves like a paragraph end. + foundUnchangedNonLastLine |= linesMatch; + + // This captures a wrapped line that continues to justify normally. + foundJustifiedNonParagraphLine |= justifiedHeight > unJustifiedHeight; + } + } + + // Both conditions are required for the test to be meaningful. + Assert.True(foundUnchangedNonLastLine); + Assert.True(foundJustifiedNonParagraphLine); + } + [Theory] [InlineData(TextDirection.LeftToRight)] [InlineData(TextDirection.RightToLeft)] @@ -1569,6 +1737,27 @@ private static List CollectFirstLine(IReadOnlyList gly return line; } + private static IReadOnlyList> CollectLines(IReadOnlyList glyphs) + { + List> lines = []; + List? current = null; + + // Re-slice the flat glyph list into visual lines using IsStartOfLine so the + // tests can compare whole lines instead of reasoning about glyph indices. + for (int i = 0; i < glyphs.Count; i++) + { + if (glyphs[i].IsStartOfLine || current is null) + { + current = []; + lines.Add(current); + } + + current.Add(glyphs[i]); + } + + return lines; + } + #if OS_WINDOWS [Fact] public void BenchmarkTest()