Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7430c35
docs(playground): add WASM playground design and implementation plan
RoboNET Mar 9, 2026
2a5f2a0
feat(playground): scaffold wasmbrowser project with FlexRender depend…
RoboNET Mar 9, 2026
4b93e27
feat(playground): add MemoryResourceLoader for browser file uploads
RoboNET Mar 9, 2026
56812d9
feat(playground): add PlaygroundApi with JSExport render/validate/loa…
RoboNET Mar 9, 2026
314c409
feat(playground): add minimal HTML/JS shell with WASM render test
RoboNET Mar 9, 2026
fd7aefb
feat(playground): add Monaco Editor UI with debounced live preview
RoboNET Mar 9, 2026
89911ec
fix(playground): resolve WASM rendering — SkiaSharp native linking, f…
RoboNET Mar 9, 2026
ff83e9a
feat(playground): add unified LoadResource/RemoveResource/ListResourc…
RoboNET Mar 10, 2026
446082b
feat(playground): add VFS module with IndexedDB persistence
RoboNET Mar 10, 2026
72be3f0
feat(playground): add splitter module for resizable panels
RoboNET Mar 10, 2026
2266b1c
feat(playground): add Files panel and splitter markup to HTML
RoboNET Mar 10, 2026
7a45aea
feat(playground): add CSS for splitters, file tree, context menu, col…
RoboNET Mar 10, 2026
0b9339c
feat(playground): wire VFS tree UI, context menu, drag & drop, splitt…
RoboNET Mar 10, 2026
af6a9e1
feat(playground): add WASM font support with embedded Inter-Regular a…
RoboNET Mar 10, 2026
c2e9f6b
feat(playground): add project system, VFS autocomplete, ZIP export/im…
RoboNET Mar 10, 2026
22af127
fix(wasm): enable LinearMetrics for consistent text measurement acros…
RoboNET Mar 10, 2026
ca989f6
feat(playground): add layout diagnostics and update text_styled snapshot
RoboNET Mar 10, 2026
97e4e17
chore: remove design/plan docs from branch
RoboNET Mar 10, 2026
77d630f
fix(playground): add Inter font to VFS and fix Data Binding each temp…
RoboNET Mar 10, 2026
38daecc
fix: address all code review issues from PR #8
RoboNET Mar 10, 2026
99a0050
fix(wasm): WASM-safe font handling, cleanup PlaygroundApi, strip alph…
RoboNET Mar 10, 2026
cd2c7ca
chore(playground): remove debug console logs from render loop
RoboNET Mar 10, 2026
fbf1fa4
fix: update NDC snapshot golden images for LinearMetrics change
RoboNET Mar 10, 2026
181d92a
Revert "fix: update NDC snapshot golden images for LinearMetrics change"
RoboNET Mar 10, 2026
dd42ffc
fix(fonts): trigger lazy loading before _fileLoadedTypefaces check in…
RoboNET Mar 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/playground.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Playground

on:
push:
branches: [main]
paths:
- 'src/FlexRender.Playground/**'
- 'src/FlexRender.Core/**'
- 'src/FlexRender.Yaml/**'
- 'src/FlexRender.Skia.Render/**'
- 'src/FlexRender.Content.Ndc/**'
- '.github/workflows/playground.yml'
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: false

jobs:
deploy:
runs-on: ubuntu-latest

environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}

steps:
- uses: actions/checkout@v4
with:
lfs: true

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'

- name: Install wasm-tools workload
run: dotnet workload install wasm-tools

- name: Publish Playground
run: dotnet publish src/FlexRender.Playground -c Release -o publish

- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: publish/wwwroot

- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,5 @@ docs/figma-wb-receipt-v2-metadata.txt
.DS_Store
Thumbs.db
docs/plans
publish
node_modules
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,15 @@ FlexRender-Marketplace/

**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.

## WASM Playground Testing

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.

**Key WASM constraints:**
- `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`
- Guard all system font calls with `!OperatingSystem.IsBrowser()`
- Never dispose shared typefaces in `RegisterFont` -- they may be referenced by variant caches

## Common Tasks

### Add new element type
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
<PackageVersion Include="SkiaSharp" Version="3.119.2" />
<PackageVersion Include="SkiaSharp.NativeAssets.WebAssembly" Version="3.119.2" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.2" />
<PackageVersion Include="SkiaSharp.NativeAssets.macOS" Version="3.119.2" />
<PackageVersion Include="SkiaSharp.NativeAssets.Win32" Version="3.119.2" />
Expand Down
3 changes: 3 additions & 0 deletions FlexRender.slnx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
<Solution>
<Folder Name="/playground/">
<Project Path="src/FlexRender.Playground/FlexRender.Playground.csproj" />
</Folder>
<Folder Name="/src/">
<Project Path="src/FlexRender.Core/FlexRender.Core.csproj" />
<Project Path="src/FlexRender.DependencyInjection/FlexRender.DependencyInjection.csproj" />
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

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.

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

## Features

- **YAML Templates** -- define complex image layouts in readable YAML format
Expand Down
169 changes: 169 additions & 0 deletions docs/wiki/Font-Loading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# Font Loading

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.

## Overview

`FontManager` is the central class responsible for all font operations in the Skia rendering backend. It manages:

- **Registration** -- mapping logical font names to file paths and optional system-font fallbacks.
- **Loading** -- reading `.ttf`/`.otf` files from disk or from `IResourceLoader` implementations (for WASM, embedded resources, HTTP, etc.).
- **Caching** -- storing loaded `SKTypeface` instances in thread-safe concurrent dictionaries so each font is loaded at most once.
- **Variant resolution** -- finding the best weight/style match (Bold, Italic, SemiBold, etc.) for a registered font family.
- **Disposal** -- deterministic cleanup of all native Skia typeface handles, including orphaned typefaces from re-registration.

### Thread Safety

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.

## Font Resolution Priority

When a template element requests a font, resolution follows a strict priority order depending on the lookup method.

### By Registered Name (`GetTypeface(fontName)`)

```
1. Registered file path --> File on disk --> SKTypeface.FromFile() --> Cache
2. Registered file path --> Resource loaders (WASM) --> SKTypeface.FromStream() --> Cache
3. Registered fallback name --> System font lookup (SKTypeface.FromFamilyName)
4. Default fallback ("Arial") --> SKTypeface.FromFamilyName
5. SKTypeface.Default (built-in blank typeface)
6. [WASM only] Any previously file-loaded typeface as last resort
```

### By Family Name (`GetTypefaceByFamily(familyName, weight, style)`)

```
1. Scan registered file-loaded fonts by FamilyName metadata
a. Exact family match + Normal weight/style --> return immediately
b. Exact family match + variant --> delegate to variant resolution
2. System font manager (SKFontManager.Default.MatchFamily) [desktop only]
- Only accepted if FamilyName matches AND weight is within 100 units
3. Best registered match (even if weight is off)
4. Fallback to "main" font
```

### Variant Resolution (`GetTypeface(fontName, weight, style)`)

When a non-default weight or style is requested:

```
1. Fast path: Normal weight + Normal style --> delegates to base GetTypeface(fontName)
2. Search file-loaded typefaces with matching FamilyName + weight (within 100 units) + slant
3. System font manager (MatchFamily with SKFontStyle) [desktop only]
4. Sibling file scan: enumerate .ttf/.otf files in the same directory [desktop only]
- Match by FamilyName, weight (within 100 units), and slant
- Dispose rejected candidates immediately to prevent leaks
5. Fall back to the base typeface (Regular weight)
```

### Four-Parameter Overload (`GetTypeface(fontName, fontFamily, weight, style)`)

This is the primary entry point used by the rendering engine:

```
1. If fontName is NOT "main" and NOT empty --> resolve by registered name + variant
2. Else if fontFamily is NOT empty --> resolve by family name
3. Else --> resolve "main" font by registered name + variant
```

## Registration

### Template-Based Registration

The `TemplatePreprocessor.RegisterFontsAsync` method processes the `fonts:` section of a YAML template:

```yaml
fonts:
default: "assets/fonts/Inter-Regular.ttf"
bold: "assets/fonts/Inter-Bold.ttf"
mono: "assets/fonts/JetBrainsMono-Regular.ttf"
```

For each font entry:

1. The path is resolved against `FlexRenderOptions.BasePath` (if set) or the current directory.
2. `RegisterFont(name, resolvedPath, fallback)` is called.
3. If the file does NOT exist on disk, `PreloadFontFromResourcesAsync` is called to try resource loaders.
4. If the resolved path fails with resource loaders, the original (unresolved) path is tried as a fallback.

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.

### Programmatic Registration

```csharp
fontManager.RegisterFont("heading", "/fonts/Inter-Bold.ttf", fallback: "Arial");
```

Parameters:
- **name** -- logical name used in templates (`font: heading`).
- **path** -- absolute or relative path to the `.ttf`/`.otf` file.
- **fallback** -- optional system font family name used when the file is missing.

Returns `true` if the file exists on disk at registration time; `false` otherwise (the font may still load later via resource loaders).

### Pre-loading from Resource Loaders

```csharp
await fontManager.PreloadFontFromResourcesAsync("my-font", "fonts/MyFont.ttf");
```

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.

## Re-Registration and Deferred Disposal

Calling `RegisterFont` with the same name a second time:

1. Updates the file path mapping.
2. Removes the old typeface from the base cache (`_typefaces`).
3. Clears ALL entries from the variant cache (`_variantTypefaces`) because variants may reference the old typeface.
4. Adds the removed typeface to an **orphaned typefaces** bag.

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()`.

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.

## WASM Constraints

When `OperatingSystem.IsBrowser()` returns `true`, several code paths are disabled:

| Feature | Desktop | WASM |
|---------|---------|------|
| System font lookup (`SKTypeface.FromFamilyName`) | Yes | **No** -- returns objects with invalid native handles |
| Sibling file scan (`Directory.EnumerateFiles`) | Yes | **No** -- no local file system |
| `SKFontManager.Default.MatchFamily` | Yes | **No** -- same invalid handle issue |
| `SKTypeface.Default` | Reliable | **May have invalid handle** |
| `FamilyName`/`IsFixedPitch` on system typefaces | Safe | **Crashes with RuntimeError** |

### File-Loaded Tracking

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.

### WASM Fallback Chain

When all resolution paths fail in WASM:

1. Try to return any previously file-loaded typeface (`GetAnyFileLoadedTypeface()`).
2. Fall back to `SKTypeface.Default` (may be blank/broken but avoids null).

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.

## Font Size Parsing

`FontManager.ParseFontSize` handles CSS-like size strings:

| Format | Example | Resolution |
|--------|---------|------------|
| Bare number | `"16"` | 16 pixels |
| `px` suffix | `"48px"` | 48 pixels |
| `em` suffix | `"1.5em"` | 1.5 x base font size |
| `%` suffix | `"50%"` | 50% of parent size (or base size when equal) |
| Invalid/empty | `""`, `"abc"` | Returns base font size |

## Diagnostic API

| Method | Returns | Purpose |
|--------|---------|---------|
| `IsFileLoaded(name)` | `bool` | Whether the font was loaded from a real file/resource (safe to inspect in WASM) |
| `GetTypefaceInfo(name)` | `(FamilyName, IsFixedPitch)?` | Font metadata; `null` if not file-loaded |
| `RegisteredFontPaths` | `IReadOnlyDictionary<string, string>` | Snapshot of all registered name-to-path mappings |
2 changes: 2 additions & 0 deletions docs/wiki/Getting-Started.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

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

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

## Installation

### All-in-one (recommended)
Expand Down
2 changes: 2 additions & 0 deletions docs/wiki/Home.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

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.

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

## Why FlexRender?

- **YAML-first** -- define complex image layouts in readable YAML, no design tools needed
Expand Down
8 changes: 5 additions & 3 deletions src/FlexRender.Core/Configuration/FlexRenderBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,14 @@ public sealed class FlexRenderBuilder

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

/// <summary>
/// Gets the configured filter registry, or <c>null</c> if no filters have been registered.
Expand Down
1 change: 1 addition & 0 deletions src/FlexRender.Core/Layout/IntrinsicMeasurer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ private IntrinsicSize MeasureTextIntrinsic(TextElement text)
contentWidth = !string.IsNullOrEmpty(text.Width.Value)
? ParseAbsolutePixelValue(text.Width.Value, shaped.TotalSize.Width)
: shaped.TotalSize.Width;

contentHeight = !string.IsNullOrEmpty(text.Height.Value)
? ParseAbsolutePixelValue(text.Height.Value, shaped.TotalSize.Height)
: shaped.TotalSize.Height;
Expand Down
14 changes: 14 additions & 0 deletions src/FlexRender.Core/Layout/LayoutDiagnostics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace FlexRender.Layout;

/// <summary>
/// Diagnostic data attached to a <see cref="LayoutNode"/> for debugging text layout.
/// </summary>
/// <param name="IntrinsicWidth">Intrinsic width from IntrinsicMeasurer (before scaling).</param>
/// <param name="ShapedWidth">Shaped width from TextShaper at final font size.</param>
/// <param name="ContentWidth">Final content width used in layout calculation.</param>
/// <param name="ResolvedTypeface">Resolved typeface family name.</param>
public sealed record LayoutDiagnostics(
float IntrinsicWidth,
float ShapedWidth,
float ContentWidth,
string? ResolvedTypeface = null);
15 changes: 15 additions & 0 deletions src/FlexRender.Core/Layout/LayoutEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ public LayoutEngine(ResourceLimits limits)
/// </summary>
public ITextShaper? TextShaper { get; set; }

/// <summary>
/// When true, populates <see cref="LayoutNode.Diagnostics"/> with text measurement
/// details (intrinsic width, shaped width, content width). Defaults to false.
/// </summary>
public bool EnableDiagnostics { get; set; }

/// <summary>
/// Base font size in pixels used for em resolution and as fallback when text elements
/// don't specify an explicit size. Must match the renderer's base font size for
Expand Down Expand Up @@ -481,6 +487,8 @@ private LayoutNode LayoutTextElement(TextElement text, LayoutContext context)
var border = BorderParser.Resolve(text, context.ContainerWidth, context.FontSize);

float contentWidth;
var diagIntrinsicW = 0f;
var diagShapedW = 0f;
if (!string.IsNullOrEmpty(text.Width.Value))
{
contentWidth = context.ResolveWidth(text.Width.Value) ?? context.ContainerWidth;
Expand All @@ -490,6 +498,7 @@ private LayoutNode LayoutTextElement(TextElement text, LayoutContext context)
&& intrinsic.MaxWidth > 0)
{
contentWidth = intrinsic.MaxWidth;
diagIntrinsicW = intrinsic.MaxWidth;
}
else
{
Expand Down Expand Up @@ -518,6 +527,7 @@ private LayoutNode LayoutTextElement(TextElement text, LayoutContext context)
? Math.Min(contentWidth, context.ContainerWidth)
: float.MaxValue;
var shaped = TextShaper.ShapeText(text, fontSize, measureWidth);
diagShapedW = shaped.TotalSize.Width;
textLines = shaped.Lines;
computedLineHeight = shaped.LineHeight;
textBaseline = shaped.Baseline;
Expand All @@ -534,6 +544,7 @@ private LayoutNode LayoutTextElement(TextElement text, LayoutContext context)
? Math.Min(contentWidth, context.ContainerWidth)
: float.MaxValue;
var shaped = TextShaper.ShapeText(text, fontSize, measureWidth);
diagShapedW = shaped.TotalSize.Width;
contentHeight = shaped.TotalSize.Height;
textLines = shaped.Lines;
computedLineHeight = shaped.LineHeight;
Expand Down Expand Up @@ -582,6 +593,10 @@ private LayoutNode LayoutTextElement(TextElement text, LayoutContext context)
node.ComputedLineHeight = computedLineHeight;
node.Baseline = padding.Top + border.Top.Width + textBaseline;
node.ComputedFontSize = resolvedFontSize;
if (EnableDiagnostics)
{
node.Diagnostics = new LayoutDiagnostics(diagIntrinsicW, diagShapedW, contentWidth);
}
return node;
}

Expand Down
Loading