Skip to content

Commit 5ff7825

Browse files
Merge pull request #495 from SixLabors/js/add-tracking
Add support for tracking (letter-spacing) REDUX
2 parents 46125b0 + 660e253 commit 5ff7825

21 files changed

+556
-121
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: 176 additions & 18 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,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; }

src/SixLabors.Fonts/TextOptions.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public TextOptions(TextOptions options)
4444
this.VerticalAlignment = options.VerticalAlignment;
4545
this.LayoutMode = options.LayoutMode;
4646
this.KerningMode = options.KerningMode;
47+
this.Tracking = options.Tracking;
4748
this.ColorFontSupport = options.ColorFontSupport;
4849
this.FeatureTags = new List<Tag>(options.FeatureTags);
4950
this.TextRuns = new List<TextRun>(options.TextRuns);
@@ -171,6 +172,13 @@ public float LineSpacing
171172
/// </summary>
172173
public KerningMode KerningMode { get; set; }
173174

175+
/// <summary>
176+
/// Gets or sets the tracking (letter-spacing) value.
177+
/// Tracking adjusts the spacing between all characters uniformly and is measured in em.
178+
/// Positive values increase spacing, negative values decrease spacing, and zero applies no adjustment.
179+
/// </summary>
180+
public float Tracking { get; set; }
181+
174182
/// <summary>
175183
/// Gets or sets the positioning mode used for rendering decorations.
176184
/// </summary>

0 commit comments

Comments
 (0)