@@ -478,6 +478,7 @@ private static List<GlyphLayout> LayoutLineVertical(
478478 ref Vector2 boxLocation ,
479479 ref Vector2 penLocation )
480480 {
481+ float originX = penLocation . X ;
481482 float originY = penLocation . Y ;
482483 float offsetY = 0 ;
483484
@@ -569,7 +570,22 @@ private static List<GlyphLayout> LayoutLineVertical(
569570 penLocation . Y += offsetY ;
570571 penLocation . X += offsetX ;
571572
572- List < GlyphLayout > glyphs = [ ] ;
573+ List < GlyphLayout > glyphs = new ( textLine . Count ) ;
574+
575+ // Grapheme-scoped state for transformed glyph alignment.
576+ //
577+ // IMPORTANT: TextLine.GlyphLayoutData is per-codepoint, not per-grapheme.
578+ // Complex scripts can therefore produce multiple entries for a single grapheme.
579+ // For example Devanagari "र्कि" can end up as two entries ("र्" and "कि") even though it
580+ // visually shapes as a single cluster.
581+ //
582+ // - Compute a single alignX for the whole grapheme (across all entries with the same GraphemeIndex).
583+ // - Apply that alignX as a positional offset only, never as part of pen/box advance.
584+ // - Transformed entries still advance along X within the grapheme (horizontal glyphs inside a vertical flow),
585+ // then X is reset at the end of the grapheme.
586+ float currentGraphemeAlignX = 0 ;
587+ bool currentGraphemeIsTransformed = false ;
588+
573589 for ( int i = 0 ; i < textLine . Count ; i ++ )
574590 {
575591 TextLine . GlyphLayoutData data = textLine [ i ] ;
@@ -595,28 +611,128 @@ private static List<GlyphLayout> LayoutLineVertical(
595611 }
596612
597613 int j = 0 ;
614+
615+ bool isFirstInGrapheme = data . GraphemeCodePointIndex == 0 ;
616+ float alignX = 0 ;
617+ float entryScaledAdvanceWidth = 0 ;
618+
619+ if ( isFirstInGrapheme )
620+ {
621+ // Reset grapheme-scoped state at the start of each grapheme.
622+ currentGraphemeAlignX = 0 ;
623+ currentGraphemeIsTransformed = false ;
624+
625+ // Determine whether this grapheme contains any transformed entries.
626+ // This is intentionally done at grapheme scope because individual entries can differ.
627+ int graphemeIndex = data . GraphemeIndex ;
628+
629+ for ( int k = i ; k < textLine . Count ; k ++ )
630+ {
631+ TextLine . GlyphLayoutData g = textLine [ k ] ;
632+
633+ if ( g . GraphemeIndex != graphemeIndex )
634+ {
635+ break ;
636+ }
637+
638+ if ( g . IsTransformed )
639+ {
640+ currentGraphemeIsTransformed = true ;
641+ break ;
642+ }
643+ }
644+
645+ if ( currentGraphemeIsTransformed )
646+ {
647+ // In vertical layout, glyphs with a vertical orientation of TransformRotate/TransformUpright are
648+ // rendered as "horizontal" glyphs inside a vertical flow.
649+ //
650+ // Their horizontal metrics (including LSB) are still expressed in the font's horizontal writing mode,
651+ // so without an adjustment these glyphs appear shifted within the column.
652+ //
653+ // To make transformed glyphs align visually with naturally-vertical glyphs, we center the ink bounds
654+ // of the ENTIRE grapheme (across all entries with the same GraphemeIndex) within the column width
655+ // (`scaledMaxLineHeight`).
656+ float minX = float . PositiveInfinity ;
657+ float maxX = float . NegativeInfinity ;
658+
659+ for ( int k = i ; k < textLine . Count ; k ++ )
660+ {
661+ TextLine . GlyphLayoutData g = textLine [ k ] ;
662+
663+ if ( g . GraphemeIndex != graphemeIndex )
664+ {
665+ break ;
666+ }
667+
668+ foreach ( GlyphMetrics m in g . Metrics )
669+ {
670+ Vector2 s = new Vector2 ( g . PointSize ) / m . ScaleFactor ;
671+
672+ float glyphMinX = m . Bounds . Min . X * s . X ;
673+ float glyphMaxX = m . Bounds . Max . X * s . X ;
674+
675+ if ( glyphMinX < minX )
676+ {
677+ minX = glyphMinX ;
678+ }
679+
680+ if ( glyphMaxX > maxX )
681+ {
682+ maxX = glyphMaxX ;
683+ }
684+ }
685+ }
686+
687+ float inkWidth = maxX - minX ;
688+
689+ // Normalize ink minX to 0 and center within the column width.
690+ // This is grapheme-correct and avoids centering based only on the "first" entry,
691+ // which is not representative for marks like reph in Devanagari.
692+ currentGraphemeAlignX = - minX + ( ( scaledMaxLineHeight - inkWidth ) * .5F ) ;
693+ }
694+ }
695+
696+ if ( currentGraphemeIsTransformed )
697+ {
698+ // Apply the grapheme-level horizontal centering offset to every entry in the grapheme.
699+ // This is positional only and must never be folded into any advance.
700+ alignX = currentGraphemeAlignX ;
701+
702+ // Transformed glyphs are still positioned using horizontal metrics (`AdvanceWidth`) even though
703+ // they participate in a vertical flow. `AdvanceWidth` gives us the horizontal pen advance we must
704+ // apply between entries inside the transformed grapheme.
705+ foreach ( GlyphMetrics m in data . Metrics )
706+ {
707+ Vector2 s = new Vector2 ( data . PointSize ) / m . ScaleFactor ;
708+ entryScaledAdvanceWidth += m . AdvanceWidth * s . X ;
709+ }
710+ }
711+
598712 foreach ( GlyphMetrics metric in data . Metrics )
599713 {
600714 // Align the glyph horizontally and vertically centering vertically around the baseline.
601715 Vector2 scale = new Vector2 ( data . PointSize ) / metric . ScaleFactor ;
602716
603- float alignX = 0 ;
604- if ( data . IsTransformed )
717+ // Offset our in both directions to account for horizontal ink centering and vertical baseline centering.
718+ Vector2 offset = new ( alignX , ( metric . Bounds . Max . Y + metric . TopSideBearing ) * scale . Y ) ;
719+
720+ float advanceW = advanceX ;
721+
722+ if ( currentGraphemeIsTransformed && ! isFirstInGrapheme )
605723 {
606- // Calculate the horizontal alignment offset:
607- // - Normalize lsb to zero
608- // - Center the glyph horizontally within the max line height.
609- alignX -= metric . LeftSideBearing * scale . X ;
610- alignX += ( scaledMaxLineHeight - ( metric . Bounds . Size ( ) . X * scale . X ) ) * .5F ;
724+ // For transformed glyphs after the first in the grapheme we advance
725+ // horizontally using the horizontal advance not the line height.
726+ // This gives us the correct total advance across the grapheme.
727+ advanceW = scale . X * metric . AdvanceWidth ;
611728 }
612729
613- Vector2 offset = new ( alignX , ( metric . Bounds . Max . Y + metric . TopSideBearing ) * scale . Y ) ;
614730 glyphs . Add ( new GlyphLayout (
615731 new Glyph ( metric , data . PointSize ) ,
616732 boxLocation ,
617733 penLocation + new Vector2 ( ( scaledMaxLineHeight - data . ScaledLineHeight ) * .5F , 0 ) ,
618734 offset ,
619- advanceX ,
735+ advanceW ,
620736 data . ScaledAdvance + yExtraAdvance ,
621737 GlyphLayoutMode . Vertical ,
622738 i == 0 && j == 0 ,
@@ -626,7 +742,19 @@ private static List<GlyphLayout> LayoutLineVertical(
626742 j ++ ;
627743 }
628744
629- penLocation . Y += data . ScaledAdvance + yExtraAdvance ;
745+ if ( currentGraphemeIsTransformed )
746+ {
747+ // Advance horizontally between entries inside the transformed grapheme.
748+ boxLocation . X += entryScaledAdvanceWidth ;
749+ penLocation . X += entryScaledAdvanceWidth ;
750+ }
751+
752+ if ( data . IsLastInGrapheme )
753+ {
754+ penLocation . Y += data . ScaledAdvance + yExtraAdvance ;
755+ boxLocation . X = originX ;
756+ penLocation . X = originX ;
757+ }
630758 }
631759
632760 boxLocation . Y = originY ;
@@ -774,9 +902,6 @@ private static List<GlyphLayout> LayoutLineVerticalMixed(
774902 // The glyph will be rotated 90 degrees for vertical mixed layout.
775903 // We still advance along Y, but the glyphs are laid out sideways in X.
776904
777- // Compute the scale that converts design units to pixels for this size.
778- Vector2 scale = new Vector2 ( data . PointSize ) / metric . ScaleFactor ;
779-
780905 // Calculate the initial horizontal offset to center the glyph baseline:
781906 // - Take half the difference between the max line height (scaledMaxLineHeight)
782907 // and the current glyph's line height (data.ScaledLineHeight).
@@ -910,7 +1035,7 @@ private static bool DoFontRun(
9101035 charIndex += charsConsumed ;
9111036
9121037 // Get the glyph id for the codepoint and add to the collection.
913- font . FontMetrics . TryGetGlyphId ( current , next , out ushort glyphId , out skipNextCodePoint ) ;
1038+ _ = font . FontMetrics . TryGetGlyphId ( current , next , out ushort glyphId , out skipNextCodePoint ) ;
9141039 substitutions . AddGlyph ( glyphId , current , ( TextDirection ) bidiRuns [ bidiRunIndex ] . Direction , textRuns [ textRunIndex ] , codePointIndex ) ;
9151040
9161041 codePointIndex ++ ;
@@ -1013,8 +1138,9 @@ private static TextBox BreakLines(
10131138 for ( graphemeIndex = 0 ; graphemeEnumerator . MoveNext ( ) ; graphemeIndex ++ )
10141139 {
10151140 // Now enumerate through each codepoint in the grapheme.
1141+ ReadOnlySpan < char > grapheme = graphemeEnumerator . Current ;
10161142 int graphemeCodePointIndex = 0 ;
1017- SpanCodePointEnumerator codePointEnumerator = new ( graphemeEnumerator . Current ) ;
1143+ SpanCodePointEnumerator codePointEnumerator = new ( grapheme ) ;
10181144 while ( codePointEnumerator . MoveNext ( ) )
10191145 {
10201146 if ( ! positionings . TryGetGlyphMetricsAtOffset (
@@ -1041,8 +1167,7 @@ private static TextBox BreakLines(
10411167 //
10421168 // Note: Not all glyphs in a font will have a codepoint associated with them. e.g. most compositions, ligatures, etc.
10431169 CodePoint codePoint = codePointEnumerator . Current ;
1044- if ( isSubstituted &&
1045- metrics . Count == 1 )
1170+ if ( isSubstituted && metrics . Count == 1 )
10461171 {
10471172 codePoint = glyph . CodePoint ;
10481173 }
@@ -1187,14 +1312,39 @@ VerticalOrientationType.Rotate or
11871312 }
11881313 }
11891314
1315+ int graphemeCodePointMax = CodePoint . GetCodePointCount ( grapheme ) - 1 ;
1316+
11901317 // For non-decomposed glyphs the length is always 1.
11911318 for ( int i = 0 ; i < decomposedAdvances . Length ; i ++ )
11921319 {
1320+ // Determine if this is the last codepoint in the grapheme.
1321+ bool isLastInGrapheme = graphemeCodePointIndex == graphemeCodePointMax && i == decomposedAdvances . Length - 1 ;
1322+
11931323 float decomposedAdvance = decomposedAdvances [ i ] ;
11941324
11951325 // Work out the scaled metrics for the glyph.
11961326 GlyphMetrics metric = metrics [ i ] ;
11971327
1328+ // Adjust the advance for the last decomposed glyph to add tracking if applicable.
1329+ // Tracking should only be added once per grapheme, so only on the last codepoint of the grapheme.
1330+ if ( isLastInGrapheme && options . Tracking != 0 && i == decomposedAdvances . Length - 1 )
1331+ {
1332+ // Tracking should not be applied to tab characters or non-rendered codepoints.
1333+ if ( ! CodePoint . IsTabulation ( codePoint ) && ! UnicodeUtility . ShouldNotBeRendered ( codePoint ) )
1334+ {
1335+ if ( isHorizontalLayout || shouldRotate )
1336+ {
1337+ float scaleAX = pointSize / glyph . ScaleFactor . X ;
1338+ decomposedAdvance += options . Tracking * metric . FontMetrics . UnitsPerEm * scaleAX ;
1339+ }
1340+ else
1341+ {
1342+ float scaleAY = pointSize / glyph . ScaleFactor . Y ;
1343+ decomposedAdvance += options . Tracking * metric . FontMetrics . UnitsPerEm * scaleAY ;
1344+ }
1345+ }
1346+ }
1347+
11981348 // Convert design-space units to pixels based on the target point size.
11991349 // ScaleFactor.Y represents the vertical UPEM scaling factor for this glyph.
12001350 float scaleY = pointSize / metric . ScaleFactor . Y ;
@@ -1244,6 +1394,7 @@ VerticalOrientationType.Rotate or
12441394 descender ,
12451395 bidiRuns [ bidiMap [ codePointIndex ] ] ,
12461396 graphemeIndex ,
1397+ isLastInGrapheme ,
12471398 codePointIndex ,
12481399 graphemeCodePointIndex ,
12491400 shouldRotate || shouldOffset ,
@@ -1460,6 +1611,7 @@ public void Add(
14601611 float scaledDescender ,
14611612 BidiRun bidiRun ,
14621613 int graphemeIndex ,
1614+ bool isLastInGrapheme ,
14631615 int codePointIndex ,
14641616 int graphemeCodePointIndex ,
14651617 bool isTransformed ,
@@ -1471,6 +1623,7 @@ public void Add(
14711623 // We track the maximum metrics for each line to ensure glyphs can be aligned.
14721624 if ( graphemeCodePointIndex == 0 )
14731625 {
1626+ // TODO: Check this logic is correct.
14741627 this . ScaledLineAdvance += scaledAdvance ;
14751628 }
14761629
@@ -1515,6 +1668,7 @@ public void Add(
15151668 scaledMinY ,
15161669 bidiRun ,
15171670 graphemeIndex ,
1671+ isLastInGrapheme ,
15181672 codePointIndex ,
15191673 graphemeCodePointIndex ,
15201674 isTransformed ,
@@ -1928,6 +2082,7 @@ public GlyphLayoutData(
19282082 float scaledMinY ,
19292083 BidiRun bidiRun ,
19302084 int graphemeIndex ,
2085+ bool isLastInGrapheme ,
19312086 int codePointIndex ,
19322087 int graphemeCodePointIndex ,
19332088 bool isTransformed ,
@@ -1943,6 +2098,7 @@ public GlyphLayoutData(
19432098 this . ScaledMinY = scaledMinY ;
19442099 this . BidiRun = bidiRun ;
19452100 this . GraphemeIndex = graphemeIndex ;
2101+ this . IsLastInGrapheme = isLastInGrapheme ;
19462102 this . CodePointIndex = codePointIndex ;
19472103 this . GraphemeCodePointIndex = graphemeCodePointIndex ;
19482104 this . IsTransformed = isTransformed ;
@@ -1972,6 +2128,8 @@ public GlyphLayoutData(
19722128
19732129 public int GraphemeIndex { get ; }
19742130
2131+ public bool IsLastInGrapheme { get ; }
2132+
19752133 public int GraphemeCodePointIndex { get ; }
19762134
19772135 public int CodePointIndex { get ; }
0 commit comments