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); } }