Compute caret positions for shaped text in C#, including inside ligatures, and map a click’s x coordinate back to an index in the string.
An editor or a text field needs to know where the caret goes between characters, and which index a click lands on. Shaped text makes both harder than adding up character widths: a ligature is one glyph for several letters, and a letter with a combining mark is one character to the user but two in the string. Shaped clusters and grapheme boundaries give the answer.
using OneFont; FontFace face = FontFace.Load(File.ReadAllBytes("NotoSerif-Regular.ttf")); string text = "office e\u0301clair"; // "office éclair", the é typed as e + combining acute float scale = 32f / face.UnitsPerEm; // 32 px IReadOnlyList<ShapedCluster> clusters = TextShaping.ShapeRun(text, face); int[] graphemes = TextSegmentation.GetGraphemeBoundaries(text); // A caret may sit at every grapheme boundary. Inside a cluster that holds several graphemes // (the ffi ligature), share the cluster's advance between them. var carets = new SortedDictionary<int, float>(); float pen = 0; foreach (ShapedCluster c in clusters) { int[] inside = graphemes.Where(b => b >= c.Start && b < c.Start + c.Length).ToArray(); for (int k = 0; k < inside.Length; k++) carets[inside[k]] = pen + c.Advance * scale * k / inside.Length; pen += c.Advance * scale; } carets[text.Length] = pen; Console.WriteLine(string.Join(" ", carets.Select(p => $"{p.Key}:{p.Value:F1}"))); // Hit test: the caret nearest to a click. foreach (float x in new[] { 3f, 25f, 120f, 500f }) { int offset = carets.MinBy(p => Math.Abs(p.Value - x)).Key; Console.WriteLine($"click at x={x}: index {offset} {text[..offset]}|{text[offset..]}"); }
0:0.0 1:18.5 2:29.2 3:39.8 4:50.5 5:66.3 6:83.4 7:91.7 9:108.8 10:124.6 11:134.5 12:152.5 13:162.8 14:177.8 click at x=3: index 0 |office éclair click at x=25: index 2 of|fice éclair click at x=120: index 10 office éc|lair click at x=500: index 14 office éclair|
- Where a caret may sit. At every grapheme boundary from
GetGraphemeBoundaries, never inside one. Index 8, between “e” and its combining acute, isn’t a caret position, so the list jumps from 7 to 9. - Inside a ligature. The ffi ligature is one cluster covering three graphemes. The sample splits its advance evenly, so the caret can stop between the f’s. A font can say where carets go inside a ligature (the
GDEFligature caret list), but onefont doesn’t read it.GetTable("GDEF")gives you the raw table if you need it. - Hit testing. The nearest caret wins. A click before the start or past the end snaps to the first or last position.
- Units. Advances are in design units, scaled here to 32 px.
This covers one left-to-right run. For right-to-left runs, positions run from the right edge: place the clusters in display order first, as in Right-to-left and mixed-direction text. For several lines, hit-test the line first by y, then within it.