Install onefont for .NET 10, load a font, read its metrics and character map, shape Latin, Arabic and Hindi text, and find line breaks and fallback fonts.
onefont is a font and text-shaping library written in C# with no dependency beyond .NET itself. It loads a font, shapes a run of text, and gives back glyphs, advances, offsets and clusters. Placing lines and drawing glyphs is up to you. Every sample on this page was compiled and run against the 1.3.0 package from NuGet, with Noto fonts under the SIL Open Font License.
Install
onefont targets .NET 10 (net10.0), so you need the .NET 10 SDK.
dotnet new console -o FontStart cd FontStart dotnet add package RecursiveAI.OneFont --version 1.3.0
The namespace is OneFont. The package has no dependencies: no native binaries and no other NuGet packages. It is safe code only, and NativeAOT and trimming are supported. The license is MIT or Apache-2.0, at your choice.
The samples read font files from the current directory. Put the fonts there under the names the code uses: Noto Sans, Noto Serif and the other Noto fonts are free to download from Google Fonts.
Load a font
using OneFont; FontFace face = FontFace.Load(File.ReadAllBytes("NotoSans-Regular.ttf")); Console.WriteLine($"{face.FamilyName} {face.SubfamilyName}, weight {face.Weight}"); Console.WriteLine($"{face.GlyphCount} glyphs, CFF outlines: {face.IsCff}"); // Every metric is in design units. Scale by size / UnitsPerEm. FontMetrics m = face.Metrics; Console.WriteLine($"{m.UnitsPerEm} units/em, ascender {m.Ascender}, descender {m.Descender}"); float size = 16; Console.WriteLine($"line height at {size} px: {m.LineHeight * size / face.UnitsPerEm:F2} px"); // The character map: code point to glyph id. Glyph 0 is the missing-glyph box. foreach (int cp in new[] { 'A', 'é', 0x20AC, 0x05D0 }) { int glyph = face.GetGlyphIndex(cp); string ch = char.ConvertFromUtf32(cp); Console.WriteLine($"U+{cp:X4} {ch} glyph {glyph,3} advance {face.GetAdvance(glyph)} has it: {face.HasGlyph(cp)}"); } // A TrueType Collection holds several faces; pick one by index. byte[] cjk = File.ReadAllBytes("NotoSansCJK-Regular.ttc"); FontFace jp = FontFace.Load(cjk, faceIndex: 0); Console.WriteLine($"{FontFace.GetFaceCount(cjk)} faces; face 0 is {jp.FamilyName}, CFF: {jp.IsCff}");
Noto Sans Regular, weight 400 3317 glyphs, CFF outlines: False 1000 units/em, ascender 1069, descender -293 line height at 16 px: 21.79 px U+0041 A glyph 36 advance 639 has it: True U+00E9 é glyph 171 advance 564 has it: True U+20AC € glyph 539 advance 572 has it: True U+05D0 א glyph 0 advance 600 has it: False 10 faces; face 0 is Noto Sans CJK JP, CFF: True
FontFace.Loadtakes the bytes of a.ttf,.otfor.ttcfile and keeps a reference to the array, so don’t change it while the face is in use. A face reads its tables lazily, is immutable, and is safe to share between threads.- Metrics, advances and outline coordinates are all in the font’s design units. Multiply by
size / UnitsPerEmto get pixels or points. The line height is ascender minus descender plus line gap. GetGlyphIndexmaps a code point through the font’s Unicode character map. Glyph 0 is the missing-glyph box, andHasGlyphis true when the id isn’t 0. Here Noto Sans has no Hebrew letters.- A TrueType Collection holds several faces.
GetFaceCountsays how many, andLoad(data, faceIndex)picks one. Noto Sans CJK is a collection with CFF outlines, which onefont reads as well as TrueType’s. - A damaged or unsupported file throws
FontFormatException. WOFF, WOFF2 and CFF2 fonts aren’t read.
More in Read a TTF or OTF font file.
Shape text
Shaping turns characters into positioned glyphs with the font’s own OpenType tables: ligatures, kerning, contextual forms and the reordering complex scripts need.
using OneFont; FontFace latin = FontFace.Load(File.ReadAllBytes("NotoSerif-Regular.ttf")); FontFace arabic = FontFace.Load(File.ReadAllBytes("NotoSansArabic-Regular.ttf")); FontFace hindi = FontFace.Load(File.ReadAllBytes("NotoSansDevanagari-Regular.ttf")); Print("office AV", latin, rightToLeft: false); Print("سلام", arabic, rightToLeft: true); // "salam" Print("नमस्ते", hindi, rightToLeft: false); // "namaste" static void Print(string text, FontFace face, bool rightToLeft) { IReadOnlyList<ShapedCluster> clusters = TextShaping.ShapeRun(text, face, rightToLeft); Console.WriteLine($"{clusters.Count} clusters, {clusters.Sum(c => c.Advance)} units wide: {text}"); foreach (ShapedCluster c in clusters) { string glyphs = string.Join(" ", c.Glyphs.ToArray().Select(g => $"{g.GlyphId}+{g.XAdvance}")); Console.WriteLine($" [{c.Start},{c.Length}] glyphs {glyphs,-20} {text.Substring(c.Start, c.Length)}"); } }
7 clusters, 4166 units wide: office AV [0,1] glyphs 82+577 o [1,3] glyphs 2132+1002 ffi [4,1] glyphs 70+492 c [5,1] glyphs 72+535 e [6,1] glyphs 3+260 [7,1] glyphs 36+625 A [8,1] glyphs 57+675 V 3 clusters, 1867 units wide: سلام [0,1] glyphs 1077+784 س [1,2] glyphs 705+599 لا [3,1] glyphs 769+484 م 3 clusters, 2112 units wide: नमस्ते [0,1] glyphs 44+555 न [1,1] glyphs 50+598 म [2,4] glyphs 215+389 40+570 75+0 स्ते
TextShaping.ShapeRunreturns clusters in logical order that cover the text without gaps.StartandLengthindex the text in UTF-16 code units. A cluster is the smallest piece you can place on its own: one character, a ligature, or a letter with its marks.- In Noto Serif, “ffi” becomes one ligature glyph, so three characters form one cluster. The A’s advance is 625 instead of the 705 in the font’s metrics table: the font kerns A and V by −80 units.
- Arabic is shaped with
rightToLeft: true. س is drawn in its initial form (glyph 1077, where the character map gives the isolated 1075), and lam and alef become the font’s lam-alef ligature. The clusters still come in logical order, so draw a right-to-left run’s clusters last to first. - In “नमस्ते” the conjunct स्त is a half form of स followed by त, and the four characters of स्ते are one cluster. The script shaper (Arabic, Indic, the Universal Shaping Engine, or Thai and Lao) is chosen from the text. You don’t name the script.
ShapeRuntakes one run: one face, one direction and one paragraph’s worth of text. Splitting a paragraph into runs by direction is your code’s job, shown in Right-to-left and mixed-direction text.- Advances and offsets are in design units.
ShapedGlyph.XOffsetandYOffsetmove a glyph away from the pen position, for marks and cursive attachment.
To turn features such as small caps on or off, or pick language-specific forms, pass ShapingOptions. See Turn on OpenType features.
Line breaks, graphemes and fallback
using OneFont; // Where a line may end (UAX #14). Entry i is about the position before text[i]. string text = "A well-known word\u2014and 日本語."; LineBreakOpportunity[] breaks = TextSegmentation.GetLineBreakOpportunities(text); var pieces = new List<string>(); int start = 0; for (int i = 1; i <= text.Length; i++) { if (breaks[i] != LineBreakOpportunity.None) { pieces.Add(text[start..i]); start = i; } } Console.WriteLine(string.Join(" | ", pieces.Select(p => $"\"{p}\""))); // User-perceived characters (UAX #29): e + combining acute is one, a flag is one. int[] bounds = TextSegmentation.GetGraphemeBoundaries("e\u0301🇫🇷!"); Console.WriteLine($"grapheme starts: {string.Join(", ", bounds)}"); // A fallback chain shapes each cluster with the first face that has its character. var chain = new FontFallbackChain([ FontFace.Load(File.ReadAllBytes("NotoSans-Regular.ttf")), FontFace.Load(File.ReadAllBytes("NotoSansCJK-Regular.ttc"), faceIndex: 0), ]); string mixed = "Go 東京"; foreach (ShapedCluster c in chain.ShapeRun(mixed)) { FontFace face = chain.Faces[c.FaceIndex]; float px = c.Advance * 16f / face.UnitsPerEm; // at 16 px Console.WriteLine($"{mixed.Substring(c.Start, c.Length)} face {c.FaceIndex} ({face.FamilyName}) {px:F2} px"); }
"A " | "well-" | "known " | "word" | "—" | "and " | "日" | "本" | "語." grapheme starts: 0, 2, 6, 7 G face 0 (Noto Sans) 11.65 px o face 0 (Noto Sans) 9.68 px face 0 (Noto Sans) 4.16 px 東 face 1 (Noto Sans CJK JP) 16.00 px 京 face 1 (Noto Sans CJK JP) 16.00 px
GetLineBreakOpportunitiesfollows the Unicode line breaking algorithm (UAX #14) on Unicode 17 data. It returnstext.Length + 1entries, one per position, eachNone,AllowedorMandatory. A line may end after a space, after the hyphen of “well-”, around the em dash and between ideographs, but not before the final period.GetGraphemeBoundariesgives the start of each user-perceived character (UAX #29), then the text length. Here e + U+0301 is one character, and the two regional indicators of the French flag are another.FontFallbackChaintakes faces in order of preference. Each grapheme cluster is shaped with the first face that has its first character, using that face’s own tables, andFaceIndexsays which. A character no face has stays with the first face, as its missing-glyph box.- Each cluster’s advance is in its own face’s units, so scale by that face’s
UnitsPerEm.
onefont finds where lines may break. Choosing the breaks and placing the lines is up to you, as in Measure and wrap text.
Drawing the text
onefont doesn’t draw. It gives glyph outlines as move, line, quadratic and cubic segments through the IOutlineSink interface, which you can turn into SVG paths or feed to your own renderer.
To draw text on images, use OneImage.Text, which is built on onefont. It adds paragraph layout, WOFF and WOFF2, hinting, color glyphs and vertical text. To set text in PDF documents, OnePDF’s layout package shapes with onefont too: see Arabic and Hindi text in a PDF.
The package
onefont is one package, RecursiveAI.OneFont, namespace OneFont, for net10.0, under MIT or Apache-2.0. It holds the font reader (TrueType, OpenType with CFF outlines, TrueType Collections), OpenType shaping, Unicode bidi, line breaking and grapheme boundaries, font fallback and glyph outlines.
Not in the package: WOFF and WOFF2, color glyphs (COLR and CPAL are available only as raw bytes through GetTable), hinting, vertical layout, variable-font instances other than the default, and drawing text.
Where next
- Shaping, bidi and segmentation: the tables and features applied and in what order, the script shapers, the limits that protect against hostile fonts, and where the output differs from SixLabors.Fonts on purpose.
- The cookbook: reading fonts, checking for characters, measuring and wrapping, SVG paths, right-to-left text, OpenType features, complex scripts, caret positions and shaping without allocating.
- Changelog
Background: OpenType vs TrueType and Text shaping in C#.