Skip to content

Commit acb2121

Browse files
authored
feat: WASM playground (#8)
1 parent ec8c0b2 commit acb2121

46 files changed

Lines changed: 6299 additions & 86 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/playground.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: Playground
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- 'src/FlexRender.Playground/**'
8+
- 'src/FlexRender.Core/**'
9+
- 'src/FlexRender.Yaml/**'
10+
- 'src/FlexRender.Skia.Render/**'
11+
- 'src/FlexRender.Content.Ndc/**'
12+
- '.github/workflows/playground.yml'
13+
workflow_dispatch:
14+
15+
permissions:
16+
contents: read
17+
pages: write
18+
id-token: write
19+
20+
concurrency:
21+
group: pages
22+
cancel-in-progress: false
23+
24+
jobs:
25+
deploy:
26+
runs-on: ubuntu-latest
27+
28+
environment:
29+
name: github-pages
30+
url: ${{ steps.deployment.outputs.page_url }}
31+
32+
steps:
33+
- uses: actions/checkout@v4
34+
with:
35+
lfs: true
36+
37+
- name: Setup .NET
38+
uses: actions/setup-dotnet@v4
39+
with:
40+
dotnet-version: '10.0.x'
41+
42+
- name: Install wasm-tools workload
43+
run: dotnet workload install wasm-tools
44+
45+
- name: Publish Playground
46+
run: dotnet publish src/FlexRender.Playground -c Release -o publish
47+
48+
- name: Upload Pages artifact
49+
uses: actions/upload-pages-artifact@v3
50+
with:
51+
path: publish/wwwroot
52+
53+
- name: Deploy to GitHub Pages
54+
id: deployment
55+
uses: actions/deploy-pages@v4

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,5 @@ docs/figma-wb-receipt-v2-metadata.txt
5757
.DS_Store
5858
Thumbs.db
5959
docs/plans
60+
publish
61+
node_modules

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,15 @@ FlexRender-Marketplace/
450450

451451
**CI automation:** The `release.yml` workflow automatically creates a PR in the marketplace repo to update `cli-version.json` when a new FlexRender version is released.
452452

453+
## WASM Playground Testing
454+
455+
When debugging or testing the WASM playground (`src/FlexRender.Playground/`), use the **agent-browser** tool to interact with the running playground at `http://localhost:5249/`. This allows inspecting rendered output, checking browser console for errors, and verifying layout diagnostics without relying on the user to manually test.
456+
457+
**Key WASM constraints:**
458+
- `SKTypeface.FromFamilyName()` and `SKFontManager.Default.MatchFamily()` return objects with **invalid native handles** in WASM -- accessing ANY native property (`.FamilyName`, `.FontStyle.Weight`) causes an unrecoverable `RuntimeError: memory access out of bounds`
459+
- Guard all system font calls with `!OperatingSystem.IsBrowser()`
460+
- Never dispose shared typefaces in `RegisterFont` -- they may be referenced by variant caches
461+
453462
## Common Tasks
454463

455464
### Add new element type

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<PackageVersion Include="xunit.v3" Version="3.2.2" />
99
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
1010
<PackageVersion Include="SkiaSharp" Version="3.119.2" />
11+
<PackageVersion Include="SkiaSharp.NativeAssets.WebAssembly" Version="3.119.2" />
1112
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.2" />
1213
<PackageVersion Include="SkiaSharp.NativeAssets.macOS" Version="3.119.2" />
1314
<PackageVersion Include="SkiaSharp.NativeAssets.Win32" Version="3.119.2" />

FlexRender.slnx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
<Solution>
2+
<Folder Name="/playground/">
3+
<Project Path="src/FlexRender.Playground/FlexRender.Playground.csproj" />
4+
</Folder>
25
<Folder Name="/src/">
36
<Project Path="src/FlexRender.Core/FlexRender.Core.csproj" />
47
<Project Path="src/FlexRender.DependencyInjection/FlexRender.DependencyInjection.csproj" />

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
A .NET library for rendering images from YAML templates with a full CSS flexbox layout engine. Perfect for generating receipts, labels, tickets, and structured documents.
99

10+
**[Try it in the browser](https://robonet.github.io/FlexRender/)** -- WASM playground with live preview, no installation required.
11+
1012
## Features
1113

1214
- **YAML Templates** -- define complex image layouts in readable YAML format

docs/wiki/Font-Loading.md

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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 |

docs/wiki/Getting-Started.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
This guide walks you through installing FlexRender, creating your first template, and rendering it using code, dependency injection, or the CLI.
44

5+
> **Want to try without installing?** Use the [browser playground](https://robonet.github.io/FlexRender/) -- edit YAML templates and see results instantly.
6+
57
## Installation
68

79
### All-in-one (recommended)

docs/wiki/Home.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
A modular .NET library for rendering images from YAML templates with a full CSS flexbox layout engine. Render-backend agnostic with SkiaSharp as the default backend. Fully AOT-compatible with zero reflection.
99

10+
**[Try it in the browser](https://robonet.github.io/FlexRender/)** -- WASM playground with live preview, no installation required.
11+
1012
## Why FlexRender?
1113

1214
- **YAML-first** -- define complex image layouts in readable YAML, no design tools needed

src/FlexRender.Core/Configuration/FlexRenderBuilder.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,14 @@ public sealed class FlexRenderBuilder
7070

7171
/// <summary>
7272
/// Gets the list of configured resource loaders.
73+
/// Custom loaders can be inserted before <see cref="Build"/> is called to control
74+
/// resolution priority (lower index = higher priority).
7375
/// </summary>
7476
/// <remarks>
75-
/// Loaders are added lazily when <see cref="Build"/> is called to ensure
76-
/// they receive the fully configured <see cref="Options"/> instance.
77+
/// Built-in loaders (file, base64, embedded) are added lazily when <see cref="Build"/>
78+
/// is called to ensure they receive the fully configured <see cref="Options"/> instance.
7779
/// </remarks>
78-
internal List<IResourceLoader> ResourceLoaders { get; } = [];
80+
public List<IResourceLoader> ResourceLoaders { get; } = [];
7981

8082
/// <summary>
8183
/// Gets the configured filter registry, or <c>null</c> if no filters have been registered.

0 commit comments

Comments
 (0)