|
| 1 | +# Font Loading |
| 2 | + |
| 3 | +This page documents how FlexRender resolves, loads, and caches fonts at render time. Understanding the font pipeline is important when working with custom fonts, WASM deployments, or when debugging missing-glyph issues. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +`FontManager` is the central class responsible for all font operations in the Skia rendering backend. It manages: |
| 8 | + |
| 9 | +- **Registration** -- mapping logical font names to file paths and optional system-font fallbacks. |
| 10 | +- **Loading** -- reading `.ttf`/`.otf` files from disk or from `IResourceLoader` implementations (for WASM, embedded resources, HTTP, etc.). |
| 11 | +- **Caching** -- storing loaded `SKTypeface` instances in thread-safe concurrent dictionaries so each font is loaded at most once. |
| 12 | +- **Variant resolution** -- finding the best weight/style match (Bold, Italic, SemiBold, etc.) for a registered font family. |
| 13 | +- **Disposal** -- deterministic cleanup of all native Skia typeface handles, including orphaned typefaces from re-registration. |
| 14 | + |
| 15 | +### Thread Safety |
| 16 | + |
| 17 | +All caches use `ConcurrentDictionary` with `GetOrAdd` for atomic, lock-free access. `FontManager` is safe to use from multiple threads and multiple render calls concurrently. The `Dispose()` method should only be called once, after all rendering is complete. |
| 18 | + |
| 19 | +## Font Resolution Priority |
| 20 | + |
| 21 | +When a template element requests a font, resolution follows a strict priority order depending on the lookup method. |
| 22 | + |
| 23 | +### By Registered Name (`GetTypeface(fontName)`) |
| 24 | + |
| 25 | +``` |
| 26 | +1. Registered file path --> File on disk --> SKTypeface.FromFile() --> Cache |
| 27 | +2. Registered file path --> Resource loaders (WASM) --> SKTypeface.FromStream() --> Cache |
| 28 | +3. Registered fallback name --> System font lookup (SKTypeface.FromFamilyName) |
| 29 | +4. Default fallback ("Arial") --> SKTypeface.FromFamilyName |
| 30 | +5. SKTypeface.Default (built-in blank typeface) |
| 31 | +6. [WASM only] Any previously file-loaded typeface as last resort |
| 32 | +``` |
| 33 | + |
| 34 | +### By Family Name (`GetTypefaceByFamily(familyName, weight, style)`) |
| 35 | + |
| 36 | +``` |
| 37 | +1. Scan registered file-loaded fonts by FamilyName metadata |
| 38 | + a. Exact family match + Normal weight/style --> return immediately |
| 39 | + b. Exact family match + variant --> delegate to variant resolution |
| 40 | +2. System font manager (SKFontManager.Default.MatchFamily) [desktop only] |
| 41 | + - Only accepted if FamilyName matches AND weight is within 100 units |
| 42 | +3. Best registered match (even if weight is off) |
| 43 | +4. Fallback to "main" font |
| 44 | +``` |
| 45 | + |
| 46 | +### Variant Resolution (`GetTypeface(fontName, weight, style)`) |
| 47 | + |
| 48 | +When a non-default weight or style is requested: |
| 49 | + |
| 50 | +``` |
| 51 | +1. Fast path: Normal weight + Normal style --> delegates to base GetTypeface(fontName) |
| 52 | +2. Search file-loaded typefaces with matching FamilyName + weight (within 100 units) + slant |
| 53 | +3. System font manager (MatchFamily with SKFontStyle) [desktop only] |
| 54 | +4. Sibling file scan: enumerate .ttf/.otf files in the same directory [desktop only] |
| 55 | + - Match by FamilyName, weight (within 100 units), and slant |
| 56 | + - Dispose rejected candidates immediately to prevent leaks |
| 57 | +5. Fall back to the base typeface (Regular weight) |
| 58 | +``` |
| 59 | + |
| 60 | +### Four-Parameter Overload (`GetTypeface(fontName, fontFamily, weight, style)`) |
| 61 | + |
| 62 | +This is the primary entry point used by the rendering engine: |
| 63 | + |
| 64 | +``` |
| 65 | +1. If fontName is NOT "main" and NOT empty --> resolve by registered name + variant |
| 66 | +2. Else if fontFamily is NOT empty --> resolve by family name |
| 67 | +3. Else --> resolve "main" font by registered name + variant |
| 68 | +``` |
| 69 | + |
| 70 | +## Registration |
| 71 | + |
| 72 | +### Template-Based Registration |
| 73 | + |
| 74 | +The `TemplatePreprocessor.RegisterFontsAsync` method processes the `fonts:` section of a YAML template: |
| 75 | + |
| 76 | +```yaml |
| 77 | +fonts: |
| 78 | + default: "assets/fonts/Inter-Regular.ttf" |
| 79 | + bold: "assets/fonts/Inter-Bold.ttf" |
| 80 | + mono: "assets/fonts/JetBrainsMono-Regular.ttf" |
| 81 | +``` |
| 82 | +
|
| 83 | +For each font entry: |
| 84 | +
|
| 85 | +1. The path is resolved against `FlexRenderOptions.BasePath` (if set) or the current directory. |
| 86 | +2. `RegisterFont(name, resolvedPath, fallback)` is called. |
| 87 | +3. If the file does NOT exist on disk, `PreloadFontFromResourcesAsync` is called to try resource loaders. |
| 88 | +4. If the resolved path fails with resource loaders, the original (unresolved) path is tried as a fallback. |
| 89 | + |
| 90 | +The special font name `"default"` is automatically registered as both `"default"` and `"main"`, making it the fallback for all text elements without an explicit `font:` property. |
| 91 | + |
| 92 | +### Programmatic Registration |
| 93 | + |
| 94 | +```csharp |
| 95 | +fontManager.RegisterFont("heading", "/fonts/Inter-Bold.ttf", fallback: "Arial"); |
| 96 | +``` |
| 97 | + |
| 98 | +Parameters: |
| 99 | +- **name** -- logical name used in templates (`font: heading`). |
| 100 | +- **path** -- absolute or relative path to the `.ttf`/`.otf` file. |
| 101 | +- **fallback** -- optional system font family name used when the file is missing. |
| 102 | + |
| 103 | +Returns `true` if the file exists on disk at registration time; `false` otherwise (the font may still load later via resource loaders). |
| 104 | + |
| 105 | +### Pre-loading from Resource Loaders |
| 106 | + |
| 107 | +```csharp |
| 108 | +await fontManager.PreloadFontFromResourcesAsync("my-font", "fonts/MyFont.ttf"); |
| 109 | +``` |
| 110 | + |
| 111 | +Iterates resource loaders in priority order. The first loader that returns a valid stream wins. The loaded typeface is cached directly, bypassing the lazy file-load path. This is the recommended approach for WASM where the file system is unavailable. |
| 112 | + |
| 113 | +## Re-Registration and Deferred Disposal |
| 114 | + |
| 115 | +Calling `RegisterFont` with the same name a second time: |
| 116 | + |
| 117 | +1. Updates the file path mapping. |
| 118 | +2. Removes the old typeface from the base cache (`_typefaces`). |
| 119 | +3. Clears ALL entries from the variant cache (`_variantTypefaces`) because variants may reference the old typeface. |
| 120 | +4. Adds the removed typeface to an **orphaned typefaces** bag. |
| 121 | + |
| 122 | +Orphaned typefaces cannot be disposed immediately because they may still be referenced by variant cache entries at the moment of removal (race condition window with concurrent reads). Instead, they are collected in a `ConcurrentBag` and disposed during `FontManager.Dispose()`. |
| 123 | + |
| 124 | +The `Dispose()` method uses a `HashSet<SKTypeface>` with `ReferenceEqualityComparer` to deduplicate typefaces that appear in multiple caches (e.g., a base typeface that is also returned as its own Normal-weight variant). Each native handle is disposed exactly once. |
| 125 | + |
| 126 | +## WASM Constraints |
| 127 | + |
| 128 | +When `OperatingSystem.IsBrowser()` returns `true`, several code paths are disabled: |
| 129 | + |
| 130 | +| Feature | Desktop | WASM | |
| 131 | +|---------|---------|------| |
| 132 | +| System font lookup (`SKTypeface.FromFamilyName`) | Yes | **No** -- returns objects with invalid native handles | |
| 133 | +| Sibling file scan (`Directory.EnumerateFiles`) | Yes | **No** -- no local file system | |
| 134 | +| `SKFontManager.Default.MatchFamily` | Yes | **No** -- same invalid handle issue | |
| 135 | +| `SKTypeface.Default` | Reliable | **May have invalid handle** | |
| 136 | +| `FamilyName`/`IsFixedPitch` on system typefaces | Safe | **Crashes with RuntimeError** | |
| 137 | + |
| 138 | +### File-Loaded Tracking |
| 139 | + |
| 140 | +The `_fileLoadedTypefaces` dictionary tracks which fonts were loaded from real files or resource loaders. Only these typefaces are safe to inspect for native properties (`FamilyName`, `IsFixedPitch`, `FontStyle`). The `IsFileLoaded(name)` and `GetTypefaceInfo(name)` methods use this tracking to prevent WASM crashes. |
| 141 | + |
| 142 | +### WASM Fallback Chain |
| 143 | + |
| 144 | +When all resolution paths fail in WASM: |
| 145 | + |
| 146 | +1. Try to return any previously file-loaded typeface (`GetAnyFileLoadedTypeface()`). |
| 147 | +2. Fall back to `SKTypeface.Default` (may be blank/broken but avoids null). |
| 148 | + |
| 149 | +For WASM deployments, **always** pre-load fonts via resource loaders before rendering. Without pre-loaded fonts, text will render with the built-in blank typeface or fail silently. |
| 150 | + |
| 151 | +## Font Size Parsing |
| 152 | + |
| 153 | +`FontManager.ParseFontSize` handles CSS-like size strings: |
| 154 | + |
| 155 | +| Format | Example | Resolution | |
| 156 | +|--------|---------|------------| |
| 157 | +| Bare number | `"16"` | 16 pixels | |
| 158 | +| `px` suffix | `"48px"` | 48 pixels | |
| 159 | +| `em` suffix | `"1.5em"` | 1.5 x base font size | |
| 160 | +| `%` suffix | `"50%"` | 50% of parent size (or base size when equal) | |
| 161 | +| Invalid/empty | `""`, `"abc"` | Returns base font size | |
| 162 | + |
| 163 | +## Diagnostic API |
| 164 | + |
| 165 | +| Method | Returns | Purpose | |
| 166 | +|--------|---------|---------| |
| 167 | +| `IsFileLoaded(name)` | `bool` | Whether the font was loaded from a real file/resource (safe to inspect in WASM) | |
| 168 | +| `GetTypefaceInfo(name)` | `(FamilyName, IsFixedPitch)?` | Font metadata; `null` if not file-loaded | |
| 169 | +| `RegisteredFontPaths` | `IReadOnlyDictionary<string, string>` | Snapshot of all registered name-to-path mappings | |
0 commit comments