Shape thousands of strings in C# with no garbage per call: reuse a ShapedRun and a ShapingOptions, and read clusters and glyphs from spans.
TextShaping.ShapeRun returns a list with one object per cluster. That is convenient, and fine for a heading or a label. A paragraph engine, a table of cells or a server that measures text all day can pass a ShapedRun instead: flat arrays that grow once and are reused on every call.
using OneFont; FontFace face = FontFace.Load(File.ReadAllBytes("NotoSans-Regular.ttf")); string[] cells = Enumerable.Range(1, 10_000).Select(i => $"Row {i}: invoice total {i * 37.5:F2} USD").ToArray(); // The list API: one object per cluster. long before = GC.GetAllocatedBytesForCurrentThread(); long width1 = 0; foreach (string cell in cells) foreach (ShapedCluster c in TextShaping.ShapeRun(cell, face)) width1 += c.Advance; long listBytes = GC.GetAllocatedBytesForCurrentThread() - before; // A reusable ShapedRun: flat arrays that grow once and are kept between calls. var run = new ShapedRun(); var options = new ShapingOptions { Kerning = KerningMode.Normal }; // reuse one options object too TextShaping.ShapeRun(cells.MaxBy(c => c.Length), face, run, options: options); // warm up on the longest run before = GC.GetAllocatedBytesForCurrentThread(); long width2 = 0; foreach (string cell in cells) { TextShaping.ShapeRun(cell, face, run, options: options); foreach (ShapedClusterRange c in run.Clusters) width2 += c.Advance; } long runBytes = GC.GetAllocatedBytesForCurrentThread() - before; int chars = cells.Sum(c => c.Length); Console.WriteLine($"{cells.Length} runs, {chars} characters, same total width: {width1 == width2}"); Console.WriteLine($"list API: {listBytes / (double)chars:F1} bytes allocated per character"); Console.WriteLine($"ShapedRun: {runBytes} bytes allocated in total");
10000 runs, 365934 characters, same total width: True list API: 83.5 bytes allocated per character ShapedRun: 0 bytes allocated in total
- The same answer. Cluster
iof aShapedRunhas the start, length, face, advance, missing-glyph flag and glyphs of clusteriof the list, so the totals match. - Reading it.
run.Clustersis a span ofShapedClusterRange.run.GlyphsOf(cluster)gives that cluster’s glyphs, andrun.Glyphsholds all of them, cluster after cluster in logical order.run.Originsgives the first code point each glyph stands for. - Valid until the next call. Each call clears the result and refills it, so copy out what you need first. If a call throws
FontFormatException, the result is left empty. - Warm up. The arrays grow to the longest run they have held, and the shaper keeps a little working state per thread. Shape the longest text once before measuring. Warming up on the shortest cell instead allocated 3,432 bytes in this run, as longer cells grew the arrays.
- Threads. A
ShapedRunbelongs to one thread at a time. Any number of threads may shape at once, each with its own. AShapingOptionscan be shared as long as nobody changes it, and it caches its feature set, so reuse one rather than creating one per call. - Fallback.
FontFallbackChain.ShapeRun(text, run)fills aShapedRunthe same way, withFaceIndexon each cluster.
The per-character figure for the list API depends on the text. The changelog gives about 75 bytes per character for Latin, and this sample’s mix of words and numbers came to 83.5.
For callers that cut paragraphs into runs themselves and keep their own cluster numbers, TextShaping.ShapeCodePoints with a reusable CodePointRun works the same way on code points. It is described in Shaping, bidi and segmentation.