Recursive AI

Shape text in C# (ligatures, kerning, Arabic, Hindi)

All onefont recipes

Load the font and call TextShaping.ShapeRun(text, face, rightToLeft). It returns the shaped clusters: the font’s glyphs after its ligatures, joining forms and conjuncts, with kerned advances and mark offsets, each tied to the characters it came from. onefont is written in C#, with no native library to ship.

Terminal
dotnet new console -o Shaping && cd Shaping
dotnet add package RecursiveAI.OneFont --version 1.3.0
NOTO=https://github.com/notofonts/notofonts.github.io/raw/main/fonts
curl -LO $NOTO/NotoSerif/hinted/ttf/NotoSerif-Regular.ttf
curl -LO $NOTO/NotoSansArabic/hinted/ttf/NotoSansArabic-Regular.ttf
curl -LO $NOTO/NotoSansDevanagari/hinted/ttf/NotoSansDevanagari-Regular.ttf

Put this in Program.cs and run dotnet run:

C#
using OneFont;
 
Shape("office AV", "NotoSerif-Regular.ttf", rightToLeft: false);
// "hello world" in Arabic, then "namaste" in Hindi
Shape("مرحبا بالعالم", "NotoSansArabic-Regular.ttf", rightToLeft: true);
Shape("नमस्ते", "NotoSansDevanagari-Regular.ttf", rightToLeft: false);
 
static void Shape(string text, string fontPath, bool rightToLeft)
{
    FontFace face = FontFace.Load(File.ReadAllBytes(fontPath));
    var clusters = TextShaping.ShapeRun(text, face, rightToLeft);
 
    // Clusters come in logical (typing) order; Start indexes the string.
    // Advances and offsets are in font units: multiply by size / UnitsPerEm.
    var glyphs = clusters.SelectMany(c =>
        c.Glyphs.ToArray().Select(g => $"{g.GlyphId}@{c.Start}+{g.XAdvance}"));
    float px = clusters.Sum(c => c.Advance) * 16f / face.UnitsPerEm;
    Console.WriteLine($"{text} ({px:F1} px at 16 px):");
    Console.WriteLine($"  {string.Join(" ", glyphs)}");
}
Text
office AV (66.7 px at 16 px):
  82@0+577 2146@1+1002 70@4+492 72@5+535 3@6+260 36@7+625 57@8+675
مرحبا بالعالم (77.1 px at 16 px):
  79@0+525 31@1+356 27@2+589 16@3+343 316@3+0 9@4+291 3@5+260 19@6+269 316@6+0 9@7+291 72@8+260 48@9+521 9@10+291 72@11+260 77@12+562
नमस्ते (33.8 px at 16 px):
  75@0+555 80@1+598 256@2+389 71@2+570 40@2+0

Each item is glyph@cluster+advance, in font units.

What happened

  • Ligature and kerning. In “office AV”, glyph 2146 is the “ffi” ligature: one glyph and one cluster for characters 1 to 3. The A’s advance is 625 rather than its normal 705 because the font kerns A and V.
  • Arabic. The letters take their joined initial, medial and final forms, and this font adds a mark glyph (316) with no advance to each ب, in the same cluster as its letter. The clusters come back in logical order, the order of the string: to display a right-to-left run, draw its clusters from last to first.
  • Hindi. In “नमस्ते” the last four characters are one cluster: स becomes a half form (glyph 256) joined to ते. A cluster is the smallest unit you can place, select or break.
  • The script shaper is picked from the text: Arabic (with Syriac and N’Ko), the Indic scripts, the Universal Shaping Engine scripts such as Khmer, Myanmar and Tibetan, and Thai and Lao. You pass only the direction.

What a shaper leaves to you

A shaper handles one run: one font, one direction, one script. Splitting a paragraph by direction, choosing a font per character, breaking lines and drawing happen around it. onefont has the pieces next to the shaper: right-to-left and mixed text, font fallback, measuring and wrapping. Features such as small caps and fractions are in OpenType features.

Limits

  • It shapes; it doesn’t draw. Turn glyphs into outlines with text to SVG path, or draw on images with OneImage.Text.
  • Not read in 1.3.0: WOFF and WOFF2 files, color glyphs, hinting, vertical text, and Apple morx and kerx tables. A variable font shapes as its default instance.

The same samples with HarfBuzzSharp, side by side, and how to choose between the two, are in Text shaping in C#: HarfBuzzSharp or a pure .NET shaper.