diff --git a/src/SixLabors.Fonts/GlyphMetrics.cs b/src/SixLabors.Fonts/GlyphMetrics.cs
index ef37544d..fef13c07 100644
--- a/src/SixLabors.Fonts/GlyphMetrics.cs
+++ b/src/SixLabors.Fonts/GlyphMetrics.cs
@@ -489,8 +489,7 @@ void SetDecoration(TextDecorations decorations, float thickness, float position)
/// The .
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected internal static bool ShouldSkipGlyphRendering(CodePoint codePoint)
- => CodePoint.IsNewLine(codePoint) ||
- (UnicodeUtility.IsDefaultIgnorableCodePoint((uint)codePoint.Value) && !UnicodeUtility.ShouldRenderWhiteSpaceOnly(codePoint));
+ => UnicodeUtility.ShouldNotBeRendered(codePoint);
///
/// Returns the size to render/measure the glyph based on the given size and resolution in px units.
diff --git a/src/SixLabors.Fonts/TextLayout.cs b/src/SixLabors.Fonts/TextLayout.cs
index 76ed850e..54a2fb08 100644
--- a/src/SixLabors.Fonts/TextLayout.cs
+++ b/src/SixLabors.Fonts/TextLayout.cs
@@ -478,6 +478,7 @@ private static List LayoutLineVertical(
ref Vector2 boxLocation,
ref Vector2 penLocation)
{
+ float originX = penLocation.X;
float originY = penLocation.Y;
float offsetY = 0;
@@ -569,7 +570,22 @@ private static List LayoutLineVertical(
penLocation.Y += offsetY;
penLocation.X += offsetX;
- List glyphs = [];
+ List glyphs = new(textLine.Count);
+
+ // Grapheme-scoped state for transformed glyph alignment.
+ //
+ // IMPORTANT: TextLine.GlyphLayoutData is per-codepoint, not per-grapheme.
+ // Complex scripts can therefore produce multiple entries for a single grapheme.
+ // For example Devanagari "र्कि" can end up as two entries ("र्" and "कि") even though it
+ // visually shapes as a single cluster.
+ //
+ // - Compute a single alignX for the whole grapheme (across all entries with the same GraphemeIndex).
+ // - Apply that alignX as a positional offset only, never as part of pen/box advance.
+ // - Transformed entries still advance along X within the grapheme (horizontal glyphs inside a vertical flow),
+ // then X is reset at the end of the grapheme.
+ float currentGraphemeAlignX = 0;
+ bool currentGraphemeIsTransformed = false;
+
for (int i = 0; i < textLine.Count; i++)
{
TextLine.GlyphLayoutData data = textLine[i];
@@ -595,28 +611,128 @@ private static List LayoutLineVertical(
}
int j = 0;
+
+ bool isFirstInGrapheme = data.GraphemeCodePointIndex == 0;
+ float alignX = 0;
+ float entryScaledAdvanceWidth = 0;
+
+ if (isFirstInGrapheme)
+ {
+ // Reset grapheme-scoped state at the start of each grapheme.
+ currentGraphemeAlignX = 0;
+ currentGraphemeIsTransformed = false;
+
+ // Determine whether this grapheme contains any transformed entries.
+ // This is intentionally done at grapheme scope because individual entries can differ.
+ int graphemeIndex = data.GraphemeIndex;
+
+ for (int k = i; k < textLine.Count; k++)
+ {
+ TextLine.GlyphLayoutData g = textLine[k];
+
+ if (g.GraphemeIndex != graphemeIndex)
+ {
+ break;
+ }
+
+ if (g.IsTransformed)
+ {
+ currentGraphemeIsTransformed = true;
+ break;
+ }
+ }
+
+ if (currentGraphemeIsTransformed)
+ {
+ // In vertical layout, glyphs with a vertical orientation of TransformRotate/TransformUpright are
+ // rendered as "horizontal" glyphs inside a vertical flow.
+ //
+ // Their horizontal metrics (including LSB) are still expressed in the font's horizontal writing mode,
+ // so without an adjustment these glyphs appear shifted within the column.
+ //
+ // To make transformed glyphs align visually with naturally-vertical glyphs, we center the ink bounds
+ // of the ENTIRE grapheme (across all entries with the same GraphemeIndex) within the column width
+ // (`scaledMaxLineHeight`).
+ float minX = float.PositiveInfinity;
+ float maxX = float.NegativeInfinity;
+
+ for (int k = i; k < textLine.Count; k++)
+ {
+ TextLine.GlyphLayoutData g = textLine[k];
+
+ if (g.GraphemeIndex != graphemeIndex)
+ {
+ break;
+ }
+
+ foreach (GlyphMetrics m in g.Metrics)
+ {
+ Vector2 s = new Vector2(g.PointSize) / m.ScaleFactor;
+
+ float glyphMinX = m.Bounds.Min.X * s.X;
+ float glyphMaxX = m.Bounds.Max.X * s.X;
+
+ if (glyphMinX < minX)
+ {
+ minX = glyphMinX;
+ }
+
+ if (glyphMaxX > maxX)
+ {
+ maxX = glyphMaxX;
+ }
+ }
+ }
+
+ float inkWidth = maxX - minX;
+
+ // Normalize ink minX to 0 and center within the column width.
+ // This is grapheme-correct and avoids centering based only on the "first" entry,
+ // which is not representative for marks like reph in Devanagari.
+ currentGraphemeAlignX = -minX + ((scaledMaxLineHeight - inkWidth) * .5F);
+ }
+ }
+
+ if (currentGraphemeIsTransformed)
+ {
+ // Apply the grapheme-level horizontal centering offset to every entry in the grapheme.
+ // This is positional only and must never be folded into any advance.
+ alignX = currentGraphemeAlignX;
+
+ // Transformed glyphs are still positioned using horizontal metrics (`AdvanceWidth`) even though
+ // they participate in a vertical flow. `AdvanceWidth` gives us the horizontal pen advance we must
+ // apply between entries inside the transformed grapheme.
+ foreach (GlyphMetrics m in data.Metrics)
+ {
+ Vector2 s = new Vector2(data.PointSize) / m.ScaleFactor;
+ entryScaledAdvanceWidth += m.AdvanceWidth * s.X;
+ }
+ }
+
foreach (GlyphMetrics metric in data.Metrics)
{
// Align the glyph horizontally and vertically centering vertically around the baseline.
Vector2 scale = new Vector2(data.PointSize) / metric.ScaleFactor;
- float alignX = 0;
- if (data.IsTransformed)
+ // Offset our in both directions to account for horizontal ink centering and vertical baseline centering.
+ Vector2 offset = new(alignX, (metric.Bounds.Max.Y + metric.TopSideBearing) * scale.Y);
+
+ float advanceW = advanceX;
+
+ if (currentGraphemeIsTransformed && !isFirstInGrapheme)
{
- // Calculate the horizontal alignment offset:
- // - Normalize lsb to zero
- // - Center the glyph horizontally within the max line height.
- alignX -= metric.LeftSideBearing * scale.X;
- alignX += (scaledMaxLineHeight - (metric.Bounds.Size().X * scale.X)) * .5F;
+ // For transformed glyphs after the first in the grapheme we advance
+ // horizontally using the horizontal advance not the line height.
+ // This gives us the correct total advance across the grapheme.
+ advanceW = scale.X * metric.AdvanceWidth;
}
- Vector2 offset = new(alignX, (metric.Bounds.Max.Y + metric.TopSideBearing) * scale.Y);
glyphs.Add(new GlyphLayout(
new Glyph(metric, data.PointSize),
boxLocation,
penLocation + new Vector2((scaledMaxLineHeight - data.ScaledLineHeight) * .5F, 0),
offset,
- advanceX,
+ advanceW,
data.ScaledAdvance + yExtraAdvance,
GlyphLayoutMode.Vertical,
i == 0 && j == 0,
@@ -626,7 +742,19 @@ private static List LayoutLineVertical(
j++;
}
- penLocation.Y += data.ScaledAdvance + yExtraAdvance;
+ if (currentGraphemeIsTransformed)
+ {
+ // Advance horizontally between entries inside the transformed grapheme.
+ boxLocation.X += entryScaledAdvanceWidth;
+ penLocation.X += entryScaledAdvanceWidth;
+ }
+
+ if (data.IsLastInGrapheme)
+ {
+ penLocation.Y += data.ScaledAdvance + yExtraAdvance;
+ boxLocation.X = originX;
+ penLocation.X = originX;
+ }
}
boxLocation.Y = originY;
@@ -774,9 +902,6 @@ private static List LayoutLineVerticalMixed(
// The glyph will be rotated 90 degrees for vertical mixed layout.
// We still advance along Y, but the glyphs are laid out sideways in X.
- // Compute the scale that converts design units to pixels for this size.
- Vector2 scale = new Vector2(data.PointSize) / metric.ScaleFactor;
-
// Calculate the initial horizontal offset to center the glyph baseline:
// - Take half the difference between the max line height (scaledMaxLineHeight)
// and the current glyph's line height (data.ScaledLineHeight).
@@ -910,7 +1035,7 @@ private static bool DoFontRun(
charIndex += charsConsumed;
// Get the glyph id for the codepoint and add to the collection.
- font.FontMetrics.TryGetGlyphId(current, next, out ushort glyphId, out skipNextCodePoint);
+ _ = font.FontMetrics.TryGetGlyphId(current, next, out ushort glyphId, out skipNextCodePoint);
substitutions.AddGlyph(glyphId, current, (TextDirection)bidiRuns[bidiRunIndex].Direction, textRuns[textRunIndex], codePointIndex);
codePointIndex++;
@@ -1013,8 +1138,9 @@ private static TextBox BreakLines(
for (graphemeIndex = 0; graphemeEnumerator.MoveNext(); graphemeIndex++)
{
// Now enumerate through each codepoint in the grapheme.
+ ReadOnlySpan grapheme = graphemeEnumerator.Current;
int graphemeCodePointIndex = 0;
- SpanCodePointEnumerator codePointEnumerator = new(graphemeEnumerator.Current);
+ SpanCodePointEnumerator codePointEnumerator = new(grapheme);
while (codePointEnumerator.MoveNext())
{
if (!positionings.TryGetGlyphMetricsAtOffset(
@@ -1041,8 +1167,7 @@ private static TextBox BreakLines(
//
// Note: Not all glyphs in a font will have a codepoint associated with them. e.g. most compositions, ligatures, etc.
CodePoint codePoint = codePointEnumerator.Current;
- if (isSubstituted &&
- metrics.Count == 1)
+ if (isSubstituted && metrics.Count == 1)
{
codePoint = glyph.CodePoint;
}
@@ -1187,14 +1312,39 @@ VerticalOrientationType.Rotate or
}
}
+ int graphemeCodePointMax = CodePoint.GetCodePointCount(grapheme) - 1;
+
// For non-decomposed glyphs the length is always 1.
for (int i = 0; i < decomposedAdvances.Length; i++)
{
+ // Determine if this is the last codepoint in the grapheme.
+ bool isLastInGrapheme = graphemeCodePointIndex == graphemeCodePointMax && i == decomposedAdvances.Length - 1;
+
float decomposedAdvance = decomposedAdvances[i];
// 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.
+ // Tracking should only be added once per grapheme, so only on the last codepoint of the grapheme.
+ if (isLastInGrapheme && options.Tracking != 0 && i == decomposedAdvances.Length - 1)
+ {
+ // Tracking should not be applied to tab characters or non-rendered codepoints.
+ if (!CodePoint.IsTabulation(codePoint) && !UnicodeUtility.ShouldNotBeRendered(codePoint))
+ {
+ 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;
@@ -1244,6 +1394,7 @@ VerticalOrientationType.Rotate or
descender,
bidiRuns[bidiMap[codePointIndex]],
graphemeIndex,
+ isLastInGrapheme,
codePointIndex,
graphemeCodePointIndex,
shouldRotate || shouldOffset,
@@ -1460,6 +1611,7 @@ public void Add(
float scaledDescender,
BidiRun bidiRun,
int graphemeIndex,
+ bool isLastInGrapheme,
int codePointIndex,
int graphemeCodePointIndex,
bool isTransformed,
@@ -1471,6 +1623,7 @@ public void Add(
// We track the maximum metrics for each line to ensure glyphs can be aligned.
if (graphemeCodePointIndex == 0)
{
+ // TODO: Check this logic is correct.
this.ScaledLineAdvance += scaledAdvance;
}
@@ -1515,6 +1668,7 @@ public void Add(
scaledMinY,
bidiRun,
graphemeIndex,
+ isLastInGrapheme,
codePointIndex,
graphemeCodePointIndex,
isTransformed,
@@ -1928,6 +2082,7 @@ public GlyphLayoutData(
float scaledMinY,
BidiRun bidiRun,
int graphemeIndex,
+ bool isLastInGrapheme,
int codePointIndex,
int graphemeCodePointIndex,
bool isTransformed,
@@ -1943,6 +2098,7 @@ public GlyphLayoutData(
this.ScaledMinY = scaledMinY;
this.BidiRun = bidiRun;
this.GraphemeIndex = graphemeIndex;
+ this.IsLastInGrapheme = isLastInGrapheme;
this.CodePointIndex = codePointIndex;
this.GraphemeCodePointIndex = graphemeCodePointIndex;
this.IsTransformed = isTransformed;
@@ -1972,6 +2128,8 @@ public GlyphLayoutData(
public int GraphemeIndex { get; }
+ public bool IsLastInGrapheme { get; }
+
public int GraphemeCodePointIndex { get; }
public int CodePointIndex { get; }
diff --git a/src/SixLabors.Fonts/TextOptions.cs b/src/SixLabors.Fonts/TextOptions.cs
index ac802ee3..a6150b2e 100644
--- a/src/SixLabors.Fonts/TextOptions.cs
+++ b/src/SixLabors.Fonts/TextOptions.cs
@@ -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(options.FeatureTags);
this.TextRuns = new List(options.TextRuns);
@@ -171,6 +172,13 @@ public float LineSpacing
///
public KerningMode KerningMode { get; set; }
+ ///
+ /// 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.
+ ///
+ public float Tracking { get; set; }
+
///
/// Gets or sets the positioning mode used for rendering decorations.
///
diff --git a/src/SixLabors.Fonts/Unicode/CodePoint.cs b/src/SixLabors.Fonts/Unicode/CodePoint.cs
index e5057f77..b3e72472 100644
--- a/src/SixLabors.Fonts/Unicode/CodePoint.cs
+++ b/src/SixLabors.Fonts/Unicode/CodePoint.cs
@@ -105,8 +105,8 @@ private CodePoint(uint scalarValue, bool unused)
// - 0x40 bit if set means 'is letter or digit'
// - 0x20 bit is reserved for future use
// - bottom 5 bits are the UnicodeCategory of the character
- private static ReadOnlySpan AsciiCharInfo => new byte[]
- {
+ private static ReadOnlySpan AsciiCharInfo =>
+ [
0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x8E, 0x8E, 0x8E, 0x8E, 0x8E, 0x0E, 0x0E, // U+0000..U+000F
0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, // U+0010..U+001F
0x8B, 0x18, 0x18, 0x18, 0x1A, 0x18, 0x18, 0x18, 0x14, 0x15, 0x18, 0x19, 0x18, 0x13, 0x18, 0x18, // U+0020..U+002F
@@ -115,7 +115,7 @@ private CodePoint(uint scalarValue, bool unused)
0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x14, 0x18, 0x15, 0x1B, 0x12, // U+0050..U+005F
0x1B, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, // U+0060..U+006F
0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x14, 0x19, 0x15, 0x19, 0x0E, // U+0070..U+007F
- };
+ ];
///
/// Gets a value indicating whether this value is ASCII ([ U+0000..U+007F ])
@@ -224,7 +224,7 @@ public int Utf8SequenceLength
///
/// The codepoint to evaluate.
/// if is a whitespace character; otherwise,
- public static bool IsWhiteSpace(CodePoint codePoint)
+ public static bool IsWhiteSpace(in CodePoint codePoint)
{
if (codePoint.IsAscii)
{
@@ -241,7 +241,7 @@ public static bool IsWhiteSpace(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// if is a non-breaking space character; otherwise,
- public static bool IsNonBreakingSpace(CodePoint codePoint)
+ public static bool IsNonBreakingSpace(in CodePoint codePoint)
=> codePoint.Value == 0x00A0;
///
@@ -249,7 +249,7 @@ public static bool IsNonBreakingSpace(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// if is a zero-width-non-joiner character; otherwise,
- public static bool IsZeroWidthNonJoiner(CodePoint codePoint)
+ public static bool IsZeroWidthNonJoiner(in CodePoint codePoint)
=> codePoint.Value == 0x200C;
///
@@ -257,7 +257,7 @@ public static bool IsZeroWidthNonJoiner(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// if is a zero-width-joiner character; otherwise,
- public static bool IsZeroWidthJoiner(CodePoint codePoint)
+ public static bool IsZeroWidthJoiner(in CodePoint codePoint)
=> codePoint.Value == 0x200D;
///
@@ -266,7 +266,7 @@ public static bool IsZeroWidthJoiner(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// if is a variation selector character; otherwise,
- public static bool IsVariationSelector(CodePoint codePoint)
+ public static bool IsVariationSelector(in CodePoint codePoint)
=> (codePoint.Value & 0xFFF0) == 0xFE00;
///
@@ -274,7 +274,7 @@ public static bool IsVariationSelector(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// if is a control character; otherwise,
- public static bool IsControl(CodePoint codePoint) =>
+ public static bool IsControl(in CodePoint codePoint) =>
// Per the Unicode stability policy, the set of control characters
// is forever fixed at [ U+0000..U+001F ], [ U+007F..U+009F ]. No
@@ -291,7 +291,7 @@ public static bool IsControl(CodePoint codePoint) =>
///
/// The codepoint to evaluate.
/// if is a decimal digit; otherwise,
- public static bool IsDigit(CodePoint codePoint)
+ public static bool IsDigit(in CodePoint codePoint)
{
if (codePoint.IsAscii)
{
@@ -308,7 +308,7 @@ public static bool IsDigit(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// if is a letter; otherwise,
- public static bool IsLetter(CodePoint codePoint)
+ public static bool IsLetter(in CodePoint codePoint)
{
if (codePoint.IsAscii)
{
@@ -325,7 +325,7 @@ public static bool IsLetter(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// if is a letter or decimal digit; otherwise,
- public static bool IsLetterOrDigit(CodePoint codePoint)
+ public static bool IsLetterOrDigit(in CodePoint codePoint)
{
if (codePoint.IsAscii)
{
@@ -342,7 +342,7 @@ public static bool IsLetterOrDigit(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// if is a lowercase letter; otherwise,
- public static bool IsLower(CodePoint codePoint)
+ public static bool IsLower(in CodePoint codePoint)
{
if (codePoint.IsAscii)
{
@@ -359,7 +359,7 @@ public static bool IsLower(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// if is a number; otherwise,
- public static bool IsNumber(CodePoint codePoint)
+ public static bool IsNumber(in CodePoint codePoint)
{
if (codePoint.IsAscii)
{
@@ -376,7 +376,7 @@ public static bool IsNumber(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// if is punctuation; otherwise,
- public static bool IsPunctuation(CodePoint codePoint)
+ public static bool IsPunctuation(in CodePoint codePoint)
=> IsCategoryPunctuation(GetGeneralCategory(codePoint));
///
@@ -384,7 +384,7 @@ public static bool IsPunctuation(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// if is a separator; otherwise,
- public static bool IsSeparator(CodePoint codePoint)
+ public static bool IsSeparator(in CodePoint codePoint)
=> IsCategorySeparator(GetGeneralCategory(codePoint));
///
@@ -392,7 +392,7 @@ public static bool IsSeparator(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// if is a symbol; otherwise,
- public static bool IsSymbol(CodePoint codePoint)
+ public static bool IsSymbol(in CodePoint codePoint)
=> IsCategorySymbol(GetGeneralCategory(codePoint));
///
@@ -400,7 +400,7 @@ public static bool IsSymbol(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// if is a symbol; otherwise,
- public static bool IsMark(CodePoint codePoint)
+ public static bool IsMark(in CodePoint codePoint)
=> IsCategoryMark(GetGeneralCategory(codePoint));
///
@@ -408,7 +408,7 @@ public static bool IsMark(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// if is a uppercase letter; otherwise,
- public static bool IsUpper(CodePoint codePoint)
+ public static bool IsUpper(in CodePoint codePoint)
{
if (codePoint.IsAscii)
{
@@ -425,7 +425,7 @@ public static bool IsUpper(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// if is a tabulation indicator; otherwise,
- public static bool IsTabulation(CodePoint codePoint)
+ public static bool IsTabulation(in CodePoint codePoint)
=> codePoint.value == 0x0009;
///
@@ -433,7 +433,7 @@ public static bool IsTabulation(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// if is a new line indicator; otherwise,
- public static bool IsNewLine(CodePoint codePoint)
+ public static bool IsNewLine(in CodePoint codePoint)
=> codePoint.Value switch
{
// See https://www.unicode.org/standard/reports/tr13/tr13-5.html
@@ -460,7 +460,7 @@ public static int GetCodePointCount(ReadOnlySpan source)
}
int count = 0;
- var enumerator = new SpanCodePointEnumerator(source);
+ SpanCodePointEnumerator enumerator = new(source);
while (enumerator.MoveNext())
{
count++;
@@ -476,7 +476,7 @@ public static int GetCodePointCount(ReadOnlySpan source)
/// The code point to be mapped.
/// The mapped canonical code point, or the passed .
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- internal static CodePoint GetCanonicalType(CodePoint codePoint)
+ internal static CodePoint GetCanonicalType(in CodePoint codePoint)
{
if (codePoint.Value == 0x3008)
{
@@ -496,7 +496,7 @@ internal static CodePoint GetCanonicalType(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// The .
- public static BidiClass GetBidiClass(CodePoint codePoint)
+ public static BidiClass GetBidiClass(in CodePoint codePoint)
=> new(codePoint);
///
@@ -511,7 +511,7 @@ public static BidiClass GetBidiClass(CodePoint codePoint)
/// .
/// if this instance has a mirror; otherwise,
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static bool TryGetBidiMirror(CodePoint codePoint, out CodePoint mirror)
+ public static bool TryGetBidiMirror(in CodePoint codePoint, out CodePoint mirror)
{
uint value = UnicodeData.GetBidiMirror(codePoint.value);
@@ -536,7 +536,7 @@ public static bool TryGetBidiMirror(CodePoint codePoint, out CodePoint mirror)
/// This parameter is passed uninitialized.
/// .
/// if this instance has a mirror; otherwise,
- public static bool TryGetVerticalMirror(CodePoint codePoint, out CodePoint mirror)
+ public static bool TryGetVerticalMirror(in CodePoint codePoint, out CodePoint mirror)
{
uint value = UnicodeUtility.GetVerticalMirror((uint)codePoint.Value);
@@ -555,7 +555,7 @@ public static bool TryGetVerticalMirror(CodePoint codePoint, out CodePoint mirro
///
/// The codepoint to evaluate.
/// The .
- public static LineBreakClass GetLineBreakClass(CodePoint codePoint)
+ public static LineBreakClass GetLineBreakClass(in CodePoint codePoint)
=> UnicodeData.GetLineBreakClass(codePoint.value);
///
@@ -563,7 +563,7 @@ public static LineBreakClass GetLineBreakClass(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// The .
- public static GraphemeClusterClass GetGraphemeClusterClass(CodePoint codePoint)
+ public static GraphemeClusterClass GetGraphemeClusterClass(in CodePoint codePoint)
=> UnicodeData.GetGraphemeClusterClass(codePoint.value);
///
@@ -571,7 +571,7 @@ public static GraphemeClusterClass GetGraphemeClusterClass(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// The .
- public static VerticalOrientationType GetVerticalOrientationType(CodePoint codePoint)
+ public static VerticalOrientationType GetVerticalOrientationType(in CodePoint codePoint)
=> UnicodeData.GetVerticalOrientation(codePoint.value);
///
@@ -579,7 +579,7 @@ public static VerticalOrientationType GetVerticalOrientationType(CodePoint codeP
///
/// The codepoint to evaluate.
/// The .
- internal static ArabicJoiningClass GetArabicJoiningClass(CodePoint codePoint)
+ internal static ArabicJoiningClass GetArabicJoiningClass(in CodePoint codePoint)
=> new(codePoint);
///
@@ -587,7 +587,7 @@ internal static ArabicJoiningClass GetArabicJoiningClass(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// The .
- internal static ScriptClass GetScriptClass(CodePoint codePoint)
+ internal static ScriptClass GetScriptClass(in CodePoint codePoint)
=> UnicodeData.GetScriptClass(codePoint.value);
///
@@ -595,7 +595,7 @@ internal static ScriptClass GetScriptClass(CodePoint codePoint)
///
/// The codepoint to evaluate.
/// The .
- internal static IndicSyllabicCategory GetIndicSyllabicCategory(CodePoint codePoint)
+ internal static IndicSyllabicCategory GetIndicSyllabicCategory(in CodePoint codePoint)
=> UnicodeData.GetIndicSyllabicCategory(codePoint.value);
///
@@ -603,7 +603,7 @@ internal static IndicSyllabicCategory GetIndicSyllabicCategory(CodePoint codePoi
///
/// The codepoint to evaluate.
/// The .
- internal static IndicPositionalCategory GetIndicPositionalCategory(CodePoint codePoint)
+ internal static IndicPositionalCategory GetIndicPositionalCategory(in CodePoint codePoint)
=> UnicodeData.GetIndicPositionalCategory(codePoint.value);
///
@@ -611,7 +611,7 @@ internal static IndicPositionalCategory GetIndicPositionalCategory(CodePoint cod
///
/// The codepoint to evaluate.
/// The .
- public static UnicodeCategory GetGeneralCategory(CodePoint codePoint)
+ public static UnicodeCategory GetGeneralCategory(in CodePoint codePoint)
{
if (codePoint.IsAscii)
{
diff --git a/src/SixLabors.Fonts/Unicode/SpanGraphemeEnumerator.cs b/src/SixLabors.Fonts/Unicode/SpanGraphemeEnumerator.cs
index 4256e144..b951ee67 100644
--- a/src/SixLabors.Fonts/Unicode/SpanGraphemeEnumerator.cs
+++ b/src/SixLabors.Fonts/Unicode/SpanGraphemeEnumerator.cs
@@ -45,20 +45,87 @@ public SpanGraphemeEnumerator(ReadOnlySpan source)
///
public bool MoveNext()
{
+ // GB9c (Indic conjuncts) requires some script-specific state.
+ // This implementation uses existing IndicSyllabicCategory data as a pragmatic approximation
+ // for the InCB tailoring required by UAX#29. It is sufficient to prevent the common
+ // "dead consonant" split for sequences like: RA VIRAMA KA ... (eg: "\u0930\u094D\u0915\u093F").
+ bool indicLinkerJustConsumed;
+
+ static bool IsIndicLinker(in CodePoint cp)
+ {
+ // In practice, this is primarily VIRAMA (and in some scripts, "pure killer").
+ // We intentionally keep this tight to avoid accidental over-aggregation.
+ IndicSyllabicCategory isc = CodePoint.GetIndicSyllabicCategory(cp);
+ return isc is IndicSyllabicCategory.Virama or IndicSyllabicCategory.PureKiller;
+ }
+
+ static bool IsIndicConsonant(in CodePoint cp)
+ {
+ IndicSyllabicCategory isc = CodePoint.GetIndicSyllabicCategory(cp);
+ return isc is IndicSyllabicCategory.Consonant
+ or IndicSyllabicCategory.ConsonantDead
+ or IndicSyllabicCategory.ConsonantWithStacker
+ or IndicSyllabicCategory.ConsonantSubjoined
+ or IndicSyllabicCategory.ConsonantFinal
+ or IndicSyllabicCategory.ConsonantMedial
+ or IndicSyllabicCategory.ConsonantHeadLetter
+ or IndicSyllabicCategory.ConsonantPlaceholder
+ or IndicSyllabicCategory.ConsonantInitialPostfixed
+ or IndicSyllabicCategory.ConsonantKiller
+ or IndicSyllabicCategory.ConsonantPrefixed
+ or IndicSyllabicCategory.ConsonantPrecedingRepha
+ or IndicSyllabicCategory.ConsonantSucceedingRepha;
+ }
+
+ // Accept the current scalar into the cluster and advance to the next scalar.
+ // IMPORTANT: Processor.Current* represents the next scalar not yet included in CharsConsumed.
+ void ConsumeCurrentAndAdvance(ref Processor p)
+ {
+ // Update Indic state based on the scalar being consumed into the cluster.
+ indicLinkerJustConsumed = IsIndicLinker(p.CurrentCodePoint);
+ p.MoveNext();
+ }
+
+ // Drain trailers per GB9/GB9a, plus GB9c-style Indic conjunct tailoring:
+ // If we just consumed a Linker (eg Virama) and the next scalar is an Indic consonant,
+ // do not break, instead consume that consonant into the same grapheme cluster.
+ void DrainTrailersAndIndicConjuncts(ref Processor p)
+ {
+ while (true)
+ {
+ // rules GB9, GB9a
+ while (p.CurrentType is GraphemeClusterClass.Extend
+ or GraphemeClusterClass.ZeroWidthJoiner
+ or GraphemeClusterClass.SpacingMark)
+ {
+ ConsumeCurrentAndAdvance(ref p);
+ }
+
+ // rule GB9c (tailoring): ... Linker x Consonant
+ if (indicLinkerJustConsumed && IsIndicConsonant(p.CurrentCodePoint))
+ {
+ ConsumeCurrentAndAdvance(ref p);
+ continue;
+ }
+
+ break;
+ }
+ }
+
if (this.source.IsEmpty)
{
return false;
}
// Algorithm given at https://www.unicode.org/reports/tr29/#Grapheme_Cluster_Boundary_Rules.
- var processor = new Processor(this.source);
+ Processor processor = new(this.source);
processor.MoveNext();
// First, consume as many Prepend scalars as we can (rule GB9b).
while (processor.CurrentType == GraphemeClusterClass.Prepend)
{
- processor.MoveNext();
+ ConsumeCurrentAndAdvance(ref processor);
}
// Next, make sure we're not about to violate control character restrictions.
@@ -75,7 +142,7 @@ or GraphemeClusterClass.CarriageReturn
// Now begin the main state machine.
GraphemeClusterClass previousClusterBreakType = processor.CurrentType;
- processor.MoveNext();
+ ConsumeCurrentAndAdvance(ref processor);
switch (previousClusterBreakType)
{
@@ -85,7 +152,7 @@ or GraphemeClusterClass.CarriageReturn
goto Return; // rules GB3 & GB4 (only can follow )
}
- processor.MoveNext();
+ ConsumeCurrentAndAdvance(ref processor);
goto case GraphemeClusterClass.LineFeed;
case GraphemeClusterClass.Control:
@@ -95,22 +162,22 @@ or GraphemeClusterClass.CarriageReturn
case GraphemeClusterClass.HangulLead:
if (processor.CurrentType == GraphemeClusterClass.HangulLead)
{
- processor.MoveNext(); // rule GB6 (L x L)
+ ConsumeCurrentAndAdvance(ref processor); // rule GB6 (L x L)
goto case GraphemeClusterClass.HangulLead;
}
else if (processor.CurrentType == GraphemeClusterClass.HangulVowel)
{
- processor.MoveNext(); // rule GB6 (L x V)
+ ConsumeCurrentAndAdvance(ref processor); // rule GB6 (L x V)
goto case GraphemeClusterClass.HangulVowel;
}
else if (processor.CurrentType == GraphemeClusterClass.HangulLeadVowel)
{
- processor.MoveNext(); // rule GB6 (L x LV)
+ ConsumeCurrentAndAdvance(ref processor); // rule GB6 (L x LV)
goto case GraphemeClusterClass.HangulLeadVowel;
}
else if (processor.CurrentType == GraphemeClusterClass.HangulLeadVowelTail)
{
- processor.MoveNext(); // rule GB6 (L x LVT)
+ ConsumeCurrentAndAdvance(ref processor); // rule GB6 (L x LVT)
goto case GraphemeClusterClass.HangulLeadVowelTail;
}
else
@@ -122,12 +189,12 @@ or GraphemeClusterClass.CarriageReturn
case GraphemeClusterClass.HangulVowel:
if (processor.CurrentType == GraphemeClusterClass.HangulVowel)
{
- processor.MoveNext(); // rule GB7 (LV | V x V)
+ ConsumeCurrentAndAdvance(ref processor); // rule GB7 (LV | V x V)
goto case GraphemeClusterClass.HangulVowel;
}
else if (processor.CurrentType == GraphemeClusterClass.HangulTail)
{
- processor.MoveNext(); // rule GB7 (LV | V x T)
+ ConsumeCurrentAndAdvance(ref processor); // rule GB7 (LV | V x T)
goto case GraphemeClusterClass.HangulTail;
}
else
@@ -139,7 +206,7 @@ or GraphemeClusterClass.CarriageReturn
case GraphemeClusterClass.HangulTail:
if (processor.CurrentType == GraphemeClusterClass.HangulTail)
{
- processor.MoveNext(); // rule GB8 (LVT | T x T)
+ ConsumeCurrentAndAdvance(ref processor); // rule GB8 (LVT | T x T)
goto case GraphemeClusterClass.HangulTail;
}
else
@@ -152,7 +219,7 @@ or GraphemeClusterClass.CarriageReturn
// First, drain any Extend scalars that might exist
while (processor.CurrentType == GraphemeClusterClass.Extend)
{
- processor.MoveNext();
+ ConsumeCurrentAndAdvance(ref processor);
}
// Now see if there's a ZWJ + extended pictograph again.
@@ -161,20 +228,20 @@ or GraphemeClusterClass.CarriageReturn
break;
}
- processor.MoveNext();
+ ConsumeCurrentAndAdvance(ref processor);
if (processor.CurrentType != GraphemeClusterClass.ExtendedPictographic)
{
break;
}
- processor.MoveNext();
+ ConsumeCurrentAndAdvance(ref processor);
goto case GraphemeClusterClass.ExtendedPictographic;
case GraphemeClusterClass.RegionalIndicator:
// We've consumed a single RI scalar. Try to consume another (to make it a pair).
if (processor.CurrentType == GraphemeClusterClass.RegionalIndicator)
{
- processor.MoveNext();
+ ConsumeCurrentAndAdvance(ref processor);
}
// Standalone RI scalars (or a single pair of RI scalars) can only be followed by trailers.
@@ -184,18 +251,12 @@ or GraphemeClusterClass.CarriageReturn
break;
}
- // rules GB9, GB9a
- while (processor.CurrentType is GraphemeClusterClass.Extend
- or GraphemeClusterClass.ZeroWidthJoiner
- or GraphemeClusterClass.SpacingMark)
- {
- processor.MoveNext();
- }
+ DrainTrailersAndIndicConjuncts(ref processor);
Return:
- this.Current = this.source.Slice(0, processor.CharsConsumed);
- this.source = this.source.Slice(processor.CharsConsumed);
+ this.Current = this.source[..processor.CharsConsumed];
+ this.source = this.source[processor.CharsConsumed..];
return true; // rules GB2, GB999
}
@@ -209,18 +270,22 @@ public Processor(ReadOnlySpan source)
{
this.source = source;
this.CurrentType = GraphemeClusterClass.Any;
+ this.CurrentCodePoint = CodePoint.ReplacementChar;
this.charsConsumed = 0;
this.CharsConsumed = 0;
}
public GraphemeClusterClass CurrentType { get; private set; }
+ public CodePoint CurrentCodePoint { get; private set; }
+
public int CharsConsumed { get; private set; }
public void MoveNext()
{
this.CharsConsumed += this.charsConsumed;
- var codePoint = CodePoint.DecodeFromUtf16At(this.source, this.CharsConsumed, out this.charsConsumed);
+ CodePoint codePoint = CodePoint.DecodeFromUtf16At(this.source, this.CharsConsumed, out this.charsConsumed);
+ this.CurrentCodePoint = codePoint;
this.CurrentType = CodePoint.GetGraphemeClusterClass(codePoint);
}
}
diff --git a/src/SixLabors.Fonts/Unicode/UnicodeUtility.cs b/src/SixLabors.Fonts/Unicode/UnicodeUtility.cs
index fa177e30..981c9c61 100644
--- a/src/SixLabors.Fonts/Unicode/UnicodeUtility.cs
+++ b/src/SixLabors.Fonts/Unicode/UnicodeUtility.cs
@@ -515,7 +515,7 @@ public static bool IsDefaultIgnorableCodePoint(uint value)
/// The code point.
/// The .
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static bool ShouldRenderWhiteSpaceOnly(CodePoint codePoint)
+ public static bool ShouldRenderWhiteSpaceOnly(in CodePoint codePoint)
{
if (CodePoint.IsWhiteSpace(codePoint))
{
@@ -541,6 +541,15 @@ public static bool ShouldRenderWhiteSpaceOnly(CodePoint codePoint)
return false;
}
+ ///
+ /// Gets a value indicating whether the specified code point should not be rendered.
+ ///
+ /// The code point.
+ /// The .
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool ShouldNotBeRendered(in CodePoint codePoint)
+ => CodePoint.IsNewLine(codePoint) || (IsDefaultIgnorableCodePoint((uint)codePoint.Value) && !ShouldRenderWhiteSpaceOnly(codePoint));
+
///
/// Returns the Unicode plane (0 through 16, inclusive) which contains this code point.
///
diff --git "a/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterHRef_-\340\244\260\340\245\215\340\244\225\340\244\277-.png" "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterHRef_-\340\244\260\340\245\215\340\244\225\340\244\277-.png"
new file mode 100644
index 00000000..5443a496
--- /dev/null
+++ "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterHRef_-\340\244\260\340\245\215\340\244\225\340\244\277-.png"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cb2f0b0185f6a47553fa02b4c0aef75f1f605c2393cb848968c9e3ae6f5f13f5
+size 1128
diff --git "a/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterHRef_-\340\244\260\340\245\215\340\244\225\340\244\277\340\244\260\340\245\215\340\244\225\340\244\277-.png" "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterHRef_-\340\244\260\340\245\215\340\244\225\340\244\277\340\244\260\340\245\215\340\244\225\340\244\277-.png"
new file mode 100644
index 00000000..0d7e7c88
--- /dev/null
+++ "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterHRef_-\340\244\260\340\245\215\340\244\225\340\244\277\340\244\260\340\245\215\340\244\225\340\244\277-.png"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:66d6ac3e4aa10dca2dda39575e8e0697d85a84fe19b7363108c368e6626f6f17
+size 2000
diff --git "a/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterHRef_-\340\244\277-.png" "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterHRef_-\340\244\277-.png"
new file mode 100644
index 00000000..76d7f116
--- /dev/null
+++ "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterHRef_-\340\244\277-.png"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:83c398cf8179e9705831f3763bdc50532dfc486f16ed4a451c17aeaec84f0855
+size 865
diff --git "a/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterHRef_-\340\244\277a-.png" "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterHRef_-\340\244\277a-.png"
new file mode 100644
index 00000000..130ccbce
--- /dev/null
+++ "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterHRef_-\340\244\277a-.png"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:631c7bf0e113f7e2120fc3581cf151d2bd07837a77eb5d59593d93103a9ef49b
+size 1001
diff --git "a/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVMRef_-\340\244\260\340\245\215\340\244\225\340\244\277-.png" "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVMRef_-\340\244\260\340\245\215\340\244\225\340\244\277-.png"
new file mode 100644
index 00000000..7a669d3b
--- /dev/null
+++ "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVMRef_-\340\244\260\340\245\215\340\244\225\340\244\277-.png"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0f7469098a6b3fba33bf50d4d29bfda31cb9d33f3402e51c47c4260b9938a427
+size 1132
diff --git "a/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVMRef_-\340\244\260\340\245\215\340\244\225\340\244\277\340\244\260\340\245\215\340\244\225\340\244\277-.png" "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVMRef_-\340\244\260\340\245\215\340\244\225\340\244\277\340\244\260\340\245\215\340\244\225\340\244\277-.png"
new file mode 100644
index 00000000..1268e322
--- /dev/null
+++ "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVMRef_-\340\244\260\340\245\215\340\244\225\340\244\277\340\244\260\340\245\215\340\244\225\340\244\277-.png"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c73b0044f351efdc30ee965abbc0719ad3a933859d8da6f8bf97ada5f42a6d3c
+size 2002
diff --git "a/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVMRef_-\340\244\277-.png" "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVMRef_-\340\244\277-.png"
new file mode 100644
index 00000000..a809c1cc
--- /dev/null
+++ "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVMRef_-\340\244\277-.png"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7b0935b11b6220f162537fc8920e003b2df114081b445be8c9e31f2adb403ed8
+size 838
diff --git "a/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVMRef_-\340\244\277a-.png" "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVMRef_-\340\244\277a-.png"
new file mode 100644
index 00000000..1efab2a7
--- /dev/null
+++ "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVMRef_-\340\244\277a-.png"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:50d72d00d7327d463df4a77b073328adca37653906aa8a313c0e8c962ddc0e54
+size 975
diff --git "a/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVRef_-\340\244\260\340\245\215\340\244\225\340\244\277-.png" "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVRef_-\340\244\260\340\245\215\340\244\225\340\244\277-.png"
new file mode 100644
index 00000000..802487a8
--- /dev/null
+++ "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVRef_-\340\244\260\340\245\215\340\244\225\340\244\277-.png"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bc0354e90fd121d079541e9c883213e3a5c2f3563d8616093cb744c996055d5b
+size 1173
diff --git "a/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVRef_-\340\244\260\340\245\215\340\244\225\340\244\277\340\244\260\340\245\215\340\244\225\340\244\277-.png" "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVRef_-\340\244\260\340\245\215\340\244\225\340\244\277\340\244\260\340\245\215\340\244\225\340\244\277-.png"
new file mode 100644
index 00000000..b29cbbbc
--- /dev/null
+++ "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVRef_-\340\244\260\340\245\215\340\244\225\340\244\277\340\244\260\340\245\215\340\244\225\340\244\277-.png"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3554e592c585e5c1055d5af650380c161b6fba51b833e4336f649fb7a6e6eca7
+size 2101
diff --git "a/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVRef_-\340\244\277-.png" "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVRef_-\340\244\277-.png"
new file mode 100644
index 00000000..6bbb238a
--- /dev/null
+++ "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVRef_-\340\244\277-.png"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:388c8f9144918a554df59eb46b7b847db0f4121da502000289a39d787cbf578a
+size 869
diff --git "a/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVRef_-\340\244\277a-.png" "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVRef_-\340\244\277a-.png"
new file mode 100644
index 00000000..b1b60b0a
--- /dev/null
+++ "b/tests/Images/ReferenceOutput/FontTracking_CorrectlyAddSpacingForComposedCharacterVRef_-\340\244\277a-.png"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b11a2c4b1a0bc30d897a8fa029d928bd58e7faa55af4a744134d7aa4ba2da022
+size 1029
diff --git a/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs b/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs
index 4b527654..ded5df55 100644
--- a/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs
+++ b/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs
@@ -116,6 +116,7 @@ private static RichTextOptions FromTextOptions(TextOptions options, bool customD
LayoutMode = options.LayoutMode,
KerningMode = options.KerningMode,
DecorationPositioningMode = options.DecorationPositioningMode,
+ Tracking = options.Tracking,
ColorFontSupport = options.ColorFontSupport,
FeatureTags = new List(options.FeatureTags),
};
diff --git a/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs b/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs
index 0fb632cc..d17b83f6 100644
--- a/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs
+++ b/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs
@@ -967,6 +967,160 @@ public void TrueTypeHinting_CanHintSmallOpenSans(char c, FontRectangle expected)
Assert.Equal(expected, actual, Comparer);
}
+ public static TheoryData 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 bounds));
+ Assert.Equal(characterPosition, bounds.ToArray().Select(x => x.Bounds.X), Comparer);
+ }
+
+ public static TheoryData 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 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, 97.65625)]
+ [InlineData("\u0930\u094D\u0915\u093F\u0930\u094D\u0915\u093F", 1, 227)]
+ [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);
+ }
+
+ [Theory]
+ [InlineData("\u093f", 1)]
+ [InlineData("\u0930\u094D\u0915\u093F", 1)]
+ [InlineData("\u0930\u094D\u0915\u093F\u0930\u094D\u0915\u093F", 1)]
+ [InlineData("\u093fa", 1)]
+ public void FontTracking_CorrectlyAddSpacingForComposedCharacterHRef(string text, float tracking)
+ {
+ FontCollection fontCollection = new();
+ string name = fontCollection.Add(TestFonts.NotoSansDevanagariRegular).Name;
+
+ FontFamily mainFontFamily = fontCollection.Get(name);
+ Font mainFont = mainFontFamily.CreateFont(30, FontStyle.Regular);
+
+ TextOptions options = new(mainFont)
+ {
+ Tracking = tracking,
+ };
+
+ TextLayoutTestUtilities.TestLayout(text, options, properties: text);
+ }
+
+ [Theory]
+ [InlineData("\u093f", 1)]
+ [InlineData("\u0930\u094D\u0915\u093F", 1)]
+ [InlineData("\u0930\u094D\u0915\u093F\u0930\u094D\u0915\u093F", 1)]
+ [InlineData("\u093fa", 1)]
+ public void FontTracking_CorrectlyAddSpacingForComposedCharacterVRef(string text, float tracking)
+ {
+ FontCollection fontCollection = new();
+ string name = fontCollection.Add(TestFonts.NotoSansDevanagariRegular).Name;
+
+ FontFamily mainFontFamily = fontCollection.Get(name);
+ Font mainFont = mainFontFamily.CreateFont(30, FontStyle.Regular);
+
+ TextOptions options = new(mainFont)
+ {
+ Tracking = tracking,
+ LayoutMode = LayoutMode.VerticalLeftRight,
+ };
+
+ TextLayoutTestUtilities.TestLayout(text, options, properties: text);
+ }
+
+ [Theory]
+ [InlineData("\u093f", 1)]
+ [InlineData("\u0930\u094D\u0915\u093F", 1)]
+ [InlineData("\u0930\u094D\u0915\u093F\u0930\u094D\u0915\u093F", 1)]
+ [InlineData("\u093fa", 1)]
+ public void FontTracking_CorrectlyAddSpacingForComposedCharacterVMRef(string text, float tracking)
+ {
+ FontCollection fontCollection = new();
+ string name = fontCollection.Add(TestFonts.NotoSansDevanagariRegular).Name;
+
+ FontFamily mainFontFamily = fontCollection.Get(name);
+ Font mainFont = mainFontFamily.CreateFont(30, FontStyle.Regular);
+
+ TextOptions options = new(mainFont)
+ {
+ Tracking = tracking,
+ LayoutMode = LayoutMode.VerticalMixedLeftRight,
+ };
+
+ TextLayoutTestUtilities.TestLayout(text, options, properties: text);
+ }
+
[Fact]
public void CanMeasureTextAdvance()
{
diff --git a/tests/SixLabors.Fonts.Tests/TextOptionsTests.cs b/tests/SixLabors.Fonts.Tests/TextOptionsTests.cs
index afbbf066..ed72e046 100644
--- a/tests/SixLabors.Fonts.Tests/TextOptionsTests.cs
+++ b/tests/SixLabors.Fonts.Tests/TextOptionsTests.cs
@@ -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);
@@ -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);
@@ -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);
@@ -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 };
@@ -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
};
@@ -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
@@ -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,
@@ -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)
{
@@ -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 text = "Z".AsSpan();
- var renderer = new GlyphRenderer();
+ GlyphRenderer renderer = new();
TextRenderer.RenderTextTo(renderer, text, options);
GlyphRendererParameters glyph = Assert.Single(renderer.GlyphKeys);
@@ -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 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);
@@ -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()
@@ -312,7 +312,8 @@ public void NonDefaultClone()
VerticalAlignment = VerticalAlignment.Bottom,
DecorationPositioningMode = DecorationPositioningMode.GlyphFont,
WrappingLength = 42F,
- FeatureTags = new List { FeatureTags.OldstyleFigures },
+ Tracking = 66F,
+ FeatureTags = new List { FeatureTags.OldstyleFigures }
};
TextOptions actual = new(expected);
@@ -326,12 +327,13 @@ public void NonDefaultClone()
Assert.Equal(expected.WrappingLength, actual.WrappingLength);
Assert.Equal(expected.DecorationPositioningMode, actual.DecorationPositioningMode);
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,
@@ -342,7 +344,8 @@ public void CloneIsDeep()
VerticalAlignment = VerticalAlignment.Bottom,
TextJustification = TextJustification.InterCharacter,
DecorationPositioningMode = DecorationPositioningMode.GlyphFont,
- WrappingLength = 42F
+ WrappingLength = 42F,
+ Tracking = 66F,
};
Assert.NotEqual(expected.KerningMode, actual.KerningMode);
@@ -354,6 +357,7 @@ public void CloneIsDeep()
Assert.NotEqual(expected.WrappingLength, actual.WrappingLength);
Assert.NotEqual(expected.DecorationPositioningMode, actual.DecorationPositioningMode);
Assert.NotEqual(expected.TextJustification, actual.TextJustification);
+ Assert.NotEqual(expected.Tracking, actual.Tracking);
}
private static void VerifyPropertyDefault(TextOptions options)
@@ -369,5 +373,6 @@ private static void VerifyPropertyDefault(TextOptions options)
Assert.Equal(LayoutMode.HorizontalTopBottom, options.LayoutMode);
Assert.Equal(DecorationPositioningMode.PrimaryFont, options.DecorationPositioningMode);
Assert.Equal(1, options.LineSpacing);
+ Assert.Equal(0, options.Tracking);
}
}