@@ -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,117 @@ private static List<GlyphLayout> LayoutLineVertical(
595611 }
596612
597613 int j = 0 ;
598- foreach ( GlyphMetrics metric in data . Metrics )
614+
615+ bool isFirstInGrapheme = data . GraphemeCodePointIndex == 0 ;
616+ float alignX = 0 ;
617+ float entryScaledAdvanceWidth = 0 ;
618+
619+ if ( isFirstInGrapheme )
599620 {
600- // Align the glyph horizontally and vertically centering vertically around the baseline.
601- Vector2 scale = new Vector2 ( data . PointSize ) / metric . ScaleFactor ;
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+ if ( textLine [ k ] . IsTransformed )
632+ {
633+ currentGraphemeIsTransformed = true ;
634+ break ;
635+ }
636+
637+ if ( textLine [ k ] . IsLastInGrapheme )
638+ {
639+ break ;
640+ }
641+ }
642+
643+ if ( currentGraphemeIsTransformed )
644+ {
645+ // In vertical layout, glyphs with a vertical orientation of TransformRotate/TransformUpright are
646+ // rendered as "horizontal" glyphs inside a vertical flow.
647+ //
648+ // Their horizontal metrics (including LSB) are still expressed in the font's horizontal writing mode,
649+ // so without an adjustment these glyphs appear shifted within the column.
650+ //
651+ // To make transformed glyphs align visually with naturally-vertical glyphs, we center the ink bounds
652+ // of the ENTIRE grapheme (across all entries with the same GraphemeIndex) within the column width
653+ // (`scaledMaxLineHeight`).
654+ float minX = float . PositiveInfinity ;
655+ float maxX = float . NegativeInfinity ;
656+
657+ for ( int k = i ; k < textLine . Count && textLine [ k ] . GraphemeIndex == graphemeIndex ; k ++ )
658+ {
659+ TextLine . GlyphLayoutData g = textLine [ k ] ;
660+
661+ foreach ( GlyphMetrics m in g . Metrics )
662+ {
663+ Vector2 s = new Vector2 ( g . PointSize ) / m . ScaleFactor ;
664+
665+ float glyphMinX = m . Bounds . Min . X * s . X ;
666+ float glyphMaxX = m . Bounds . Max . X * s . X ;
667+
668+ if ( glyphMinX < minX )
669+ {
670+ minX = glyphMinX ;
671+ }
672+
673+ if ( glyphMaxX > maxX )
674+ {
675+ maxX = glyphMaxX ;
676+ }
677+ }
678+
679+ if ( g . IsLastInGrapheme )
680+ {
681+ break ;
682+ }
683+ }
684+
685+ float inkWidth = maxX - minX ;
602686
603- float alignX = 0 ;
604- if ( data . IsTransformed )
687+ // Normalize ink minX to 0 and center within the column width.
688+ // This is grapheme-correct and avoids centering based only on the "first" entry,
689+ // which is not representative for marks like reph in Devanagari.
690+ currentGraphemeAlignX = - minX + ( ( scaledMaxLineHeight - inkWidth ) * .5F ) ;
691+ }
692+ }
693+
694+ if ( currentGraphemeIsTransformed )
695+ {
696+ // Apply the grapheme-level horizontal centering offset to every entry in the grapheme.
697+ // This is positional only and must never be folded into any advance.
698+ alignX = currentGraphemeAlignX ;
699+
700+ // Transformed glyphs are still positioned using horizontal metrics (`AdvanceWidth`) even though
701+ // they participate in a vertical flow. `AdvanceWidth` gives us the horizontal pen advance we must
702+ // apply between entries inside the transformed grapheme.
703+ foreach ( GlyphMetrics m in data . Metrics )
605704 {
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 ;
705+ Vector2 s = new Vector2 ( data . PointSize ) / m . ScaleFactor ;
706+ entryScaledAdvanceWidth += m . AdvanceWidth * s . X ;
611707 }
708+ }
612709
710+ foreach ( GlyphMetrics metric in data . Metrics )
711+ {
712+ // Align the glyph horizontally and vertically centering vertically around the baseline.
713+ Vector2 scale = new Vector2 ( data . PointSize ) / metric . ScaleFactor ;
714+
715+ // Offset our in both directions to account for horizontal ink centering and vertical baseline centering.
613716 Vector2 offset = new ( alignX , ( metric . Bounds . Max . Y + metric . TopSideBearing ) * scale . Y ) ;
717+
718+ // For transformed glyphs we advance horizontally using the horizontal advance not the line height.
614719 glyphs . Add ( new GlyphLayout (
615720 new Glyph ( metric , data . PointSize ) ,
616721 boxLocation ,
617722 penLocation + new Vector2 ( ( scaledMaxLineHeight - data . ScaledLineHeight ) * .5F , 0 ) ,
618723 offset ,
619- advanceX ,
724+ currentGraphemeIsTransformed ? scale . X * metric . AdvanceWidth : advanceX ,
620725 data . ScaledAdvance + yExtraAdvance ,
621726 GlyphLayoutMode . Vertical ,
622727 i == 0 && j == 0 ,
@@ -626,7 +731,19 @@ private static List<GlyphLayout> LayoutLineVertical(
626731 j ++ ;
627732 }
628733
629- penLocation . Y += data . ScaledAdvance + yExtraAdvance ;
734+ if ( currentGraphemeIsTransformed )
735+ {
736+ // Advance horizontally between entries inside the transformed grapheme.
737+ boxLocation . X += entryScaledAdvanceWidth ;
738+ penLocation . X += entryScaledAdvanceWidth ;
739+ }
740+
741+ if ( data . IsLastInGrapheme )
742+ {
743+ penLocation . Y += data . ScaledAdvance + yExtraAdvance ;
744+ boxLocation . X = originX ;
745+ penLocation . X = originX ;
746+ }
630747 }
631748
632749 boxLocation . Y = originY ;
@@ -774,9 +891,6 @@ private static List<GlyphLayout> LayoutLineVerticalMixed(
774891 // The glyph will be rotated 90 degrees for vertical mixed layout.
775892 // We still advance along Y, but the glyphs are laid out sideways in X.
776893
777- // Compute the scale that converts design units to pixels for this size.
778- Vector2 scale = new Vector2 ( data . PointSize ) / metric . ScaleFactor ;
779-
780894 // Calculate the initial horizontal offset to center the glyph baseline:
781895 // - Take half the difference between the max line height (scaledMaxLineHeight)
782896 // and the current glyph's line height (data.ScaledLineHeight).
@@ -1013,8 +1127,9 @@ private static TextBox BreakLines(
10131127 for ( graphemeIndex = 0 ; graphemeEnumerator . MoveNext ( ) ; graphemeIndex ++ )
10141128 {
10151129 // Now enumerate through each codepoint in the grapheme.
1130+ ReadOnlySpan < char > grapheme = graphemeEnumerator . Current ;
10161131 int graphemeCodePointIndex = 0 ;
1017- SpanCodePointEnumerator codePointEnumerator = new ( graphemeEnumerator . Current ) ;
1132+ SpanCodePointEnumerator codePointEnumerator = new ( grapheme ) ;
10181133 while ( codePointEnumerator . MoveNext ( ) )
10191134 {
10201135 if ( ! positionings . TryGetGlyphMetricsAtOffset (
@@ -1187,6 +1302,8 @@ VerticalOrientationType.Rotate or
11871302 }
11881303 }
11891304
1305+ bool isLastInGrapheme = graphemeCodePointIndex == CodePoint . GetCodePointCount ( grapheme ) - 1 ;
1306+
11901307 // For non-decomposed glyphs the length is always 1.
11911308 for ( int i = 0 ; i < decomposedAdvances . Length ; i ++ )
11921309 {
@@ -1195,19 +1312,23 @@ VerticalOrientationType.Rotate or
11951312 // Work out the scaled metrics for the glyph.
11961313 GlyphMetrics metric = metrics [ i ] ;
11971314
1198- // Adjust the advance for the last decomposed glyph to add
1199- // tracking if applicable .
1200- if ( options . Tracking != 0 && decomposedAdvance > 0 && i == decomposedAdvances . Length - 1 )
1315+ // Adjust the advance for the last decomposed glyph to add tracking if applicable.
1316+ // Tracking should only be added once per grapheme, so only on the last codepoint of the grapheme .
1317+ if ( isLastInGrapheme && options . Tracking != 0 && i == decomposedAdvances . Length - 1 )
12011318 {
1202- if ( isHorizontalLayout || shouldRotate )
1319+ // Tracking should not be applied to tab characters or non-rendered codepoints.
1320+ if ( ! CodePoint . IsTabulation ( codePoint ) && ! UnicodeUtility . ShouldNotBeRendered ( codePoint ) )
12031321 {
1204- float scaleAX = pointSize / glyph . ScaleFactor . X ;
1205- decomposedAdvance += options . Tracking * metric . FontMetrics . UnitsPerEm * scaleAX ;
1206- }
1207- else
1208- {
1209- float scaleAY = pointSize / glyph . ScaleFactor . Y ;
1210- decomposedAdvance += options . Tracking * metric . FontMetrics . UnitsPerEm * scaleAY ;
1322+ if ( isHorizontalLayout || shouldRotate )
1323+ {
1324+ float scaleAX = pointSize / glyph . ScaleFactor . X ;
1325+ decomposedAdvance += options . Tracking * metric . FontMetrics . UnitsPerEm * scaleAX ;
1326+ }
1327+ else
1328+ {
1329+ float scaleAY = pointSize / glyph . ScaleFactor . Y ;
1330+ decomposedAdvance += options . Tracking * metric . FontMetrics . UnitsPerEm * scaleAY ;
1331+ }
12111332 }
12121333 }
12131334
@@ -1260,6 +1381,7 @@ VerticalOrientationType.Rotate or
12601381 descender ,
12611382 bidiRuns [ bidiMap [ codePointIndex ] ] ,
12621383 graphemeIndex ,
1384+ isLastInGrapheme ,
12631385 codePointIndex ,
12641386 graphemeCodePointIndex ,
12651387 shouldRotate || shouldOffset ,
@@ -1476,6 +1598,7 @@ public void Add(
14761598 float scaledDescender ,
14771599 BidiRun bidiRun ,
14781600 int graphemeIndex ,
1601+ bool isLastInGrapheme ,
14791602 int codePointIndex ,
14801603 int graphemeCodePointIndex ,
14811604 bool isTransformed ,
@@ -1487,6 +1610,7 @@ public void Add(
14871610 // We track the maximum metrics for each line to ensure glyphs can be aligned.
14881611 if ( graphemeCodePointIndex == 0 )
14891612 {
1613+ // TODO: Check this logic is correct.
14901614 this . ScaledLineAdvance += scaledAdvance ;
14911615 }
14921616
@@ -1531,6 +1655,7 @@ public void Add(
15311655 scaledMinY ,
15321656 bidiRun ,
15331657 graphemeIndex ,
1658+ isLastInGrapheme ,
15341659 codePointIndex ,
15351660 graphemeCodePointIndex ,
15361661 isTransformed ,
@@ -1944,6 +2069,7 @@ public GlyphLayoutData(
19442069 float scaledMinY ,
19452070 BidiRun bidiRun ,
19462071 int graphemeIndex ,
2072+ bool isLastInGrapheme ,
19472073 int codePointIndex ,
19482074 int graphemeCodePointIndex ,
19492075 bool isTransformed ,
@@ -1959,6 +2085,7 @@ public GlyphLayoutData(
19592085 this . ScaledMinY = scaledMinY ;
19602086 this . BidiRun = bidiRun ;
19612087 this . GraphemeIndex = graphemeIndex ;
2088+ this . IsLastInGrapheme = isLastInGrapheme ;
19622089 this . CodePointIndex = codePointIndex ;
19632090 this . GraphemeCodePointIndex = graphemeCodePointIndex ;
19642091 this . IsTransformed = isTransformed ;
@@ -1988,6 +2115,8 @@ public GlyphLayoutData(
19882115
19892116 public int GraphemeIndex { get ; }
19902117
2118+ public bool IsLastInGrapheme { get ; }
2119+
19912120 public int GraphemeCodePointIndex { get ; }
19922121
19932122 public int CodePointIndex { get ; }
0 commit comments