Skip to content

Commit 65cea84

Browse files
Fix grapheme enumeration and vertical layout
1 parent 4ec467d commit 65cea84

File tree

5 files changed

+292
-90
lines changed

5 files changed

+292
-90
lines changed

src/SixLabors.Fonts/GlyphMetrics.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -489,8 +489,7 @@ void SetDecoration(TextDecorations decorations, float thickness, float position)
489489
/// <returns>The <see cref="bool"/>.</returns>
490490
[MethodImpl(MethodImplOptions.AggressiveInlining)]
491491
protected internal static bool ShouldSkipGlyphRendering(CodePoint codePoint)
492-
=> CodePoint.IsNewLine(codePoint) ||
493-
(UnicodeUtility.IsDefaultIgnorableCodePoint((uint)codePoint.Value) && !UnicodeUtility.ShouldRenderWhiteSpaceOnly(codePoint));
492+
=> UnicodeUtility.ShouldNotBeRendered(codePoint);
494493

495494
/// <summary>
496495
/// Returns the size to render/measure the glyph based on the given size and resolution in px units.

src/SixLabors.Fonts/TextLayout.cs

Lines changed: 157 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)