# onefont docs, all pages (onefont 1.3.0) Every page of https://recursiveai.org/onefont/docs/ and https://recursiveai.org/onefont/cookbook/ as markdown, getting started first. Index: https://recursiveai.org/onefont/llms.txt # Getting started with onefont 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. ```sh 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 ```csharp 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}"); ``` ```text 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.Load` takes the bytes of a `.ttf`, `.otf` or `.ttc` file 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 / UnitsPerEm` to get pixels or points. The line height is ascender minus descender plus line gap. - `GetGlyphIndex` maps a code point through the font’s Unicode character map. Glyph 0 is the missing-glyph box, and `HasGlyph` is true when the id isn’t 0. Here Noto Sans has no Hebrew letters. - A TrueType Collection holds several faces. `GetFaceCount` says how many, and `Load(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](https://recursiveai.org/onefont/cookbook/read-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. ```csharp 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 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)}"); } } ``` ```text 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.ShapeRun` returns clusters in logical order that cover the text without gaps. `Start` and `Length` index 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. - `ShapeRun` takes 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](https://recursiveai.org/onefont/cookbook/right-to-left-text/). - Advances and offsets are in design units. `ShapedGlyph.XOffset` and `YOffset` move 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](https://recursiveai.org/onefont/cookbook/opentype-features/). ## Line breaks, graphemes and fallback ```csharp 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(); 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"); } ``` ```text "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 ``` - `GetLineBreakOpportunities` follows the Unicode line breaking algorithm (UAX #14) on Unicode 17 data. It returns `text.Length + 1` entries, one per position, each `None`, `Allowed` or `Mandatory`. 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. - `GetGraphemeBoundaries` gives 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. - `FontFallbackChain` takes 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, and `FaceIndex` says 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](https://recursiveai.org/onefont/cookbook/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](https://recursiveai.org/onefont/cookbook/text-to-svg-path/) or feed to your own renderer. To draw text on images, use [OneImage.Text](https://recursiveai.org/oneimage/docs/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](https://recursiveai.org/onepdf/cookbook/arabic-hindi-text/). ## 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](https://recursiveai.org/onefont/docs/shaping/): 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](https://recursiveai.org/onefont/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](https://recursiveai.org/onefont/docs/changelog/) Background: [OpenType vs TrueType](https://recursiveai.org/onefont/articles/opentype-vs-truetype/) and [Text shaping in C#](https://recursiveai.org/onefont/articles/text-shaping-csharp/). # Shaping, bidi and segmentation: what OneFont does This page describes behavior that goes beyond the API docs: which tables are read, in what order features run, the limits that protect against hostile fonts, and where the output deliberately differs from SixLabors.Fonts 2.1.3, which the tests use as a black-box reference through its public API. ## Shaping Code points go through the font's character map, then GSUB substitutions, then GPOS positioning. The result is a run of glyphs with advances and offsets in font design units. - **Tables.** `GDEF` glyph classes, mark attachment classes and mark glyph sets. `GSUB` lookup types 1 single, 2 multiple, 3 alternate, 4 ligature, 5 context, 6 chained context (all three formats of each), 7 extension and 8 reverse chaining single. `GPOS` types 1 single, 2 pair (formats 1 and 2), 3 cursive, 4 mark-to-base, 5 mark-to-ligature, 6 mark-to-mark, 7 context, 8 chained context and 9 extension. All lookup flags apply: ignore base glyphs, ligatures and marks, mark attachment type, and mark filtering sets (a filtering set wins over an attachment type). Device and variation-index tables inside value records and anchors are skipped: outlines are unhinted and a variable font is read at its default instance. - **Lookup order.** Lookups of the enabled features run in lookup-list order, as the OpenType specification says, in two stages: `ccmp` and `locl` first, then everything else. So `smcp` on `fi` changes the letters to small capitals before `liga` looks for a ligature, and no ligature forms. SixLabors.Fonts ligates first. - **Script and language.** Each stretch of one script (spaces, digits and punctuation join the stretch they are in; combining marks follow their base; nothing substitutes or kerns across a change of script) is shaped with that script's feature set, falling back to `DFLT`, `dflt`, then `latn` when the font lacks the script. `ShapingOptions.Language` (a BCP 47 tag such as `sr`, `ro`, `tr`, or an OpenType language tag in capitals such as `SRB`) picks the font's language system for the script; a language the font does not have uses the script's default. - **Features.** The defaults for horizontal text are `ccmp`, `locl`, `rlig`, `liga`, `clig`, `calt`, `rclt`, `kern`, `mark`, `mkmk` and `curs`. `ShapingOptions.Features` takes `FontFeature` settings in order, later ones replacing earlier ones: `FontFeature.On("smcp")`, `FontFeature.Off("liga")`, or a value (`new FontFeature("salt", 2)` picks the second alternate; a value past the font's alternates leaves the glyph alone). `Kerning = KerningMode.None` is the same as `Off("kern")`, and an explicit `kern` setting wins over it. A tag the font does not have does nothing. A feature outside the default stages (`smcp`, `onum`, `dlig`, `ss01`...) runs in the second stage with the defaults. - **Marks.** A mark is placed from the anchors of the font's `mark` and `mkmk` lookups: on the base, on a ligature's component, or on the mark before it. A combining sequence the font composes through `ccmp` is substituted before positioning. Kerning does not cross a change of direction. - **Clusters.** `ShapedCluster` covers the characters from its `Start` to the next cluster. A ligature is one cluster for several characters. A base and its combining marks, and the glyphs of one character the font splits into several, share one cluster, as grapheme clusters do. A zero width non-joiner, a zero width space and a soft hyphen are not shaped as part of a run by a caller that splits runs there (see below), so nothing ligates or kerns across them. - **Limits.** Fonts are untrusted. Every read is bounds-checked and a subtable that cannot be read is skipped. Contexts nest lookups at most 6 deep. A run may grow to eight glyphs per character plus 256, and may attempt at most 65,536 + 1,024 per character subtable applications (2^26 overall; a subtable that fails to read counts as 2,048, and one the engine skips because it has a type it cannot use counts as 1, so a lookup of tens of thousands of them cannot cost that many steps at every glyph). A run that exhausts the budget keeps what it has and applies no further lookups. A damaged GSUB, GPOS or GDEF header means the table is ignored, as if the font had none. Shaping does not throw on damaged layout tables; damaged glyph data raises `FontFormatException` when an outline is read. Not done: canonical reordering and composition of combining marks (a mark sequence is shaped as typed; the font's own `ccmp` handles the compositions it has), contour-point anchors, feature ranges inside a string, Apple `morx`/`kerx` layout, and the GSUB/GPOS FeatureVariations table. The paragraph-level work around the shaper (splitting a paragraph into runs of one direction, dropping format characters, ending a run at a zero width non-joiner, zero width space or soft hyphen, carrying the script from run to run) belongs to the caller. `tests/OneFont.Tests/MiniLayout.cs` is a short, complete example. ## Shaping many runs without allocating `TextShaping.ShapeRun` and `FontFallbackChain.ShapeRun` return a list of `ShapedCluster` objects: one object per cluster, with its glyphs. That is convenient and costs about 75 bytes per character (1.0.0 spent about 270). A caller that shapes thousands of runs (a paragraph engine, a table of cells) can pass a `ShapedRun` instead: ```csharp var run = new ShapedRun(); // keep it; its arrays grow once and are reused TextShaping.ShapeRun(text, face, run, rightToLeft: false, options); foreach (ShapedClusterRange cluster in run.Clusters) { ReadOnlySpan glyphs = run.GlyphsOf(cluster); // cluster.Start, Length, FaceIndex, Advance, HasMissingGlyph } ``` - **Same values.** Cluster `i` of the `ShapedRun` has the start, length, face, advance, missing-glyph flag and glyphs of cluster `i` of the list. The glyphs of all clusters sit in `run.Glyphs`, cluster after cluster in logical order, and `GlyphStart` and `GlyphCount` say which belong to a cluster. A cluster the shaper dropped every character of has `GlyphCount` 0. - **Replaced on every call.** The call clears the result first and, if it throws `FontFormatException`, leaves it empty. The spans are valid until the next call with the same `ShapedRun`. A `ShapedRun` belongs to one thread at a time; any number of threads may shape concurrently, each with its own. - **Nothing per call.** Once the result and the calling thread have warmed up, the call allocates no managed memory for Latin, Arabic or Indic text, with or without options and through a fallback chain. The shaper keeps a little state per thread (glyph buffers, the lookup engines, the sort space for cluster order); it is trimmed when a run grows it past 16,384 glyphs, so one huge run does not pin memory in an idle thread. The 1.0 list is built from the same path, so it allocates only the cluster objects, one shared glyph array and the list. - **Options.** A `ShapingOptions` object caches the feature set built from its settings and rebuilds it when `Kerning` or `Features` change, so reusing one object across calls costs nothing; a new object per call costs one small allocation if it has settings. - **Beside it.** `TextShaping.GetBidiLevels(text, direction, levels, out paragraphLevel)`, `GetVisualOrder(levels, order)` and `TextSegmentation.GetLineBreakOpportunities(text, opportunities)` fill buffers the caller owns. `tests/OneFont.Tests/ShapedRunTests.cs` checks both forms against digests made by the 1.0.0 build over every test face and a fixed corpus, so a change to either path that moves a glyph, advance, offset or cluster boundary fails. ## Direction and right-to-left text `TextShaping.GetBidiLevels` runs the Unicode Bidirectional Algorithm (UAX #9, Unicode 17 data) on one paragraph; the official `BidiTest.txt` and `BidiCharacterTest.txt` pass in full and are committed as test data. `TextDirection.Auto` takes the paragraph direction from the first strong character and falls back to left to right; `LeftToRight` and `RightToLeft` force the paragraph level, and the algorithm still reorders embedded runs of the other direction. `GetVisualOrder` applies rule L2 to levels per character or per cluster. Brackets are mirrored by the shaper (rule L4 and bracket pairs, rule N0), by code point, or through the font's `rtlm` feature when it has one. Explicit embeddings, overrides and isolates up to depth 125 are honored. Embedding depth is bounded at 125 and the bracket stack at 63, as the algorithm says, and hostile text lays out in time linear in its length. ### Arabic and Hebrew Arabic, Syriac and N'Ko go through a joining shaper. Each letter's form comes from the Joining_Type of its neighbours (transparent marks and ZWJ/ZWNJ are looked through or break the join as Unicode says), and the form features run in this order: `ccmp`, `locl`, `rtlm`; then `isol`, `fina`, `fin2`, `fin3`, `medi`, `med2`, `init` (each only on the letters of its form); then `rlig`; then `calt`; then `liga`, `clig`, `rclt`, `mset`; and `kern`, `mark`, `mkmk`, `curs` for positioning. Lam-alef and other ligatures come from the font's lookups, so a font without them gets plain joined forms. Digits after Arabic text pick up the Arabic script's lookups. Syriac Alaph forms (`fin2`, `fin3`, `med2` selection) are not implemented. Cursive attachment in right-to-left runs is implemented but no test font has `curs`, so it is untested. Hebrew needs no joining: the generic shaper and the bidirectional algorithm place the letters, and points and cantillation marks attach through the font's `mark` and `mkmk` lookups. A right-to-left run is shaped in logical order with offsets given for display order. The caller reverses the glyphs of the run (and of each cluster) when it reorders a line; `MiniLayout` shows how. Agreement with SixLabors.Fonts 2.1.3 is tested on a merged Noto Sans / Noto Sans Arabic / Noto Sans Hebrew subset: glyph ids are equal and glyph origins agree to 0.01 px except in these deliberate cases. - **Kerning across Hebrew points.** The font's kern lookup ignores marks, so we kern the letters on either side of a point; SixLabors.Fonts does not. Up to 2 px at 40 px; identical with kerning off. - **The space after a Hebrew letter** across a change of direction is 10 font units wider in SixLabors.Fonts; we use the font's advance. - **Direction controls.** SixLabors.Fonts gives embedding, isolate and override controls a 0.6 em advance each; we give them none. Glyph ids still agree. - **Forced direction.** With a forced left-to-right direction SixLabors.Fonts does no reordering at all, and with right-to-left it reverses the whole line. We apply UAX #9 with the paragraph level forced. - **Fallback faces.** With a fallback face SixLabors.Fonts applies the primary font's GDEF and GPOS to fallback glyph ids. `FontFallbackChain` shapes each face with its own tables. ## Complex scripts: Indic, Southeast Asian and the Universal Shaping Engine Three shapers are chosen by the script of a run. Script tags and the character properties come from generated Unicode tables (`tools/gen_unicode_tables.py`, Unicode 17.0.0: Script, Indic_Syllabic_Category, Indic_Positional_Category and the canonical two-part decompositions). - **Indic shaper:** Devanagari, Bengali, Gurmukhi, Gujarati, Oriya, Tamil, Telugu, Kannada and Malayalam, following Microsoft's Indic script-development documents (the v2 tags `dev2`, `bng2`... first, then the v1 tags). Text is cut into syllables, split vowels are decomposed, the base consonant is found, a reph is moved, pre-base matras go to the front, and features run in the documented order: `locl`, `ccmp`, then `nukt`, `akhn`, `rphf`, `rkrf`, `pref`, `blwf`, `abvf`, `half`, `pstf`, `vatu`, `cjct`, then `init`, `pres`, `abvs`, `blws`, `psts`, `haln`, `dist`, `abvm`, `blwm` with the default positioning features. A mark with no base gets a dotted circle (U+25CC) as its base. - **Universal Shaping Engine shaper:** Khmer, Myanmar, Sinhala, Tibetan, Balinese, Javanese and the other scripts of the USE list, from the USE specification, with Khmer and Myanmar specifics: coeng + ro, pre-base vowels and medials, the Khmer split vowels, kinzi and stacked consonants. - **Thai and Lao:** SARA AM (U+0E33, U+0EB3) is split into NIKHAHIT and SARA AA, and the nikhahit moves in front of the tone mark that precedes it, as the Thai script document says. - **Limits.** A syllable is cut after 48 glyphs; runs over 4,096 glyphs are shaped in pieces of 2,048 glyphs cut at syllable boundaries, so the cost is linear. - **Choices that follow the documents, not the reference.** ZWJ and ZWNJ are never skipped by lookup flags; their glyphs are removed before positioning. ZWJ after a consonant and halant asks for the half form, ZWNJ denies it. A reph forms only when the font's `rphf` changes Ra + halant. Malayalam needs a ZWJ for a reph. The base consonant is found with probes of the font's `pref`, `blwf` and `pstf` lookups. A syllable's characters share one cluster value. ### Agreement with SixLabors.Fonts Checked through its public API with 17 Noto subsets (`tests/testdata/fonts/Noto*-Subset-Regular.ttf`) on 1,042 cases (`tests/testdata/complex/*.txt`). Glyph ids must be equal and glyph origins within 0.01 font units. The reference throws on 26 of the cases; those are not compared. Every difference is listed in `tests/testdata/complex/known-differences.txt` with a reason; the test fails if a listed case starts to match or an unlisted one differs. - **broken-cluster.** A mark without a base: we insert the dotted circle first, in logical order, and the mark follows it (a pre-base matra moves in front of the circle as it would in front of a consonant). The reference puts the circle after the mark. - **myanmar-reorder.** The reference does no reordering for Myanmar. We follow USE: the pre-base vowel E (U+1031) and medial Ra (U+103C) move before the consonant, a virama joins two consonants into the font's stacked form, and a consonant + asat + virama before a consonant becomes the font's kinzi. - **tibetan-extra-glyph.** For U+0F40 followed by U+0F73 or U+0F81 we output the consonant with aa, then the vowel sign; the reference appends an extra dotted circle. - **joiner-kerning.** ZWJ/ZWNJ glyphs are removed after substitution, so pair adjustment sees the neighbours next to each other. - **ignore-marks-pair, below-mark-offset.** Malayalam i/ii: a pair lookup with IgnoreMarks does not apply to a second glyph the GDEF table calls a mark (OpenType lookup flags); the reference applies it. Gujarati u/uu/vocalic r: the font's contextual `dist`/`blwm` single positioning applies in ours and not in the reference. - **sara-am.** The nikhahit goes in front of the tone mark; the reference leaves them overlapping. - **kannada-conjunct.** We form the font's conjunct and conjunct + matra ligatures; the reference breaks the ligature. - **bengali-form, contextual-form, prebase-reorder.** The font's contextual variants or the ordering follow the font's tables and the documents in ours; the reference skips them. Some members were not derived from a rule in the documents, so treat those as unproven and not as known errors of the reference. A font with the older v1 script tags only is shaped through the v1 tags; the v1-specific behaviors of older engines are not reproduced. ## Callers that split text into runs themselves A layout that cuts a paragraph into runs by direction, face or style, and keeps its own cluster numbers, uses the code point entry points. They give the same results the clustered API is built on. - **`TextShaping.ShapeCodePoints(face, codePoints, clusters, output, rightToLeft, options, ref script)`** shapes one run and appends `CodePointGlyph` values (glyph id, the caller's cluster value, the first code point, advance, offsets) in the order the shaper produced them. `script` is the script of the text before the run, as an OpenType script tag, 0 at the start of a paragraph: characters with no script of their own (spaces, digits, punctuation) take it, and it is updated to the script the run ends in. Pass the same variable along the paragraph. With the Noto bidi test subset, `123` after Arabic shapes to glyphs 1853, 2232, 2215, and 18, 19, 20 when the script starts fresh. Values that are not scalar values are shaped as U+FFFD. - **`TextShaping.ShapeCodePoints(face, codePoints, clusters, result, rightToLeft, options, ref script)`** with a `CodePointRun` as `result` gives exactly the same glyphs and the same carried script, but replaces what `result` held instead of appending, and keeps its array between calls: once it has grown to the longest run, a call allocates nothing. Read `result.Glyphs` (or `result[i]`) before the next call; one `CodePointRun` per thread. If the call throws, `result` is left empty. The list overload shares the same shaping path. - **`ShapedRun.Origins`** holds, for each glyph of a run shaped by `ShapeRun`, the first code point it stands for, the offset of its cluster and `ShaperIndex`, its position in the shaper's order. Indic shapers move glyphs, and the run lists clusters in logical order; `ShaperIndex` tells where each glyph was before that. - **Bidi on code points.** `TextShaping.ResolveBidiLevels(codePoints, direction, levels)` returns the paragraph level and writes one level per code point; characters removed by rule X9 (LRE, RLE, LRO, RLO, PDF and boundary neutrals) get `TextShaping.BidiRemoved` (255) where `GetBidiLevels` gives them the previous level. `ResetTrailingWhitespace(codePoints, levels, paragraphLevel)` applies rule L1 to one line (trailing white space and isolate controls, and white space before separators, go back to the paragraph level). `GetVisualOrder(levels, order)` skips removed entries and returns how many characters are shown. - **Segmentation fills.** `FillLineBreakOpportunities` and `FillGraphemeStarts` write one byte per UTF-16 offset (`text.Length + 1` entries); `IsEastAsianWide(codePoint)` is the East_Asian_Width Fullwidth, Wide or Halfwidth test. - **Properties.** `UnicodeProperties.GetJoiningKind` is the Joining_Type; `IsSyllableScript` is true for scripts shaped in reordering syllables (Indic scripts, Khmer, Myanmar, Tibetan and the other Universal Shaping Engine scripts), false for Thai, Lao and everything else. - **Raw tables.** `FontFace.GetTable("COLR")` gives the bytes of a table as stored, empty when the font has none (`HasTable` tells an empty table from a missing one). The bytes are not validated. ## Line breaking and graphemes `TextSegmentation.GetLineBreakOpportunities` is the Unicode line breaking algorithm, UAX #14, Unicode 17.0 data, default rules as written, with the small kana of class CJ treated as NS (the annex's strict reading). All of `LineBreakTest.txt` and `GraphemeBreakTest.txt` pass; the tests read the unmodified files from `tests/testdata/unicode`. - **What breaks.** After spaces, hyphens and dashes, around the soft hyphen, after the zero width space, between ideographs and kana but not before closing punctuation or small kana, after a solidus before a letter, between emoji sequences and flag pairs but never inside one, and after hard breaks. It does not break in "$1,234.56", "-5.25", "12.5%", inside a word, after an opening bracket or quote even across spaces, before a closing bracket or quote, around the no-break space or the word joiner, or inside a Korean syllable block. - **Thai, Lao, Khmer, Myanmar.** UAX #14 without dictionaries: their letters are class SA and are treated as letters, so there is an opportunity only at spaces, punctuation and explicit zero width spaces. - **Grapheme clusters.** `GetGraphemeBoundaries` and `GetGraphemeLength` follow UAX #29 extended grapheme clusters (combining marks, emoji ZWJ sequences and modifiers, flags, Hangul jamo and syllables, Indic conjuncts). ## Fallback `FontFallbackChain` picks, per grapheme cluster, the first face with a glyph for the cluster's first character, shapes each stretch of one face with that face's own tables, and keeps characters that no face covers on the first face (its missing-glyph box). Invisible characters (controls, zero width spaces and joiners, soft hyphens, bidi controls) stay with the face of the cluster before them. ## Fonts TrueType (`glyf`), OpenType with CFF (CFF1) outlines, and TrueType Collections; `cmap` (a Unicode subtable is required), `head`, `hhea`, `hmtx`, `maxp`, `OS/2`, `name`, `post`, `kern`. Not read: WOFF, WOFF2, CFF2, variations (the default instance is used), colour tables, hinting instructions, vertical metrics. Bounds: at most 256 faces per collection, 256 tables per face, 128 MiB per table, 8,192 cached outlines per face, 2^18 segments per outline. Every offset and length from the font is range-checked. # Changelog All notable changes are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] ## [1.3.0] - 2026-10-24 - Added a `TextShaping.ShapeCodePoints` overload that fills a reusable `CodePointRun` (`Count`, `Glyphs`, indexer) instead of a `List`: the same glyphs and carried script, no allocation per call once the run has grown. The list overload gives identical output and now shares the code. ## [1.2.0] - 2026-10-24 - Added `TextShaping.ShapeCodePoints`: shapes a run given as code points with caller-supplied cluster values and a script carried from run to run (`ref uint script`), so digits and spaces after Arabic take the Arabic script. It returns `CodePointGlyph` values in the shaper's own order, each with its code point and cluster. - Added `ShapedRun.Origins` (`ShapedGlyphOrigin`): for every glyph of a run the first code point it stands for, its cluster offset and its position in the shaper's order before clusters were put in logical order. - Added code point bidi: `TextShaping.ResolveBidiLevels` (keeps the rule X9 marker `TextShaping.BidiRemoved` instead of filling it) and `TextShaping.ResetTrailingWhitespace` (rule L1 per line). `GetVisualOrder(levels, order)` accepts levels with the marker and returns how many characters are shown. - Added `TextSegmentation.IsEastAsianWide`, `FillLineBreakOpportunities` and `FillGraphemeStarts` (one byte per UTF-16 offset), and `UnicodeProperties.GetJoiningKind` / `IsSyllableScript`. - Added `FontFace.GetTable` and `HasTable`: the raw bytes of any table (`COLR`, `CPAL`, `vhea`, `vmtx`, `VORG`, `fpgm`, `prep`, `cvt ` and so on). - Existing behaviour and output are unchanged; the 1.0.0 digests still match. ## [1.1.0] - 2026-10-24 - Added `ShapedRun`, `ShapedClusterRange` and the `TextShaping.ShapeRun(text, face, result, ...)` and `FontFallbackChain.ShapeRun(text, result, ...)` overloads: the same clusters and glyphs as the list API in flat, reusable arrays, with no allocation per call once warm. The list API is built on the same path and allocates under a third as much as before (about 75 bytes per character instead of about 270 for Latin). - Shaping reuses glyph buffers, lookup engines and syllable work arrays per thread, and `ShapingOptions` caches its feature set. Output is unchanged: the tests compare against digests made by 1.0.0. - Added buffer overloads `TextShaping.GetBidiLevels(text, direction, levels, out paragraphLevel)`, `TextShaping.GetVisualOrder(levels, order)` and `TextSegmentation.GetLineBreakOpportunities(text, opportunities)`. ## [1.0.0] - 2026-10-23 - First version. Fonts: TrueType, OpenType with CFF outlines, TrueType Collections. Shaping: GSUB and GPOS with every lookup type and flag; Arabic, Indic, Universal Shaping Engine, Thai and Lao shapers. Unicode 17: bidi levels and order (UAX #9), line break opportunities (UAX #14), grapheme boundaries (UAX #29). Font fallback chain. Glyph outlines through a sink. - `TextShaping.ShapeRun` returns clusters that start at offset 0 and follow each other to the end of the text, even when the shaper drops characters (a joiner before an Indic letter). [1.3.0]: https://github.com/recursive-ai-oss/onefont-csharp/releases/tag/v1.3.0 [1.2.0]: https://github.com/recursive-ai-oss/onefont-csharp/releases/tag/v1.2.0 [1.1.0]: https://github.com/recursive-ai-oss/onefont-csharp/releases/tag/v1.1.0 [1.0.0]: https://github.com/recursive-ai-oss/onefont-csharp/releases/tag/v1.0.0 # Read a TTF or OTF font file Read a font’s names, style, metrics and tables in C# from a TTF, OTF or TTC file, on any platform, and tell TrueType outlines from CFF. `FontFace.Load` reads TrueType fonts, OpenType fonts with CFF outlines, and TrueType Collections from a byte array. It doesn’t need the font to be installed, and it doesn’t use System.Drawing or any operating system font API. ```csharp using OneFont; string[] files = ["NotoSans-Regular.ttf", "NotoSansCJK-Regular.ttc", "KaTeX_Main-Regular.woff2"]; string[] tables = ["glyf", "CFF ", "GSUB", "GPOS", "kern", "COLR", "fvar"]; foreach (string file in files) { byte[] data = File.ReadAllBytes(file); try { int faces = FontFace.GetFaceCount(data); FontFace face = FontFace.Load(data); // face 0; pass faceIndex for the others in a collection Console.WriteLine($"{file}: {faces} face(s)"); Console.WriteLine($" family \"{face.FamilyName}\", style \"{face.SubfamilyName}\", full \"{face.FullName}\""); string outlines = face.IsCff ? "CFF (cubic)" : "TrueType (quadratic)"; Console.WriteLine($" weight {face.Weight}, bold {face.IsBold}, italic {face.IsItalic}, outlines {outlines}"); FontMetrics m = face.Metrics; Console.WriteLine($" {m.UnitsPerEm} units/em, ascender {m.Ascender}, descender {m.Descender}"); Console.WriteLine($" x-height {m.XHeight}, cap height {m.CapHeight}, line gap {m.LineGap}"); Console.WriteLine($" tables: {string.Join(", ", tables.Where(face.HasTable).Select(t => t.Trim()))}"); Console.WriteLine($" name table: {face.GetTable("name").Length} bytes"); if (faces > 1) { var names = Enumerable.Range(0, faces).Select(i => FontFace.Load(data, i).FamilyName); Console.WriteLine($" all faces: {string.Join(", ", names)}"); } } catch (FontFormatException e) { Console.WriteLine($"{file}: not readable: {e.Message}"); } } ``` ```text NotoSans-Regular.ttf: 1 face(s) family "Noto Sans", style "Regular", full "Noto Sans Regular" weight 400, bold False, italic False, outlines TrueType (quadratic) 1000 units/em, ascender 1069, descender -293 x-height 536, cap height 714, line gap 0 tables: glyf, GSUB, GPOS name table: 1660 bytes NotoSansCJK-Regular.ttc: 10 face(s) family "Noto Sans CJK JP", style "Regular", full "Noto Sans CJK JP" weight 400, bold False, italic False, outlines CFF (cubic) 1000 units/em, ascender 1160, descender -288 x-height 543, cap height 733, line gap 0 tables: CFF, GSUB, GPOS name table: 2146 bytes all faces: Noto Sans CJK JP, Noto Sans CJK KR, Noto Sans CJK SC, Noto Sans CJK TC, Noto Sans CJK HK, Noto Sans Mono CJK JP, Noto Sans Mono CJK KR, Noto Sans Mono CJK SC, Noto Sans Mono CJK TC, Noto Sans Mono CJK HK KaTeX_Main-Regular.woff2: not readable: Not an sfnt font: unknown version tag. ``` - **Names.** `FamilyName` is the typographic family when the font has one, else the legacy family. `SubfamilyName` is the style, and `FullName` and `PostScriptName` are `null` when the font has none. - **Style.** `Weight` is the OS/2 weight class, 100 to 900. `IsBold` comes from the OS/2 selection flags, the `head` style bits or a weight of 600 or more, and `IsItalic` covers oblique faces too. - **TrueType or CFF.** The `.ttf` and `.otf` extensions don’t decide it: the outlines do. `IsCff` is true for cubic CFF outlines and false for quadratic TrueType (`glyf`) outlines. Both kinds can carry the OpenType layout tables `GSUB` and `GPOS`, and onefont shapes with them either way. - **Metrics.** `Metrics` holds the ascender, descender, line gap, x-height, cap height, and the underline and strikeout positions, all in design units. Divide by `UnitsPerEm` and multiply by the font size. - **Raw tables.** `HasTable` and `GetTable` reach any table by its four-character tag, as stored in the file and not interpreted. A tag shorter than four characters is padded with spaces, so `"CFF"` finds `CFF `. - **Collections.** `GetFaceCount` returns the number of faces (at most 256), and `Load(data, faceIndex)` reads one of them. - **Errors.** A file that isn’t a font, or is damaged or truncated, throws `FontFormatException`. WOFF and WOFF2 are compressed wrappers that onefont doesn’t unpack, so the KaTeX `.woff2` above is refused. CFF2 fonts aren’t read either. A face keeps a reference to the byte array and reads tables from it on demand, so leave the array unchanged while the face is in use. Faces are immutable and safe to use from several threads. The first steps with a font are in [Getting started](https://recursiveai.org/onefont/docs/getting-started/). # Check if a font supports a character Find out in C# whether a font has a glyph for a character, list the characters it lacks, and fall back to other fonts cluster by cluster. A font supports a character when its Unicode character map gives it a glyph. `HasGlyph` answers that for one code point. For text with several scripts, a `FontFallbackChain` picks a face for each cluster and shapes it with that face’s own tables. ```csharp using System.Text; using OneFont; FontFace noto = FontFace.Load(File.ReadAllBytes("NotoSans-Regular.ttf")); FontFace dejavu = FontFace.Load(File.ReadAllBytes("DejaVuSans.ttf")); FontFace cjk = FontFace.Load(File.ReadAllBytes("NotoSansCJK-Regular.ttc"), faceIndex: 0); string text = "Café ✓ 東京 😀 क"; // One character: HasGlyph takes a code point, so surrogate pairs (emoji) need Rune, not char. Console.WriteLine($"Noto Sans has 'é': {noto.HasGlyph('é')}, has '✓': {noto.HasGlyph(0x2713)}"); // Every character a font lacks. var missing = text.EnumerateRunes().Where(r => !Rune.IsWhiteSpace(r) && !noto.HasGlyph(r.Value)); Console.WriteLine($"Noto Sans lacks: {string.Join(" ", missing.Select(r => $"U+{r.Value:X4}"))}"); // Pick a face for each character, then shape through the chain. var chain = new FontFallbackChain([noto, dejavu, cjk]); Console.WriteLine($"'✓' comes from face {chain.SelectFace(0x2713)}, '東' from face {chain.SelectFace('東')}"); foreach (ShapedCluster c in chain.ShapeRun(text).Where(c => !char.IsWhiteSpace(text[c.Start]))) { string note = c.HasMissingGlyph ? ", missing: no face has it" : ""; Console.WriteLine($" {text.Substring(c.Start, c.Length)} {chain.Faces[c.FaceIndex].FamilyName}{note}"); } ``` ```text Noto Sans has 'é': True, has '✓': False Noto Sans lacks: U+2713 U+6771 U+4EAC U+1F600 U+0915 '✓' comes from face 1, '東' from face 2 C Noto Sans a Noto Sans f Noto Sans é Noto Sans ✓ DejaVu Sans 東 Noto Sans CJK JP 京 Noto Sans CJK JP 😀 DejaVu Sans क Noto Sans, missing: no face has it ``` - `HasGlyph` and `GetGlyphIndex` take a code point (an `int`), not a `char`. Characters outside the Basic Multilingual Plane, such as most emoji, are two `char`s in a .NET string, so walk the text with `EnumerateRunes`. - Glyph 0 is the font’s missing-glyph box. `HasGlyph` is true when the character map gives any other glyph. - `SelectFace` returns the index of the first face in the chain with a glyph for a code point, or 0 when no face has one. - `ShapeRun` on the chain chooses a face for each grapheme cluster by its first character. Invisible characters (controls, zero width spaces and joiners, soft hyphens, bidi controls) take the face of the cluster before them. Each stretch of one face is shaped with that face’s GSUB, GPOS and GDEF tables. - A character no face has stays with the first face, and its cluster has `HasMissingGlyph` set. Here none of the three fonts has Devanagari. - DejaVu Sans has an outline glyph for 😀, so the chain uses it. onefont doesn’t draw color glyphs: a color emoji font’s `COLR` or bitmap tables are available only as raw bytes. - A chain holds at most 32 faces. A glyph in the character map is not the whole story for complex scripts: a font can have every letter of a script and still lack the conjuncts or contextual forms a word needs. For those, see [Shape complex scripts](https://recursiveai.org/onefont/cookbook/complex-scripts/). # Measure and wrap text 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. ```csharp 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 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; ``` ```text "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.** `Advance` on a cluster is the sum of its glyphs’ advances in design units, including pair kerning with the cluster that follows. Multiply by `size / UnitsPerEm`. In Noto Serif at 16 px, kerning takes “Wave” from 43.58 to 42.46 px. - **Break opportunities.** `GetLineBreakOpportunities` gives one entry per position, `text.Length + 1` in all: entry `i` says whether a line may end before `text[i]`. That covers spaces, hyphens (“well-” above), dashes, and the rules for East Asian text, numbers and punctuation in UAX #14. `Mandatory` marks 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.LineHeight` is 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](https://recursiveai.org/onefont/cookbook/right-to-left-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](https://recursiveai.org/onefont/cookbook/font-supports-character/) the same way. To lay out and draw paragraphs on images, [OneImage.Text](https://recursiveai.org/oneimage/docs/text/) does all of this on top of onefont. # Convert text to SVG paths Turn a string into SVG path data in C#: shape it with the font’s ligatures and kerning, then write each glyph’s outline through a sink that emits SVG commands. A glyph outline is a list of figures made of line, quadratic and cubic segments in design units, with y pointing up. `GlyphOutline.Write` replays them into any `IOutlineSink`, through a transform. A sink that appends SVG commands gives path data a browser can draw. ```csharp using System.Globalization; using System.Numerics; using System.Text; using OneFont; FontFace face = FontFace.Load(File.ReadAllBytes("NotoSerif-Regular.ttf")); string text = "Shaped office"; float size = 48, s = size / face.UnitsPerEm; float baseline = face.Metrics.Ascender * s; // Shape first, so kerning and the ffi ligature are in the paths, then walk the pen along the glyphs. var sink = new SvgPathSink(); float pen = 0; foreach (ShapedCluster cluster in TextShaping.ShapeRun(text, face)) { foreach (ShapedGlyph g in cluster.Glyphs) { // Font units have y up; SVG has y down. Flip, scale and move to the pen position. var transform = new Matrix3x2(s, 0, 0, -s, pen + g.XOffset * s, baseline - g.YOffset * s); face.GetOutline(g.GlyphId).Write(sink, transform); pen += g.XAdvance * s; } } float height = face.Metrics.LineHeight * s; string svg = $""" """; File.WriteAllText("shaped.svg", svg); Console.WriteLine($"shaped.svg: {F(pen)} x {F(height)} px, path data {sink.ToString().Length} characters"); Console.WriteLine(sink.ToString()[..72] + "..."); static string F(float v) => SvgPathSink.F(v); // TrueType outlines arrive as quadratic segments (Q), CFF outlines as cubic ones (C). sealed class SvgPathSink : IOutlineSink { private readonly StringBuilder _d = new(); public void MoveTo(float x, float y) => _d.Append($"M{F(x)} {F(y)}"); public void LineTo(float x, float y) => _d.Append($"L{F(x)} {F(y)}"); public void QuadTo(float cx, float cy, float x, float y) => _d.Append($"Q{F(cx)} {F(cy)} {F(x)} {F(y)}"); public void CubicTo(float c1x, float c1y, float c2x, float c2y, float x, float y) => _d.Append($"C{F(c1x)} {F(c1y)} {F(c2x)} {F(c2y)} {F(x)} {F(y)}"); public void Close() => _d.Append('Z'); public override string ToString() => _d.ToString(); public static string F(float v) => v.ToString("0.##", CultureInfo.InvariantCulture); } ``` ```text shaped.svg: 305.81 x 65.38 px, path data 6971 characters M11.86 51.79Q7.15 51.79 4.73 49.92Q2.3 48.05 2.3 45.02Q2.3 43.58 3.22 42... ``` The SVG shows “Shaped office” in Noto Serif at 48 px, with “ffi” drawn as the font’s ligature. - **Shape first.** Mapping characters straight to glyphs would miss the ligature, kerning, Arabic joining forms and Indic reordering. Walk the shaped glyphs and advance the pen by each `XAdvance`. - **The transform.** `Matrix3x2(s, 0, 0, -s, x, baseline)` scales design units by `s = size / UnitsPerEm`, flips y so it points down as in SVG, and moves the glyph to the pen position and baseline. Add the glyph’s `XOffset` and subtract its `YOffset`, which place marks. - **Segments.** TrueType fonts give quadratic segments (`QuadTo`, SVG `Q`), CFF fonts cubic ones (`CubicTo`, SVG `C`). `Close` ends a figure. - **Empty glyphs.** A space has an empty outline (`IsEmpty`), and writing it emits nothing, but its advance still moves the pen. - **Bounds.** `TightBox()` gives the exact extents of an outline’s curves in design units, and `ControlBox()` the box around all its points, control points included. Use them to crop or align a glyph. - **Right-to-left runs.** A right-to-left run’s clusters come in logical order. Walk them last to first to place them from left to right. Outlines are unhinted, and a variable font gives its default instance. Color glyphs (`COLR`) aren’t interpreted. Outlines are cached by the face and safe to read from several threads. A glyph with damaged data throws `FontFormatException` when its outline is read. To fill and stroke text on images rather than export paths, use [OneImage.Text](https://recursiveai.org/oneimage/docs/text/). # Right-to-left and mixed-direction text Lay out Arabic, Hebrew and mixed-direction text in C#: resolve the Unicode bidi levels, cut the paragraph into runs, put the runs in display order, and shape each one in its direction. Text is stored in logical order, the order it is typed and read. On screen, right-to-left letters run from right to left, and numbers and Latin words inside them keep left-to-right order. The Unicode Bidirectional Algorithm (UAX #9) decides the direction of every character. onefont implements it on Unicode 17 data and passes the official `BidiTest.txt` and `BidiCharacterTest.txt` in full. ```csharp using OneFont; var chain = new FontFallbackChain([ FontFace.Load(File.ReadAllBytes("NotoSans-Regular.ttf")), FontFace.Load(File.ReadAllBytes("NotoSansHebrew-Regular.ttf")), FontFace.Load(File.ReadAllBytes("NotoSansArabic-Regular.ttf")), ]); Show("The word שלום means peace."); Show("في عام 2026 قرأت 12 كتابا."); // "In 2026 I read 12 books." void Show(string text) { // 1. Bidi levels, one per UTF-16 unit. Odd levels are right to left. byte[] levels = TextShaping.GetBidiLevels(text, TextDirection.Auto, out int paragraphLevel); // 2. Cut the paragraph into runs of one level. var runs = new List<(int Start, int End, byte Level)>(); for (int i = 0, start = 0; i <= text.Length; i++) { if (i == text.Length || levels[i] != levels[start]) { runs.Add((start, i, levels[start])); start = i; } } // 3. Display order of the runs (rule L2), from their levels. int[] visual = TextShaping.GetVisualOrder(runs.Select(r => r.Level).ToArray()); // 4. Shape each run in its direction and place the runs left to right. Console.WriteLine($"{(paragraphLevel == 1 ? "RTL" : "LTR")} paragraph, {runs.Count} runs, left to right on screen:"); float x = 0; foreach (int r in visual) { (int start, int end, byte level) = runs[r]; bool rtl = (level & 1) == 1; IReadOnlyList clusters = chain.ShapeRun(text.AsSpan(start, end - start), rightToLeft: rtl); float width = clusters.Sum(c => c.Advance * 16f / chain.Faces[c.FaceIndex].UnitsPerEm); // Clusters come in logical order: draw a right-to-left run's clusters last to first. Console.WriteLine($" x={x,6:F2} level {level} {(rtl ? "RTL" : "LTR")} \"{text[start..end]}\""); x += width; } } ``` ```text LTR paragraph, 3 runs, left to right on screen: x= 0.00 level 0 LTR "The word " x= 74.51 level 1 RTL "שלום" x=110.14 level 0 LTR " means peace." RTL paragraph, 5 runs, left to right on screen: x= 0.00 level 1 RTL " كتابا." x= 34.62 level 2 LTR "12" x= 52.93 level 1 RTL " قرأت " x= 94.38 level 2 LTR "2026" x=130.99 level 1 RTL "في عام " ``` - **Levels.** `GetBidiLevels` returns one embedding level per UTF-16 code unit. Even levels are left to right, odd levels right to left. `TextDirection.Auto` takes the paragraph’s direction from its first strong character, so the English sentence is a left-to-right paragraph and the Arabic one a right-to-left paragraph. `LeftToRight` and `RightToLeft` force it. - **Runs.** A run is a stretch of one level. In the Arabic sentence the numbers 2026 and 12 are level 2, left to right inside the right-to-left paragraph. - **Display order.** `GetVisualOrder` applies rule L2 to a list of levels and returns the indexes in display order, left to right. It works on levels per character or per run, as here. In the Arabic paragraph the end of the sentence comes first on the left. - **Shaping.** Each run is shaped on its own with `rightToLeft` set from its level, so Arabic letters take their joined forms. A run’s clusters come back in logical order: draw a right-to-left run’s clusters last to first. - **Across fonts.** The runs here go through a `FontFallbackChain`, so English, Hebrew and Arabic each come from a font that has them. For a paragraph you wrap, run the algorithm once for the paragraph, choose the line breaks, then reorder each line on its own. Rule L1, which resets trailing white space per line, is on the code point API: `TextShaping.ResolveBidiLevels` and `TextShaping.ResetTrailingWhitespace`. Explicit embeddings, overrides and isolates (U+202A to U+202E, U+2066 to U+2069) are honored up to depth 125. ## Mirrored brackets In right-to-left text, brackets and other paired characters are drawn mirrored, so a logical “(” shows the glyph of “)”. The shaper does this by code point, unless the font has `rtlm` lookups for the script, in which case it leaves mirroring to them. ```csharp using OneFont; // In a right-to-left run, "(" should be drawn with the glyph of ")". foreach (string file in new[] { "DejaVuSans.ttf", "NotoSans-Regular.ttf" }) { FontFace face = FontFace.Load(File.ReadAllBytes(file)); int first = TextShaping.ShapeRun("(\u05D0)", face, rightToLeft: true)[0].Glyphs[0].GlyphId; int open = face.GetGlyphIndex('('), close = face.GetGlyphIndex(')'); Console.WriteLine($"{file}: \"(\" is glyph {open}, \")\" is {close}; shaped right to left: {first}"); } ``` ```text DejaVuSans.ttf: "(" is glyph 11, ")" is 12; shaped right to left: 12 NotoSans-Regular.ttf: "(" is glyph 11, ")" is 12; shaped right to left: 11 ``` In 1.3.0 that rule misses fonts whose `rtlm` lookups cover only a few glyphs. Noto Sans’s `rtlm` maps a single math symbol, so its parentheses aren’t mirrored, while DejaVu Sans, which has no `rtlm`, is mirrored by code point. With such a font, swap the mirrored characters yourself before shaping a right-to-left run. Where onefont stops: it doesn’t split paragraphs, choose line breaks or draw. The rules it follows, and the places it differs from SixLabors.Fonts, are in [Shaping, bidi and segmentation](https://recursiveai.org/onefont/docs/shaping/). # Turn on OpenType features Switch OpenType features such as small caps, figure styles, fractions and ligatures on or off in C#, and choose a language’s local forms, with ShapingOptions. Fonts carry optional features as GSUB and GPOS lookups under four-letter tags. onefont applies the defaults for horizontal text on every run. `ShapingOptions` adds or removes features and picks the language whose forms the font should use. ```csharp using OneFont; FontFace face = FontFace.Load(File.ReadAllBytes("NotoSerif-Regular.ttf")); Show("office", new ShapingOptions()); // liga is on by default Show("office", With(FontFeature.Off("liga"))); Show("office", With(FontFeature.On("smcp"))); // small caps run before liga: no ligature Show("1/2", With(FontFeature.On("frac"))); Show("1110", new ShapingOptions()); // this font's default figures are tabular Show("1110", With(FontFeature.On("pnum"))); // proportional: each digit its own width Show("1110", With(FontFeature.On("onum"))); // old-style figures Show("ş", new ShapingOptions()); Show("ş", new ShapingOptions { Language = "ro" }); // Romanian locl: s with comma below Console.WriteLine($"cmap: ş is glyph {face.GetGlyphIndex('ş')}, ș is glyph {face.GetGlyphIndex('ș')}"); static ShapingOptions With(FontFeature feature) { var options = new ShapingOptions(); options.Features.Add(feature); return options; } void Show(string text, ShapingOptions options) { var clusters = TextShaping.ShapeRun(text, face, options: options); ShapedGlyph[] glyphs = clusters.SelectMany(c => c.Glyphs.ToArray()).ToArray(); string settings = string.Join(" ", options.Features) + (options.Language is null ? "" : $"lang={options.Language}"); string ids = string.Join(" ", glyphs.Select(g => g.GlyphId)); string advances = string.Join(" ", glyphs.Select(g => g.XAdvance)); Console.WriteLine($"{text,-7} {(settings == "" ? "defaults" : settings),-9} glyphs [{ids}] advances [{advances}]"); } ``` ```text office defaults glyphs [82 2132 70 72] advances [577 1002 492 535] office liga=0 glyphs [82 73 73 76 70 72] advances [577 369 369 320 492 535] office smcp=1 glyphs [2207 2170 2170 2179 2150 2160] advances [631 504 504 330 537 535] 1/2 frac=1 glyphs [2530 534 2521] advances [400 62 400] 1110 defaults glyphs [20 20 20 19] advances [559 559 559 559] 1110 pnum=1 glyphs [2490 2490 2490 2489] advances [459 459 459 579] 1110 onum=1 glyphs [2510 2510 2510 2509] advances [579 579 579 579] ş defaults glyphs [288] advances [451] ş lang=ro glyphs [329] advances [451] cmap: ş is glyph 288, ș is glyph 329 ``` - **Defaults.** Every run gets `ccmp`, `locl`, `rlig`, `liga`, `clig`, `calt`, `rclt`, `kern`, `mark`, `mkmk` and `curs`, plus the script’s own features for Arabic, Indic and other complex scripts. So “office” has the ffi ligature without asking. - **On and off.** `FontFeature.On("smcp")` and `FontFeature.Off("liga")` go into `Features`. `new FontFeature("salt", 2)` picks the second alternate. Later settings replace earlier ones for the same tag. A tag the font doesn’t have does nothing. - **Order.** Lookups run in the font’s lookup-list order, as the OpenType specification says: `ccmp` and `locl` first, then everything else. With `smcp` the letters become small capitals before `liga` looks for “ffi”, so no ligature forms. SixLabors.Fonts ligates first. - **Figures.** Noto Serif’s default figures are tabular, every digit 559 units wide. `pnum` gives proportional figures and `onum` old-style ones. `frac` turns “1/2” into a numerator, a fraction slash and a denominator. - **Kerning.** `Kerning = KerningMode.None` turns pair kerning off. It is the same as `FontFeature.Off("kern")`, and an explicit `kern` setting wins over it. - **Language.** `Language` takes a BCP 47 tag such as `ro`, `tr` or `sr`, or an OpenType language tag in capitals such as `ROM`. Here Romanian `locl` replaces ş (s with cedilla) with the font’s ș (s with comma below). A language the font has no forms for uses the script’s defaults. A feature applies to the whole run: feature ranges inside one string aren’t supported, so shape text with different settings as separate runs. One `ShapingOptions` can be shared by any number of calls and threads as long as nobody changes it while they run. It caches the feature set it builds. The full feature order, including the Arabic and Indic stages, is in [Shaping, bidi and segmentation](https://recursiveai.org/onefont/docs/shaping/). What the font formats actually contain: [OpenType vs TrueType](https://recursiveai.org/onefont/articles/opentype-vs-truetype/). # Shape complex scripts Shape Hindi, other Indic scripts and Thai in C# with onefont: syllables, conjuncts, reordered vowel signs and stacked marks, and which character each glyph came from. In complex scripts the glyphs on screen aren’t one per character in typing order. A vowel sign can be drawn before its consonant, two consonants can merge into a conjunct, and marks stack above and below. onefont chooses a script shaper from the text and runs the font’s features in the order each script needs. ```csharp using OneFont; FontFace hindi = FontFace.Load(File.ReadAllBytes("NotoSansDevanagari-Regular.ttf")); FontFace thai = FontFace.Load(File.ReadAllBytes("NotoSansThai-Regular.ttf")); var run = new ShapedRun(); Show("हिंदी", hindi); // "Hindi": the vowel sign ि is drawn before its consonant Show("क्षत्र", hindi); // conjuncts: क्ष and त्र Show("ि", hindi); // a vowel sign with no consonant gets a dotted circle Show("น้ำ", thai); // "water": SARA AM splits, NIKHAHIT moves before the tone mark void Show(string text, FontFace face) { TextShaping.ShapeRun(text, face, run); Console.WriteLine($"{text}: {run.ClusterCount} cluster(s), {run.GlyphCount} glyphs"); // Origins: for each glyph, the first code point it stands for. Glyphs are in drawing order. for (int i = 0; i < run.GlyphCount; i++) { ShapedGlyph g = run.Glyphs[i]; int cp = run.Origins[i].CodePoint; string from = $"U+{cp:X4} {char.ConvertFromUtf32(cp)}"; Console.WriteLine($" glyph {g.GlyphId,4} from {from} advance {g.XAdvance,3} offset {g.XOffset},{g.YOffset}"); } } ``` ```text हिंदी: 2 cluster(s), 5 glyphs glyph 619 from U+093F ि advance 259 offset 0,0 glyph 61 from U+0939 ह advance 531 offset 0,0 glyph 652 from U+0902 ं advance 0 offset 0,0 glyph 42 from U+0926 द advance 520 offset 0,0 glyph 653 from U+0940 ी advance 259 offset 0,0 क्षत्र: 2 cluster(s), 2 glyphs glyph 179 from U+0915 क advance 712 offset 0,0 glyph 269 from U+0924 त advance 552 offset 0,0 ि: 1 cluster(s), 2 glyphs glyph 67 from U+093F ि advance 259 offset 0,0 glyph 134 from U+25CC ◌ advance 510 offset 0,0 น้ำ: 1 cluster(s), 4 glyphs glyph 71 from U+0E19 น advance 613 offset 0,0 glyph 59 from U+0E4D ํ advance 0 offset 0,0 glyph 49 from U+0E49 ้ advance 0 offset -29,0 glyph 86 from U+0E32 า advance 406 offset 0,0 ``` - **Reordering.** In “हिंदी” the vowel sign ि (U+093F) is typed after ह but drawn before it. `ShapedRun.Origins` gives the first code point each glyph stands for, so you can see the move. - **Conjuncts.** “क्षत्र” is six characters and two glyphs: the font’s क्ष and त्र conjuncts. Each glyph’s origin is the first consonant of its conjunct. - **Broken clusters.** A vowel sign with no consonant gets a dotted circle (U+25CC) as its base, and the pre-base sign moves in front of the circle as it would in front of a consonant. - **Thai.** In “น้ำ” the SARA AM (U+0E33) is split into NIKHAHIT and SARA AA, and the nikhahit moves in front of the tone mark. The font’s positioning gives the tone mark an advance of 0 and an x offset of −29 units. - **Clusters.** A syllable’s characters share one cluster. Place each cluster as a whole, and don’t break a line or move a caret inside one. The shapers: | Shaper | Scripts | |---|---| | Arabic | Arabic, Syriac, N’Ko | | Indic | Devanagari, Bengali, Gurmukhi, Gujarati, Oriya, Tamil, Telugu, Kannada, Malayalam | | Universal Shaping Engine | Khmer, Myanmar, Sinhala, Tibetan, Balinese, Javanese and the other scripts of the USE list | | Thai and Lao | Thai, Lao | The Indic shaper uses a font’s v2 script tags (`dev2`, `bng2`…) first and the v1 tags otherwise. A syllable is cut after 48 glyphs, and runs over 4,096 glyphs are shaped in pieces cut at syllable boundaries, so cost stays linear. Some outputs differ from SixLabors.Fonts on purpose, case by case: the list is in [Shaping, bidi and segmentation](https://recursiveai.org/onefont/docs/shaping/). Not done in 1.3.0: Syriac Alaph forms (`fin2`, `fin3`, `med2` selection), canonical reordering of combining marks, Apple `morx` and `kerx` tables, and vertical layout. A longer guide, compared with HarfBuzzSharp: [Text shaping in C#](https://recursiveai.org/onefont/articles/text-shaping-csharp/). # Find caret positions and hit-test text 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. ```csharp 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 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(); 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..]}"); } ``` ```text 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 `GDEF` ligature 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](https://recursiveai.org/onefont/cookbook/right-to-left-text/). For several lines, hit-test the line first by y, then within it. # Shape many runs without allocating 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. ```csharp 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](https://recursiveai.org/onefont/docs/shaping/).