Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/SixLabors.Fonts/TextLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1195,6 +1195,22 @@ VerticalOrientationType.Rotate or
// Work out the scaled metrics for the glyph.
GlyphMetrics metric = metrics[i];

// Adjust the advance for the last decomposed glyph to add
// tracking if applicable.
if (options.Tracking != 0 && decomposedAdvance > 0 && i == decomposedAdvances.Length - 1)
{
if (isHorizontalLayout || shouldRotate)
{
float scaleAX = pointSize / glyph.ScaleFactor.X;
decomposedAdvance += options.Tracking * metric.FontMetrics.UnitsPerEm * scaleAX;
}
else
{
float scaleAY = pointSize / glyph.ScaleFactor.Y;
decomposedAdvance += options.Tracking * metric.FontMetrics.UnitsPerEm * scaleAY;
}
}

// Convert design-space units to pixels based on the target point size.
// ScaleFactor.Y represents the vertical UPEM scaling factor for this glyph.
float scaleY = pointSize / metric.ScaleFactor.Y;
Expand Down
8 changes: 8 additions & 0 deletions src/SixLabors.Fonts/TextOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public TextOptions(TextOptions options)
this.VerticalAlignment = options.VerticalAlignment;
this.LayoutMode = options.LayoutMode;
this.KerningMode = options.KerningMode;
this.Tracking = options.Tracking;
this.ColorFontSupport = options.ColorFontSupport;
this.FeatureTags = new List<Tag>(options.FeatureTags);
this.TextRuns = new List<TextRun>(options.TextRuns);
Expand Down Expand Up @@ -170,6 +171,13 @@ public float LineSpacing
/// </summary>
public KerningMode KerningMode { get; set; }

/// <summary>
/// Gets or sets the tracking (letter-spacing) value.
/// Tracking adjusts the spacing between all characters uniformly and is measured in em.
/// Positive values increase spacing, negative values decrease spacing, and zero applies no adjustment.
/// </summary>
public float Tracking { get; set; }

/// <summary>
/// Gets or sets the positioning mode used for rendering decorations.
/// </summary>
Expand Down
90 changes: 90 additions & 0 deletions tests/SixLabors.Fonts.Tests/TextLayoutTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,96 @@ public void TrueTypeHinting_CanHintSmallOpenSans(char c, FontRectangle expected)
Assert.Equal(expected, actual, Comparer);
}

public static TheoryData<string, float, float, float[]> FontTrackingHorizontalData
= new()
{
{ "aaaa", 0.0f, 134.0f, [2.9f, 38.5f, 74.0f, 109.6f] },
{ "aaaa", 0.1f, 153.3f, [2.9f, 44.9f, 86.8f, 128.8f] },
{ "aaaa", 1.0f, 326.1f, [2.9f, 102.5f, 202.0f, 301.6f] },
{ "awwa", 0.0f, 162.1f, [2.9f, 36.3f, 85.9f, 137.6f] },
{ "awwa", 0.1f, 181.4f, [2.9f, 42.7f, 98.7f, 156.8f] },
{ "awwa", 1.0f, 354.1f, [2.9f, 100.3f, 213.9f, 329.6f] },
};

[Theory]
[MemberData(nameof(FontTrackingHorizontalData))]
public void FontTracking_SpaceCharacters_WithHorizontalLayout(string text, float tracking, float width, float[] characterPosition)
{
Font font = new FontCollection().Add(TestFonts.OpenSansFile).CreateFont(64);
TextOptions options = new(font)
{
Tracking = tracking,
};

FontRectangle actual = TextMeasurer.MeasureSize(text, options);
Assert.Equal(width, actual.Width, Comparer);

Assert.True(TextMeasurer.TryMeasureCharacterBounds(text, options, out ReadOnlySpan<GlyphBounds> bounds));
Assert.Equal(characterPosition, bounds.ToArray().Select(x => x.Bounds.X), Comparer);
}

public static TheoryData<string, float, float, float[]> FontTrackingVerticalData
= new()
{
{ "aaaa", 0.0f, 296.9f, [33.5f, 120.7f, 207.9f, 295.0f] },
{ "aaaa", 0.1f, 316.1f, [33.5f, 127.1f, 220.7f, 314.2f] },
{ "aaaa", 1.0f, 488.9f, [33.5f, 184.7f, 335.9f, 487.0f] },
{ "awwa", 0.0f, 296.9f, [33.5f, 121.2f, 208.4f, 295.0f] },
{ "awwa", 0.1f, 316.1f, [33.5f, 127.6f, 221.2f, 314.2f] },
{ "awwa", 1.0f, 488.9f, [33.5f, 185.2f, 336.4f, 487.0f] },
};

[Theory]
[MemberData(nameof(FontTrackingVerticalData))]
public void FontTracking_SpaceCharacters_WithVerticalLayout(string text, float tracking, float width, float[] characterPosition)
{
Font font = new FontCollection().Add(TestFonts.OpenSansFile).CreateFont(64);
TextOptions options = new(font)
{
Tracking = tracking,
LayoutMode = LayoutMode.VerticalLeftRight,
};

FontRectangle actual = TextMeasurer.MeasureSize(text, options);
Assert.Equal(width, actual.Height, Comparer);

Assert.True(TextMeasurer.TryMeasureCharacterBounds(text, options, out ReadOnlySpan<GlyphBounds> bounds));
Assert.Equal(characterPosition, bounds.ToArray().Select(x => x.Bounds.Y), Comparer);
}


[Theory]
[InlineData("\u1B3C", 1, 83.8)]
[InlineData("\u1B3C\u1B3C", 1, 83.8)]
public void FontTracking_DoNotAddSpacingAfterCharacterThatDidNotAdvance(string text, float tracking, float width)
{
Font font = new FontCollection().Add(TestFonts.NotoSansBalineseRegular).CreateFont(64);
TextOptions options = new(font)
{
Tracking = tracking,
};

FontRectangle actual = TextMeasurer.MeasureSize(text, options);
Assert.Equal(width, actual.Width, Comparer);
}

[Theory]
[InlineData("\u093f", 1, 48.4)]
[InlineData("\u0930\u094D\u0915\u093F", 1, 225.6)]
[InlineData("\u0930\u094D\u0915\u093F\u0930\u094D\u0915\u093F", 1, 419)]
[InlineData("\u093fa", 1, 145.5f)]
public void FontTracking_CorrectlyAddSpacingForComposedCharacter(string text, float tracking, float width)
{
Font font = new FontCollection().Add(TestFonts.NotoSansDevanagariRegular).CreateFont(64);
TextOptions options = new(font)
{
Tracking = tracking,
};

FontRectangle actual = TextMeasurer.MeasureSize(text, options);
Assert.Equal(width, actual.Width, Comparer);
}

[Fact]
public void CanMeasureTextAdvance()
{
Expand Down
85 changes: 45 additions & 40 deletions tests/SixLabors.Fonts.Tests/TextOptionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public TextOptionsTests()
public void ConstructorTest_FontOnly()
{
Font font = FakeFont.CreateFont("ABC");
var options = new TextOptions(font);
TextOptions options = new(font);

Assert.Equal(72, options.Dpi);
Assert.Empty(options.FallbackFontFamilies);
Expand All @@ -38,7 +38,7 @@ public void ConstructorTest_FontWithSingleDpi()
{
Font font = FakeFont.CreateFont("ABC");
const float dpi = 123;
var options = new TextOptions(font) { Dpi = dpi };
TextOptions options = new(font) { Dpi = dpi };

Assert.Equal(dpi, options.Dpi);
Assert.Empty(options.FallbackFontFamilies);
Expand All @@ -51,7 +51,7 @@ public void ConstructorTest_FontWithSingleDpi()
public void ConstructorTest_FontWithOrigin()
{
Font font = FakeFont.CreateFont("ABC");
var origin = new Vector2(123, 345);
Vector2 origin = new(123, 345);
TextOptions options = new(font) { Origin = origin };

Assert.Equal(72, options.Dpi);
Expand All @@ -65,7 +65,7 @@ public void ConstructorTest_FontWithOrigin()
public void ConstructorTest_FontWithSingleDpiWithOrigin()
{
Font font = FakeFont.CreateFont("ABC");
var origin = new Vector2(123, 345);
Vector2 origin = new(123, 345);
const float dpi = 123;
TextOptions options = new(font) { Dpi = dpi, Origin = origin };

Expand All @@ -80,13 +80,13 @@ public void ConstructorTest_FontWithSingleDpiWithOrigin()
public void ConstructorTest_FontOnly_WithFallbackFonts()
{
Font font = FakeFont.CreateFont("ABC");
FontFamily[] fontFamilies = new[]
{
FontFamily[] fontFamilies =
[
FakeFont.CreateFont("DEF").Family,
FakeFont.CreateFont("GHI").Family
};
FakeFont.CreateFont("GHI").Family,
];

var options = new TextOptions(font)
TextOptions options = new(font)
{
FallbackFontFamilies = fontFamilies
};
Expand All @@ -102,14 +102,14 @@ public void ConstructorTest_FontOnly_WithFallbackFonts()
public void ConstructorTest_FontWithSingleDpi_WithFallbackFonts()
{
Font font = FakeFont.CreateFont("ABC");
FontFamily[] fontFamilies = new[]
{
FontFamily[] fontFamilies =
[
FakeFont.CreateFont("DEF").Family,
FakeFont.CreateFont("GHI").Family
};
FakeFont.CreateFont("GHI").Family,
];

const float dpi = 123;
var options = new TextOptions(font)
TextOptions options = new(font)
{
Dpi = dpi,
FallbackFontFamilies = fontFamilies
Expand All @@ -126,13 +126,13 @@ public void ConstructorTest_FontWithSingleDpi_WithFallbackFonts()
public void ConstructorTest_FontWithOrigin_WithFallbackFonts()
{
Font font = FakeFont.CreateFont("ABC");
FontFamily[] fontFamilies = new[]
{
FontFamily[] fontFamilies =
[
FakeFont.CreateFont("DEF").Family,
FakeFont.CreateFont("GHI").Family
};
FakeFont.CreateFont("GHI").Family,
];

var origin = new Vector2(123, 345);
Vector2 origin = new(123, 345);
TextOptions options = new(font)
{
FallbackFontFamilies = fontFamilies,
Expand All @@ -150,13 +150,13 @@ public void ConstructorTest_FontWithOrigin_WithFallbackFonts()
public void ConstructorTest_FontWithSingleDpiWithOrigin_WithFallbackFonts()
{
Font font = FakeFont.CreateFont("ABC");
FontFamily[] fontFamilies = new[]
{
FontFamily[] fontFamilies =
[
FakeFont.CreateFont("DEF").Family,
FakeFont.CreateFont("GHI").Family
};
FakeFont.CreateFont("GHI").Family,
];

var origin = new Vector2(123, 345);
Vector2 origin = new(123, 345);
const float dpi = 123;
TextOptions options = new(font)
{
Expand All @@ -176,20 +176,20 @@ public void ConstructorTest_FontWithSingleDpiWithOrigin_WithFallbackFonts()
public void GetMissingGlyphFromMainFont()
{
Font font = FakeFont.CreateFontWithInstance("ABC", "ABC", out Fakes.FakeFontInstance abcFontInstance);
FontFamily[] fontFamilies = new[]
{
FakeFont.CreateFontWithInstance("DEF", "DEF", out Fakes.FakeFontInstance defFontInstance).Family,
FakeFont.CreateFontWithInstance("GHI", "GHI", out Fakes.FakeFontInstance ghiFontInstance).Family
};
FontFamily[] fontFamilies =
[
FakeFont.CreateFontWithInstance("DEF", "DEF", out Fakes.FakeFontInstance _).Family,
FakeFont.CreateFontWithInstance("GHI", "GHI", out Fakes.FakeFontInstance _).Family,
];

var options = new TextOptions(font)
TextOptions options = new(font)
{
FallbackFontFamilies = fontFamilies,
ColorFontSupport = ColorFontSupport.None
};

ReadOnlySpan<char> text = "Z".AsSpan();
var renderer = new GlyphRenderer();
GlyphRenderer renderer = new();
TextRenderer.RenderTextTo(renderer, text, options);

GlyphRendererParameters glyph = Assert.Single(renderer.GlyphKeys);
Expand All @@ -204,20 +204,20 @@ public void GetMissingGlyphFromMainFont()
public void GetGlyphFromFirstAvailableInstance(char character, string instance)
{
Font font = FakeFont.CreateFontWithInstance("ABC", "ABC", out Fakes.FakeFontInstance abcFontInstance);
FontFamily[] fontFamilies = new[]
{
FontFamily[] fontFamilies =
[
FakeFont.CreateFontWithInstance("DEF", "DEF", out Fakes.FakeFontInstance defFontInstance).Family,
FakeFont.CreateFontWithInstance("EFGHI", "EFGHI", out Fakes.FakeFontInstance efghiFontInstance).Family
};
FakeFont.CreateFontWithInstance("EFGHI", "EFGHI", out Fakes.FakeFontInstance efghiFontInstance).Family,
];

var options = new TextOptions(font)
TextOptions options = new(font)
{
FallbackFontFamilies = fontFamilies,
ColorFontSupport = ColorFontSupport.None
};

ReadOnlySpan<char> text = new[] { character };
var renderer = new GlyphRenderer();
GlyphRenderer renderer = new();
TextRenderer.RenderTextTo(renderer, text, options);
GlyphRendererParameters glyph = Assert.Single(renderer.GlyphKeys);
Assert.Equal(GlyphType.Standard, glyph.GlyphType);
Expand All @@ -233,7 +233,7 @@ public void GetGlyphFromFirstAvailableInstance(char character, string instance)
}

[Fact]
public void CloneTextOptionsIsNotNull() => Assert.True(this.clonedTextOptions != null);
public void CloneTextOptionsIsNotNull() => Assert.NotNull(this.clonedTextOptions);

[Fact]
public void DefaultTextOptionsApplyKerning()
Expand Down Expand Up @@ -311,6 +311,7 @@ public void NonDefaultClone()
LineSpacing = -1F,
VerticalAlignment = VerticalAlignment.Bottom,
WrappingLength = 42F,
Tracking = 66F,
FeatureTags = new List<Tag> { FeatureTags.OldstyleFigures }
};

Expand All @@ -324,12 +325,13 @@ public void NonDefaultClone()
Assert.Equal(expected.VerticalAlignment, actual.VerticalAlignment);
Assert.Equal(expected.WrappingLength, actual.WrappingLength);
Assert.Equal(expected.FeatureTags, actual.FeatureTags);
Assert.Equal(expected.Tracking, actual.Tracking);
}

[Fact]
public void CloneIsDeep()
{
var expected = new TextOptions(this.fakeFont);
TextOptions expected = new(this.fakeFont);
TextOptions actual = new(expected)
{
KerningMode = KerningMode.None,
Expand All @@ -339,7 +341,8 @@ public void CloneIsDeep()
LineSpacing = 2F,
VerticalAlignment = VerticalAlignment.Bottom,
TextJustification = TextJustification.InterCharacter,
WrappingLength = 42F
WrappingLength = 42F,
Tracking = 66F,
};

Assert.NotEqual(expected.KerningMode, actual.KerningMode);
Expand All @@ -350,6 +353,7 @@ public void CloneIsDeep()
Assert.NotEqual(expected.VerticalAlignment, actual.VerticalAlignment);
Assert.NotEqual(expected.WrappingLength, actual.WrappingLength);
Assert.NotEqual(expected.TextJustification, actual.TextJustification);
Assert.NotEqual(expected.Tracking, actual.Tracking);
}

private static void VerifyPropertyDefault(TextOptions options)
Expand All @@ -364,5 +368,6 @@ private static void VerifyPropertyDefault(TextOptions options)
Assert.Equal(TextDirection.Auto, options.TextDirection);
Assert.Equal(LayoutMode.HorizontalTopBottom, options.LayoutMode);
Assert.Equal(1, options.LineSpacing);
Assert.Equal(0, options.Tracking);
}
}