Measure the width of a string in C# from the font itself, with kerning and ligatures, and wrap a paragraph at Unicode line break opportunities, without System.Drawing.
The width of a piece of text is the sum of its shaped advances, scaled from design units to your font size. Shaping first matters: kerning and ligatures change the width, and complex scripts can’t be measured from the character map at all. To wrap, combine those widths with the places the Unicode line breaking algorithm allows a break.
using OneFont; FontFace face = FontFace.Load(File.ReadAllBytes("NotoSerif-Regular.ttf")); float size = 16; // pixels per em float scale = size / face.UnitsPerEm; // Measure: shape, then add up the advances. Kerning and ligatures are included. var noKerning = new ShapingOptions { Kerning = KerningMode.None }; Console.WriteLine($"\"Wave\" is {Width("Wave"):F2} px; without kerning {Width("Wave", noKerning):F2} px"); // Wrap: shape the paragraph once, then break greedily where UAX #14 allows. string text = "The quick brown fox jumps over the lazy dog. A well-known sentence, set in Noto Serif at 16 px."; float maxWidth = 220; IReadOnlyList<ShapedCluster> clusters = TextShaping.ShapeRun(text, face); LineBreakOpportunity[] breaks = TextSegmentation.GetLineBreakOpportunities(text); // x[i] = pen position, in px, before UTF-16 index i. var x = new float[text.Length + 1]; foreach (ShapedCluster c in clusters) for (int i = c.Start; i < c.Start + c.Length; i++) x[i + 1] = x[c.Start] + c.Advance * scale; int lineStart = 0, lastBreak = -1; for (int i = 1; i <= text.Length; i++) { if (breaks[i] == LineBreakOpportunity.None) continue; // Trailing spaces hang past the margin, so they don't count toward the width. if (Visible(lineStart, i) > maxWidth && lastBreak > lineStart) { Emit(lineStart, lastBreak); lineStart = lastBreak; } lastBreak = i; } Emit(lineStart, text.Length); Console.WriteLine($"line height {face.Metrics.LineHeight * scale:F2} px"); float Visible(int from, int to) { while (to > from && text[to - 1] == ' ') to--; return x[to] - x[from]; } void Emit(int from, int to) => Console.WriteLine($"{Visible(from, to),6:F2} px | {text[from..to].TrimEnd()}"); float Width(string s, ShapingOptions? options = null) => TextShaping.ShapeRun(s, face, options: options).Sum(c => c.Advance) * scale;
"Wave" is 42.46 px; without kerning 43.58 px 209.71 px | The quick brown fox jumps 191.10 px | over the lazy dog. A well- 213.86 px | known sentence, set in Noto 103.90 px | Serif at 16 px. line height 21.79 px
- Measure.
Advanceon a cluster is the sum of its glyphs’ advances in design units, including pair kerning with the cluster that follows. Multiply bysize / UnitsPerEm. In Noto Serif at 16 px, kerning takes “Wave” from 43.58 to 42.46 px. - Break opportunities.
GetLineBreakOpportunitiesgives one entry per position,text.Length + 1in all: entryisays whether a line may end beforetext[i]. That covers spaces, hyphens (“well-” above), dashes, and the rules for East Asian text, numbers and punctuation in UAX #14.Mandatorymarks a hard line break. - Pen positions. The sample shapes the paragraph once and records the pen position before every UTF-16 index, so the width of any line is a subtraction. Positions inside a cluster get the cluster’s end, and the line breaking algorithm never allows a break inside a combining sequence or a surrogate pair.
- Greedy wrapping. Each line takes as many break opportunities as fit in 220 px. Trailing spaces don’t count toward the width. A single word wider than the line stays on its own line and overflows: break inside it at grapheme boundaries if you need to.
- Line height.
Metrics.LineHeightis ascender minus descender plus line gap.
What onefont leaves to you: choosing the breaks (greedy as here, or a smarter fit), hyphenation, justification, tab stops, and placing each line. A paragraph with right-to-left text needs its bidi levels and runs as well, per line: see Right-to-left and mixed-direction text. Shaping the whole paragraph once, as here, is right for one face and one direction. Text in several fonts goes through a fallback chain the same way.
To lay out and draw paragraphs on images, OneImage.Text does all of this on top of onefont.