---
title: "Text shaping in C#"
url: https://recursiveai.org/onefont/articles/text-shaping-csharp/
description: "Shape text in C# with OpenType GSUB and GPOS: Arabic, Devanagari, ligatures, kerning and features, with OneFont or HarfBuzzSharp, compared on the same fonts."
date: 2026-10-26
author: OneFont team
---

# Text shaping in C#

Shape text in C# with OpenType GSUB and GPOS: Arabic, Devanagari, ligatures, kerning and features, with OneFont or HarfBuzzSharp, compared on the same fonts.

October 26, 2026 · OneFont team

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 `cmap` table.
- **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

*Terminal · NuGet*

```
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](https://fonts.google.com/noto)) in the program’s folder.

*Program.cs · OneFont 1.3.0 · .NET 10*

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

*Output · Console*

```
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.UnitsPerEm` for 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](https://github.com/harfbuzz/harfbuzz) says is used in Android, Chrome, Firefox and Microsoft Edge, among many others. Its [NuGet README](https://www.nuget.org/packages/HarfBuzzSharp/14.2.1.301) 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’”.

*Terminal · NuGet*

```
# 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](https://learn.microsoft.com/en-us/dotnet/api/harfbuzzsharp.glyphposition):

*Program.cs · HarfBuzzSharp 14.2.1.301*

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

*Output · Console*

```
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`:

*Program.cs · OneFont 1.3.0 · .NET 10*

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

*Output · Console*

```
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`:

*Program.cs · HarfBuzzSharp 14.2.1.301*

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

*Output · Console*

```
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](https://recursiveai.org/onefont/cookbook/opentype-features/) 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](https://harfbuzz.github.io/what-harfbuzz-doesnt-do.html), 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:

*Program.cs · OneFont 1.3.0 · .NET 10*

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

*Output · Console*

```
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
```

- `GetBidiLevels` runs the Unicode Bidirectional Algorithm (UAX #9). Level 1 marks the Hebrew words as right to left inside a left-to-right paragraph.
- `FontFallbackChain` shapes 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.
- `GetVisualOrder` gives 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.
- `GetLineBreakOpportunities` follows UAX #14. Choosing breaks and placing lines is left to you; the [measure and wrap text](https://recursiveai.org/onefont/cookbook/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](https://recursiveai.org/onefont/cookbook/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](https://recursiveai.org/onefont/docs/shaping/).
- **Choose HarfBuzzSharp** when you already draw with SkiaSharp (`SkiaSharp.HarfBuzz` connects 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](https://recursiveai.org/oneimage/docs/text/) is built on OneFont and adds paragraph layout, WOFF and WOFF2, hinting, color glyphs and vertical text.

## Where next

- [Getting started with OneFont](https://recursiveai.org/onefont/docs/getting-started/) and [shaping, bidi and segmentation](https://recursiveai.org/onefont/docs/shaping/): the tables, features, order and limits.
- The cookbook: [complex scripts](https://recursiveai.org/onefont/cookbook/complex-scripts/), [right-to-left text](https://recursiveai.org/onefont/cookbook/right-to-left-text/) and [caret positions](https://recursiveai.org/onefont/cookbook/caret-positions/).
- [OpenType vs TrueType](https://recursiveai.org/onefont/articles/opentype-vs-truetype/): what differs inside a font file.
