Recursive AI

Measure text width in C# without System.Drawing

All onefont recipes

Shape the string, add up the cluster advances, and scale from font units to pixels with size / face.UnitsPerEm. The width includes the font’s kerning and ligatures, and it is the same on every platform because it comes from the font file, not from an installed font or a graphics context.

Terminal
dotnet new console -o MeasureText && cd MeasureText
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

Put this in Program.cs and run dotnet run:

C#
using OneFont;
 
FontFace face = FontFace.Load(File.ReadAllBytes("NotoSerif-Regular.ttf"));
 
var noKerning = new ShapingOptions { Kerning = KerningMode.None };
foreach (string text in new[] { "Wave", "office", "Invoice total: $1,240.00" })
{
    float width = Width(text, 16), unkerned = Width(text, 16, noKerning);
    Console.WriteLine($"\"{text}\" at 16 px: {width:F2} px wide "
        + $"(without kerning {unkerned:F2} px)");
}
 
// The same text at other sizes: width scales linearly with the font size.
var sizes = new[] { 12f, 24f, 48f }.Select(px => Width("Wave", px).ToString("F2"));
Console.WriteLine($"\"Wave\" at 12 / 24 / 48 px: {string.Join(" / ", sizes)}");
 
// Height of a line, and of the ink above and below the baseline, at 16 px.
FontMetrics m = face.Metrics;
float s = 16f / face.UnitsPerEm;
Console.WriteLine($"line height {m.LineHeight * s:F2} px, "
    + $"ascender {m.Ascender * s:F2}, descender {m.Descender * s:F2}");
 
// Shape, add up the advances (font units), scale to pixels.
float Width(string text, float size, ShapingOptions? options = null)
{
    int units = TextShaping.ShapeRun(text, face, options: options).Sum(c => c.Advance);
    return units * size / face.UnitsPerEm;
}
Text
"Wave" at 16 px: 42.46 px wide (without kerning 43.58 px)
"office" at 16 px: 41.70 px wide (without kerning 41.70 px)
"Invoice total: $1,240.00" at 16 px: 174.21 px wide (without kerning 174.21 px)
"Wave" at 12 / 24 / 48 px: 31.85 / 63.70 / 127.39
line height 21.79 px, ascender 17.10, descender -4.69

How it works

  • Advances. Advance on a cluster is the sum of its glyphs’ advances in design units, including pair kerning with the cluster that follows. Kerning pulls “W” and “a” together, so “Wave” is narrower than its letters added up.
  • Ligatures. “office” is measured with the font’s ffi ligature, one glyph for three letters, which is why measuring character by character from the character map gives the wrong width.
  • Scaling. Design units times size / UnitsPerEm gives pixels when the size is in pixels, or points when it is in points.
  • Height. Metrics.LineHeight is ascender minus descender plus line gap. The descender is negative: it is below the baseline.

Several fonts or scripts

For text that needs more than one font, measure through a FontFallbackChain: each cluster has a FaceIndex, and each face has its own UnitsPerEm. Arabic, Hebrew and Indic text is measured the same way, since shaping produces the joined forms and conjuncts first.

Limits

  • Advance width, not ink. The width is where the next text would start. A glyph’s ink can reach past it (an italic f, for example). For the exact ink box, read the outlines’ TightBox(), as in text to SVG path.
  • Unhinted. Advances are the font’s design advances, not snapped to whole pixels as some renderers do at small sizes.
  • One line. To fit text into a width, see Wrap text to a width.