Recursive AI

Shape many runs without allocating

All onefont recipes

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.

C#
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");
Text
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 i of a ShapedRun has the start, length, face, advance, missing-glyph flag and glyphs of cluster i of the list, so the totals match.
  • Reading it. run.Clusters is a span of ShapedClusterRange. run.GlyphsOf(cluster) gives that cluster’s glyphs, and run.Glyphs holds all of them, cluster after cluster in logical order. run.Origins gives 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 ShapedRun belongs to one thread at a time. Any number of threads may shape at once, each with its own. A ShapingOptions can 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 a ShapedRun the same way, with FaceIndex on 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.