@@ -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