Text shaping turns a string into positioned glyphs: it picks each glyph from the font, applies the font’s OpenType substitutions (ligatures, Arabic joining forms, Indic conjuncts and reordering) and its positioning (kerning, mark placement), and returns glyph ids with advances and offsets. In C# there are two ways to do it. HarfBuzzSharp binds the native HarfBuzz library. OneFont is a shaper written in C#, with no native code: TextShaping.ShapeRun(text, face) returns the shaped clusters. On the Latin, Arabic and Devanagari samples below, both produce the same glyphs and advances from the same Noto fonts. This page shows both side by side, what a shaper leaves to you, and how to choose. Every sample was compiled and run on Linux against OneFont 1.3.0 and HarfBuzzSharp 14.2.1.301.
What a shaper does
- Character map: each character becomes a glyph id through the font’s
cmaptable. - Substitution (
GSUB): “f” + “f” + “i” becomes one “ffi” glyph; an Arabic letter takes its initial, medial or final form from its neighbors; in Hindi, a consonant with a virama becomes a half form, and some vowel signs are drawn before the consonant they follow in the text. - Positioning (
GPOS): kerning moves “A” and “V” closer, and accents and vowel marks are placed on their base letters by anchor points. - Clusters: each output glyph is tied back to the characters it came from, so you can place carets, select text and map a click to a character.
Without shaping, Arabic comes out as disconnected letters and Hindi as letters in the wrong places. Even for English, it gives you the font’s ligatures and kerning. A shaper does this for one run: one font, one direction, one script. Splitting a paragraph into runs, choosing fonts and breaking lines happen around it.
Shape text with OneFont
dotnet new console -o Shaping cd Shaping dotnet add package RecursiveAI.OneFont --version 1.3.0
The samples use Noto Serif, Noto Sans Arabic and Noto Sans Devanagari (SIL Open Font License, from Google Fonts) in the program’s folder.
using OneFont; Shape("office AV", "NotoSerif-Regular.ttf", rightToLeft: false); Shape("مرحبا بالعالم", "NotoSansArabic-Regular.ttf", rightToLeft: true); // "hello world" Shape("नमस्ते", "NotoSansDevanagari-Regular.ttf", rightToLeft: false); // "namaste" static void Shape(string text, string fontPath, bool rightToLeft) { FontFace face = FontFace.Load(File.ReadAllBytes(fontPath)); IReadOnlyList<ShapedCluster> 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}")); Console.WriteLine($"{text}: {string.Join(" ", glyphs)}"); }
office AV: 82@0+577 2132@1+1002 70@4+492 72@5+535 3@6+260 36@7+625 57@8+675 مرحبا بالعالم: 771@0+525 979@1+396 417@2+549 104@3+343 47@4+291 3@5+260 102@6+269 47@7+291 667@8+260 12@9+521 47@10+291 667@11+260 770@12+562 नमस्ते: 44@0+555 50@1+598 215@2+389 40@2+570 75@2+0
Each item is glyph@cluster+advance:
- In “office AV”, glyph 2132 is the “ffi” ligature, 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.
- The Arabic letters take their joined forms, and the clusters come back in logical order, the order of the string. For display, draw a right-to-left run’s clusters from last to first.
- In “नमस्ते” the last four characters are one cluster: स becomes a half form (glyph 215) joined to ते. A cluster is the smallest unit you can place, select or break on its own.
- Advances and offsets are in font units. Multiply by
size / face.UnitsPerEmfor pixels or points. - OneFont picks the script shaper 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.
The same with HarfBuzzSharp
HarfBuzzSharp 14.2.1.301 (MIT) is part of Microsoft’s open-source SkiaSharp project and binds HarfBuzz, which its README says is used in Android, Chrome, Firefox and Microsoft Edge, among many others. Its NuGet README notes that on Linux you add the matching native-assets package yourself. Without HarfBuzzSharp.NativeAssets.Linux, the program builds but fails at run time with “Unable to load shared library ‘libHarfBuzzSharp’”.
# HarfBuzzSharp brings native libraries for Windows and macOS; add Linux yourself dotnet add package HarfBuzzSharp --version 14.2.1.301 dotnet add package HarfBuzzSharp.NativeAssets.Linux --version 14.2.1.301
Following the README’s sample, with glyph positions from the API reference:
using HarfBuzzSharp; Shape("office AV", "NotoSerif-Regular.ttf"); Shape("مرحبا بالعالم", "NotoSansArabic-Regular.ttf"); Shape("नमस्ते", "NotoSansDevanagari-Regular.ttf"); static void Shape(string text, string fontPath) { using var blob = Blob.FromFile(fontPath); using var face = new Face(blob, 0); using var font = new Font(face); font.SetScale(face.UnitsPerEm, face.UnitsPerEm); using var buffer = new HarfBuzzSharp.Buffer(); buffer.AddUtf16(text); buffer.GuessSegmentProperties(); // script, language and direction from the text font.Shape(buffer); // Glyphs come in visual order (right to left text is reversed); Cluster indexes the UTF-16 string. var infos = buffer.GlyphInfos; var positions = buffer.GlyphPositions; var glyphs = infos.Select((g, i) => $"{g.Codepoint}@{g.Cluster}+{positions[i].XAdvance}"); Console.WriteLine($"{text}: {string.Join(" ", glyphs)}"); }
office AV: 82@0+577 2132@1+1002 70@4+492 72@5+535 3@6+260 36@7+625 57@8+675 مرحبا بالعالم: 770@12+562 667@11+260 47@10+291 12@9+521 667@8+260 47@7+291 102@6+269 3@5+260 47@4+291 104@3+343 417@2+549 979@1+396 771@0+525 नमस्ते: 44@0+555 50@1+598 215@2+389 40@4+570 75@4+0
The glyph ids and advances are the same as OneFont’s for all three strings. Two things differ in form:
- Order: HarfBuzz returns right-to-left glyphs in visual order, so the Arabic line reads backwards from OneFont’s. OneFont keeps logical order and lets you reverse at display time.
- Clusters: HarfBuzz gives “ते” its own cluster value (4), while OneFont makes स्ते one cluster starting at 2, so the conjunct is placed, selected and broken as a unit.
Turn OpenType features on and off
Features such as ligatures, small caps, fractions and old-style figures are switched by four-letter tags. With OneFont they go in ShapingOptions:
using OneFont; FontFace face = FontFace.Load(File.ReadAllBytes("NotoSerif-Regular.ttf")); Show("office 1/2", new ShapingOptions()); // defaults: liga, kern, ... var options = new ShapingOptions(); options.Features.Add(FontFeature.Off("liga")); // no ligatures options.Features.Add(FontFeature.On("frac")); // fractions Show("office 1/2", options); void Show(string text, ShapingOptions o) { var clusters = TextShaping.ShapeRun(text, face, options: o); Console.WriteLine(string.Join(" ", clusters.SelectMany(c => c.Glyphs.ToArray()).Select(g => g.GlyphId))); }
82 2132 70 72 3 20 18 21 82 73 73 76 70 72 3 2530 534 2521
And with HarfBuzzSharp, as Feature values passed to Shape:
using HarfBuzzSharp; using var blob = Blob.FromFile("NotoSerif-Regular.ttf"); using var face = new Face(blob, 0); using var font = new Font(face); Show("office 1/2"); Show("office 1/2", Feature.Parse("-liga"), Feature.Parse("frac")); void Show(string text, params Feature[] features) { using var buffer = new HarfBuzzSharp.Buffer(); buffer.AddUtf16(text); buffer.GuessSegmentProperties(); font.Shape(buffer, features); Console.WriteLine(string.Join(" ", buffer.GlyphInfos.Select(g => g.Codepoint))); }
82 2132 70 72 3 20 18 21 82 73 73 76 70 72 3 2530 534 2521
Both give the same glyphs: the ligature (2132) splits into “f”, “f”, “i” when liga is off, and with frac on, “1/2” becomes the font’s fraction glyphs (2530, 534, 2521) instead of 20, 18 and 21. OneFont’s ShapingOptions.Language takes a BCP 47 tag, such as tr or sr, to pick a language’s local forms. The OpenType features recipe covers small caps, figure styles and kerning.
What happens around the shaper
HarfBuzz is explicit about its scope: it “won’t help you with bidirectionality”, with text that mixes fonts, or with “line breaking, hyphenation, or justification”, per its page on what HarfBuzz doesn’t do, which suggests libraries such as ICU and FriBidi. With HarfBuzzSharp you find those elsewhere. OneFont includes the Unicode algorithms for them next to the shaper, on Unicode 17 data, and passes the official conformance files:
using OneFont; // What a shaper doesn't do: split a paragraph by direction, pick a font per character, // and find where lines may break. OneFont has all three next to the shaper. string text = "Order 66 shipped to שלום עליכם today"; FontFace latin = FontFace.Load(File.ReadAllBytes("NotoSans-Regular.ttf")); FontFace hebrew = FontFace.Load(File.ReadAllBytes("NotoSansHebrew-Regular.ttf")); var chain = new FontFallbackChain([latin, hebrew]); // 1. Bidi (UAX #9): an embedding level per character; odd levels are right to left. byte[] levels = TextShaping.GetBidiLevels(text, TextDirection.Auto, out int paragraphLevel); // 2. Cut the paragraph into runs of one level and shape each run with the fallback chain. var runs = new List<(int Start, int Length, byte Level)>(); for (int i = 0; i < text.Length;) { int j = i; while (j < text.Length && levels[j] == levels[i]) j++; runs.Add((i, j - i, levels[i])); i = j; } foreach (var run in runs) { string part = text.Substring(run.Start, run.Length); var clusters = chain.ShapeRun(part, rightToLeft: run.Level % 2 == 1); string faces = string.Join("", clusters.Select(c => c.FaceIndex)); Console.WriteLine($"level {run.Level} \"{part}\": {clusters.Count} clusters, faces {faces}"); } // 3. Display order (rule L2) and line break opportunities (UAX #14). int[] visual = TextShaping.GetVisualOrder(levels); Console.WriteLine("display: " + new string(visual.Select(i => text[i]).ToArray())); LineBreakOpportunity[] breaks = TextSegmentation.GetLineBreakOpportunities(text); Console.WriteLine("may break before: " + string.Join(", ", Enumerable.Range(1, text.Length - 1).Where(i => breaks[i] != LineBreakOpportunity.None)));
level 0 "Order 66 shipped to ": 20 clusters, faces 00000000000000000000 level 1 "שלום עליכם": 10 clusters, faces 1111011111 level 0 " today": 6 clusters, faces 000000 display: Order 66 shipped to םכילע םולש today may break before: 6, 9, 17, 20, 25, 31
GetBidiLevelsruns the Unicode Bidirectional Algorithm (UAX #9). Level 1 marks the Hebrew words as right to left inside a left-to-right paragraph.FontFallbackChainshapes each grapheme cluster with the first face that has its character: Noto Sans for the Latin text and the space, Noto Sans Hebrew for the Hebrew letters, each with its own tables.GetVisualOrdergives the display order. Printed character by character to a terminal, the Hebrew comes out reversed, which is the order a renderer draws it from left to right.GetLineBreakOpportunitiesfollows UAX #14. Choosing breaks and placing lines is left to you; the measure and wrap text recipe shows a simple version.
From HarfBuzzSharp to OneFont
| HarfBuzzSharp | OneFont 1.3.0 |
|---|---|
Blob.FromFile, new Face(blob, 0), new Font(face) | FontFace.Load(bytes), or FontFace.Load(bytes, faceIndex) for a collection |
font.SetScale(...) | Not needed: results are in font units; scale by size / UnitsPerEm |
buffer.AddUtf16(text), GuessSegmentProperties(), font.Shape(buffer) | TextShaping.ShapeRun(text, face, rightToLeft); the script is found from the text |
Feature.Parse("smcp"), Feature.Parse("-liga") | FontFeature.On("smcp"), FontFeature.Off("liga") in ShapingOptions.Features |
buffer.Language | ShapingOptions.Language |
GlyphInfo.Codepoint (the glyph id after shaping), GlyphInfo.Cluster | ShapedGlyph.GlyphId, ShapedCluster.Start and Length |
GlyphPosition.XAdvance, XOffset, YOffset | ShapedGlyph.XAdvance, XOffset, YOffset |
| Glyph order: visual | Cluster order: logical |
For many runs, OneFont’s ShapedRun overload reuses one result object and allocates nothing per call once warm; see shape without allocating.
Which one to use
- Choose OneFont when you want no native libraries to ship or load (servers, containers, NativeAOT, trimmed apps), when you need bidi, line breaking and font fallback in the same package, or when you parse fonts you don’t trust: every read is bounds-checked and shaping work is capped, per the shaping documentation.
- Choose HarfBuzzSharp when you already draw with SkiaSharp (
SkiaSharp.HarfBuzzconnects the two), when you need variable-font instances other than the default or access to color glyph data, or when you want the shaper those browsers use.
OneFont 1.3.0 doesn’t read WOFF or WOFF2, draw color glyphs, hint, lay out vertical text, or draw text at all: it shapes. To draw shaped text on images, OneImage’s text support is built on OneFont and adds paragraph layout, WOFF and WOFF2, hinting, color glyphs and vertical text.
Where next
- Getting started with OneFont and shaping, bidi and segmentation: the tables, features, order and limits.
- The cookbook: complex scripts, right-to-left text and caret positions.
- OpenType vs TrueType: what differs inside a font file.