From 7430c35df5bcede7e4fca0c5940e72513c1ad28a Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Mon, 9 Mar 2026 18:40:28 +0300 Subject: [PATCH 01/25] docs(playground): add WASM playground design and implementation plan --- .../2026-03-09-wasm-playground-design.md | 227 +++ docs/plans/2026-03-09-wasm-playground-plan.md | 1586 +++++++++++++++++ 2 files changed, 1813 insertions(+) create mode 100644 docs/plans/2026-03-09-wasm-playground-design.md create mode 100644 docs/plans/2026-03-09-wasm-playground-plan.md diff --git a/docs/plans/2026-03-09-wasm-playground-design.md b/docs/plans/2026-03-09-wasm-playground-design.md new file mode 100644 index 0000000..85affba --- /dev/null +++ b/docs/plans/2026-03-09-wasm-playground-design.md @@ -0,0 +1,227 @@ +# FlexRender WASM Playground — Design Document + +## Goal + +A fully client-side, IDE-like browser application for authoring and previewing FlexRender YAML templates. Runs entirely in the browser via .NET WebAssembly — no backend required. + +## Technology Stack + +| Layer | Technology | +|-------|-----------| +| .NET WASM | `wasmbrowser` template, `[JSExport]`/`[JSImport]` interop | +| Rendering | FlexRender.Core + FlexRender.Yaml + FlexRender.Skia.Render | +| Native WASM | SkiaSharp.NativeAssets.WebAssembly | +| Code editor | Monaco Editor + monaco-yaml (JSON Schema autocomplete) | +| Hosting | GitHub Pages (static) | +| CI/CD | GitHub Actions | + +## Architecture + +``` +Browser (Static HTML/JS/CSS) +┌─────────────────────────────────────────────────────────┐ +│ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │ +│ │ Monaco YAML │ │ Monaco JSON │ │ Preview Panel │ │ +│ │ (template) │ │ (data) │ │ PNG/SVG/canvas│ │ +│ └──────┬───────┘ └──────┬───────┘ └───────▲───────┘ │ +│ └────────┬────────┘ │ │ +│ debounce 300ms │ │ +│ ┌────────▼────────────────────────────┘ │ +│ │ Web Worker (.NET WASM) │ +│ │ FlexRender.Core + Yaml + Skia.Render │ +│ │ SkiaSharp.NativeAssets.WebAssembly │ +│ │ │ +│ │ [JSExport] RenderToPng(yaml, json) → byte[] │ +│ │ [JSExport] RenderToSvg(yaml, json) → string │ +│ │ [JSExport] Validate(yaml) → string │ +│ │ [JSExport] DebugLayout(yaml, json) → string │ +│ │ [JSExport] LoadFont(name, data) │ +│ │ [JSExport] LoadImage(path, data) │ +│ │ [JSExport] LoadContent(path, data) │ +│ └────────────────────────────────────────────────┘ +└─────────────────────────────────────────────────────────┘ +``` + +## JSExport API (C# → JS) + +```csharp +partial class PlaygroundApi +{ + // Rendering + [JSExport] + public static byte[] RenderToPng(string yaml, string? dataJson); + + [JSExport] + public static string RenderToSvg(string yaml, string? dataJson); + + // Validation — returns JSON array of error objects + [JSExport] + public static string Validate(string yaml); + + // Debug — returns JSON layout tree + [JSExport] + public static string DebugLayout(string yaml, string? dataJson); + + // Resource loading (drag & drop) + [JSExport] + public static void LoadFont(string name, byte[] data); // .ttf, .otf, .woff2 + + [JSExport] + public static void LoadImage(string path, byte[] data); // .png, .jpg, .svg + + [JSExport] + public static void LoadContent(string path, byte[] data); // .ndc, .txt +} +``` + +All resources are loaded into an in-memory `IResourceLoader` on the WASM side. Templates reference them by filename (e.g., `src: "logo.png"`, `src: "receipt.ndc"`). + +## UI Layout + +``` +┌─────────────────────────────────────────────────────┐ +│ Logo [Examples ▾] [Export PNG] [Export SVG] [☀/🌙] │ +├────────────────────────┬────────────────────────────┤ +│ Monaco YAML Editor │ Preview │ +│ (template.yaml) │ ┌──────────────────────┐ │ +│ │ │ │ │ +│ │ │ Rendered Image │ │ +│ │ │ (zoom/pan) │ │ +│ │ │ │ │ +│ │ └──────────────────────┘ │ +├────────────────────────┤ [Preview] [Layout] [Errors]│ +│ Monaco JSON Editor ├────────────────────────────┤ +│ (data.json) │ Layout tree / Error list │ +│ │ │ +└────────────────────────┴────────────────────────────┘ +│ Ready · 400×600 · Rendered in 45ms · 0 errors │ +└─────────────────────────────────────────────────────┘ +``` + +### Panels + +- **Top bar**: Logo, example gallery dropdown, export buttons (PNG/SVG), light/dark theme toggle +- **Left top**: Monaco YAML editor with monaco-yaml plugin (JSON Schema autocomplete for FlexRender properties) +- **Left bottom**: Monaco JSON editor for template data +- **Right top**: Rendered image preview with zoom (mouse wheel) and pan (drag) +- **Right bottom tabs**: + - Preview (default) — rendered image + - Layout — debug layout tree visualization (from `DebugLayout`) + - Errors — validation errors list (from `Validate`) +- **Status bar**: Render status, canvas dimensions, render time, error count + +### Key interactions + +- **Debounce 300ms**: After the user stops typing, call `Validate()` + `RenderToPng()` via Web Worker +- **Drag & drop**: Drop font files (.ttf/.otf/.woff2), images (.png/.jpg/.svg), or NDC content (.ndc/.txt) anywhere → calls `LoadFont`/`LoadImage`/`LoadContent` → re-renders +- **Example gallery**: Built-in examples from `examples/` directory, loads YAML + JSON into editors on click +- **Zoom/Pan**: CSS transform on ``, mouse wheel for zoom, drag to pan +- **Export**: Download rendered result as PNG or SVG file + +## Monaco YAML Integration + +- **monaco-yaml** plugin provides YAML validation, autocomplete, and hover +- **JSON Schema** generated from `KnownProperties.cs` — covers all element types, properties, enums (FlexDirection, JustifyContent, AlignItems, etc.) +- Schema file: `wwwroot/schemas/flexrender-template.json` +- Autocomplete for: element `type` values, all properties per element type, enum values, CSS-like values + +## Project Structure + +``` +src/FlexRender.Playground/ + FlexRender.Playground.csproj # wasmbrowser SDK project + Program.cs # Entry point + [JSExport] API + PlaygroundApi.cs # Render/Validate/Debug/Load methods + MemoryResourceLoader.cs # In-memory IResourceLoader for drag&drop + wwwroot/ + index.html # Main page + main.js # .NET WASM init + Monaco + UI wiring + style.css # Layout and theming + schemas/ + flexrender-template.json # JSON Schema for autocomplete + examples/ # Built-in example templates + receipt.yaml / receipt.json + card.yaml / card.json + ... +``` + +## .csproj Configuration + +```xml + + + net10.0 + true + true + + + + + + + + + +``` + +## Build & Deploy + +### Local development +```bash +dotnet run --project src/FlexRender.Playground +``` +Launches dev server with hot-reload at `https://localhost:5xxx`. + +### Production build +```bash +dotnet publish src/FlexRender.Playground -c Release +``` +Output: `bin/Release/net10.0/publish/wwwroot/` — static files ready for deployment. + +### Bundle size (estimated) +| Component | Gzip size | +|-----------|-----------| +| .NET WASM runtime | ~3 MB | +| SkiaSharp native WASM | ~2 MB | +| FlexRender assemblies | ~500 KB | +| Monaco Editor (CDN) | 0 (loaded externally) | +| **Total first load** | **~5.5 MB** | + +All assets are cached by the browser after first load. + +### Optimization +- `InvariantGlobalization=true` — removes ICU data (~30% savings) +- `PublishTrimmed=true` — tree-shakes unused code +- Brotli pre-compression in CI for all static files +- Monaco Editor loaded from CDN (not bundled) + +### GitHub Actions CI/CD +- **Trigger**: push to `main` or tag `v*` +- **Steps**: `dotnet publish` → copy `wwwroot/` → deploy to GitHub Pages +- **URL**: `https://robonet.github.io/FlexRender/` (or custom domain) + +## Embedded Fonts + +Two fonts bundled as embedded resources: +- **Inter** (sans-serif) — default for text elements +- **Roboto Mono** (monospace) — for code/receipt templates + +Additional fonts loaded via drag & drop at runtime. + +## Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| SkiaSharp 3.119.2 vs NativeAssets.WebAssembly 3.119.1 version mismatch | Build/runtime failure | Pin both to 3.119.1 or wait for 3.119.2 WASM release | +| Large bundle size (~5.5 MB) | Slow first load | Brotli compression, loading spinner, cache headers | +| HarfBuzz WASM not available | Complex text shaping broken | Start without HarfBuzz, add later if HarfBuzzSharp.NativeAssets.WebAssembly exists | +| Web Worker + .NET WASM interop complexity | Technical risk | Prototype Web Worker integration first as spike | +| YamlDotNet AOT compatibility in WASM | Possible trimming issues | Test early, configure trimmer roots if needed | + +## Out of Scope (Future) + +- URL sharing (gzip+base64 or server-side storage) +- Collaborative editing +- Template marketplace / community gallery +- PWA / offline support +- Mobile-optimized layout diff --git a/docs/plans/2026-03-09-wasm-playground-plan.md b/docs/plans/2026-03-09-wasm-playground-plan.md new file mode 100644 index 0000000..df9bac8 --- /dev/null +++ b/docs/plans/2026-03-09-wasm-playground-plan.md @@ -0,0 +1,1586 @@ +# FlexRender WASM Playground Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a fully client-side browser IDE for authoring and previewing FlexRender YAML templates using .NET WebAssembly. + +**Architecture:** Static site (GitHub Pages) with .NET WASM runtime running FlexRender.Core + Yaml + Skia.Render in a Web Worker. Monaco Editor with monaco-yaml provides YAML editing with autocomplete. No backend. + +**Tech Stack:** .NET 10 wasmbrowser, SkiaSharp.NativeAssets.WebAssembly, Monaco Editor, monaco-yaml, vanilla HTML/JS/CSS + +**Design doc:** `docs/plans/2026-03-09-wasm-playground-design.md` + +--- + +## Task 1: Scaffold wasmbrowser project + +**Files:** +- Create: `src/FlexRender.Playground/FlexRender.Playground.csproj` +- Create: `src/FlexRender.Playground/Program.cs` +- Modify: `FlexRender.slnx` (add project reference) + +**Step 1: Install wasmbrowser template** + +```bash +dotnet new install Microsoft.NET.Runtime.WebAssembly.Templates.net10 +``` + +Expected: Template `wasmbrowser` available. + +**Step 2: Create project** + +```bash +cd src +mkdir FlexRender.Playground +cd FlexRender.Playground +dotnet new wasmbrowser +``` + +**Step 3: Replace generated csproj with FlexRender-specific configuration** + +Replace `FlexRender.Playground.csproj`: + +```xml + + + net10.0 + true + true + + true + + + + + + + + + + + + + + +``` + +**Step 4: Write minimal Program.cs** + +```csharp +using System.Runtime.InteropServices.JavaScript; + +Console.WriteLine("FlexRender Playground loaded"); +``` + +**Step 5: Add project to solution** + +```bash +cd /path/to/SkiaLayout +dotnet sln FlexRender.slnx add src/FlexRender.Playground/FlexRender.Playground.csproj --solution-folder playground +``` + +**Step 6: Verify it builds** + +```bash +dotnet build src/FlexRender.Playground +``` + +Expected: Build succeeds. If SkiaSharp version mismatch, pin SkiaSharp to 3.119.1 in `Directory.Packages.props`. + +**Step 7: Verify it runs** + +```bash +dotnet run --project src/FlexRender.Playground +``` + +Expected: Dev server starts, browser opens, console shows "FlexRender Playground loaded". + +**Step 8: Commit** + +```bash +git add src/FlexRender.Playground/ FlexRender.slnx +git commit -m "feat(playground): scaffold wasmbrowser project with FlexRender dependencies" +``` + +--- + +## Task 2: MemoryResourceLoader + +In-memory resource loader for drag & drop files (fonts, images, NDC content). Follows the `Base64ResourceLoader` pattern. + +**Files:** +- Create: `src/FlexRender.Playground/MemoryResourceLoader.cs` + +**Step 1: Write MemoryResourceLoader** + +```csharp +using FlexRender.Abstractions; + +namespace FlexRender.Playground; + +/// +/// In-memory resource loader for browser-uploaded files (fonts, images, content). +/// Resources are stored by name and served from memory. +/// +internal sealed class MemoryResourceLoader : IResourceLoader +{ + private readonly Dictionary _resources = new(StringComparer.OrdinalIgnoreCase); + + /// + public int Priority => 10; // Highest priority — uploaded files override everything + + /// + public bool CanHandle(string uri) + { + if (string.IsNullOrWhiteSpace(uri)) + return false; + + // Handle any URI that matches a stored resource name + // Strip leading "./" or "/" for matching + var normalized = NormalizePath(uri); + return _resources.ContainsKey(normalized); + } + + /// + public Task Load(string uri, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(uri); + + var normalized = NormalizePath(uri); + + if (_resources.TryGetValue(normalized, out var data)) + { + Stream stream = new MemoryStream(data, writable: false); + return Task.FromResult(stream); + } + + return Task.FromResult(null); + } + + /// + /// Stores a resource in memory, available by name. + /// + public void AddResource(string name, byte[] data) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(data); + + var normalized = NormalizePath(name); + _resources[normalized] = data; + } + + /// + /// Removes a resource from memory. + /// + public bool RemoveResource(string name) + { + var normalized = NormalizePath(name); + return _resources.Remove(normalized); + } + + /// + /// Removes all stored resources. + /// + public void Clear() => _resources.Clear(); + + private static string NormalizePath(string path) + { + if (path.StartsWith("./", StringComparison.Ordinal)) + path = path[2..]; + else if (path.StartsWith("/", StringComparison.Ordinal)) + path = path[1..]; + + return path; + } +} +``` + +**Step 2: Verify build** + +```bash +dotnet build src/FlexRender.Playground +``` + +Expected: Build succeeds. + +**Step 3: Commit** + +```bash +git add src/FlexRender.Playground/MemoryResourceLoader.cs +git commit -m "feat(playground): add MemoryResourceLoader for browser file uploads" +``` + +--- + +## Task 3: PlaygroundApi — JSExport interop + +The C# API surface exposed to JavaScript via `[JSExport]`. + +**Files:** +- Create: `src/FlexRender.Playground/PlaygroundApi.cs` +- Modify: `src/FlexRender.Playground/Program.cs` + +**Step 1: Write PlaygroundApi.cs** + +```csharp +using System.Runtime.InteropServices.JavaScript; +using System.Text.Json; +using FlexRender.Abstractions; +using FlexRender.Configuration; +using FlexRender.Content.Ndc; +using FlexRender.Skia; +using FlexRender.Values; +using FlexRender.Yaml; + +namespace FlexRender.Playground; + +/// +/// Browser-facing API exposed via [JSExport] for the WASM playground. +/// +internal static partial class PlaygroundApi +{ + private static MemoryResourceLoader s_memoryLoader = new(); + private static IFlexRender? s_render; + private static TemplateParser s_parser = new(); + + /// + /// Initializes the FlexRender engine. Must be called once before rendering. + /// + [JSExport] + public static void Initialize() + { + s_memoryLoader = new MemoryResourceLoader(); + + s_render?.Dispose(); + s_render = new FlexRenderBuilder() + .WithResourceLoader(s_memoryLoader) + .WithoutDefaultLoaders() + .WithSkia() + .WithContentParser(new NdcContentParser()) + .Build(); + } + + /// + /// Renders a YAML template to PNG bytes. + /// + /// PNG image as byte array, or empty array on error. + [JSExport] + public static byte[] RenderToPng(string yaml, string? dataJson) + { + try + { + if (s_render is null) + return []; + + var template = s_parser.Parse(yaml); + var data = ParseData(dataJson); + + // JSExport doesn't support async — use sync-over-async + return s_render.RenderToPng(template, data).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Render error: {ex.Message}"); + return []; + } + } + + /// + /// Validates a YAML template and returns errors as JSON array. + /// + /// JSON string: [] on success, [{"message":"...","line":N},...] on errors. + [JSExport] + public static string Validate(string yaml) + { + var errors = new List(); + + try + { + s_parser.Parse(yaml); + } + catch (TemplateParseException ex) + { + errors.Add(new { message = ex.Message, line = ex.Line }); + } + catch (Exception ex) + { + errors.Add(new { message = ex.Message, line = 0 }); + } + + return JsonSerializer.Serialize(errors); + } + + /// + /// Loads a font file into memory for template rendering. + /// + [JSExport] + public static void LoadFont(string name, byte[] data) + { + s_memoryLoader.AddResource(name, data); + } + + /// + /// Loads an image file into memory for template rendering. + /// + [JSExport] + public static void LoadImage(string path, byte[] data) + { + s_memoryLoader.AddResource(path, data); + } + + /// + /// Loads a content file (NDC, etc.) into memory for template rendering. + /// + [JSExport] + public static void LoadContent(string path, byte[] data) + { + s_memoryLoader.AddResource(path, data); + } + + private static ObjectValue? ParseData(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + return TemplateData.FromJson(json); + } +} +``` + +> **Note:** `TemplateData.FromJson` — check if this utility exists. If not, use `JsonSerializer.Deserialize` + conversion to `ObjectValue`. The exact method name may need adjustment based on the actual codebase API. + +**Step 2: Update Program.cs** + +```csharp +using System.Runtime.InteropServices.JavaScript; +using FlexRender.Playground; + +// Initialize the FlexRender engine +PlaygroundApi.Initialize(); + +Console.WriteLine("FlexRender Playground ready"); +``` + +**Step 3: Verify build** + +```bash +dotnet build src/FlexRender.Playground +``` + +Expected: Build succeeds. Fix any missing `using` directives or API mismatches. + +> **Important:** If `FlexRenderBuilder.WithResourceLoader()` doesn't exist as a direct method, check for `AddResourceLoader()` or add the `MemoryResourceLoader` to the builder's loader list. The builder API may need a small extension. Also check if `WithoutDefaultLoaders()` exists — if not, the default `FileResourceLoader` is harmless in WASM (it just won't find local files). + +> **Important:** If `WithContentParser()` doesn't exist on `FlexRenderBuilder`, check how NDC parser is registered (it may need `FlexRenderBuilder` extension from the Content.Ndc package). + +**Step 4: Commit** + +```bash +git add src/FlexRender.Playground/PlaygroundApi.cs src/FlexRender.Playground/Program.cs +git commit -m "feat(playground): add PlaygroundApi with JSExport render/validate/load methods" +``` + +--- + +## Task 4: Minimal HTML + JS shell + +Basic page that loads .NET WASM, renders a hardcoded template, and displays the result. This validates the full WASM pipeline before adding Monaco. + +**Files:** +- Create: `src/FlexRender.Playground/wwwroot/index.html` +- Create: `src/FlexRender.Playground/wwwroot/main.js` + +**Step 1: Write index.html** + +```html + + + + + + FlexRender Playground + + + + + + +
Loading FlexRender WASM runtime...
+
+
+

FlexRender Playground

+

WASM runtime loaded. Minimal test:

+ +

+        
+
Ready
+
+ + +``` + +**Step 2: Write main.js** + +```javascript +import { dotnet } from './_framework/dotnet.js'; + +const { getAssemblyExports, getConfig, runMain } = await dotnet + .withApplicationArguments("start") + .create(); + +const config = getConfig(); +const exports = await getAssemblyExports(config.mainAssemblyName); +const api = exports.FlexRender.Playground.PlaygroundApi; + +await runMain(); + +// Hide loading, show app +document.getElementById('loading').style.display = 'none'; +document.getElementById('app').style.display = 'flex'; + +// Test render with a minimal template +const testYaml = ` +canvas: + width: 300 + height: 100 + background: "#ffffff" +elements: + - type: text + content: "Hello from WASM!" + size: 24 + color: "#333333" + padding: "20" +`; + +const statusEl = document.getElementById('status'); +const errorEl = document.getElementById('error'); +const imgEl = document.getElementById('preview-img'); + +try { + statusEl.textContent = 'Rendering...'; + const start = performance.now(); + + const pngBytes = api.RenderToPng(testYaml, null); + const elapsed = (performance.now() - start).toFixed(0); + + if (pngBytes && pngBytes.length > 0) { + const blob = new Blob([pngBytes], { type: 'image/png' }); + imgEl.src = URL.createObjectURL(blob); + statusEl.textContent = `Rendered in ${elapsed}ms · ${pngBytes.length} bytes`; + } else { + errorEl.textContent = 'Render returned empty result'; + statusEl.textContent = 'Error'; + } +} catch (e) { + errorEl.textContent = e.message || String(e); + statusEl.textContent = 'Error'; +} +``` + +**Step 3: Run and test in browser** + +```bash +dotnet run --project src/FlexRender.Playground +``` + +Expected: Browser opens, shows "Hello from WASM!" rendered as a PNG image. If SkiaSharp WASM fails, this is where we'll discover it and fix. + +> **Troubleshooting:** If SkiaSharp native binding fails, check: +> 1. `SkiaSharp.NativeAssets.WebAssembly` version compatibility +> 2. May need `false` in csproj +> 3. May need `-s ALLOW_MEMORY_GROWTH=1` + +**Step 4: Commit** + +```bash +git add src/FlexRender.Playground/wwwroot/ +git commit -m "feat(playground): add minimal HTML/JS shell with WASM render test" +``` + +--- + +## Task 5: Monaco Editor integration + +Add Monaco Editor with two panes: YAML template editor and JSON data editor. + +**Files:** +- Modify: `src/FlexRender.Playground/wwwroot/index.html` +- Create: `src/FlexRender.Playground/wwwroot/style.css` +- Modify: `src/FlexRender.Playground/wwwroot/main.js` + +**Step 1: Write style.css** + +```css +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: system-ui, -apple-system, sans-serif; + background: #1e1e1e; + color: #d4d4d4; + height: 100vh; + overflow: hidden; +} + +/* Loading screen */ +#loading { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + font-size: 1.2em; + flex-direction: column; + gap: 12px; +} + +#loading .spinner { + width: 32px; + height: 32px; + border: 3px solid #333; + border-top-color: #007acc; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +/* Main app layout */ +#app { + display: none; + flex-direction: column; + height: 100vh; +} + +/* Top bar */ +.toolbar { + display: flex; + align-items: center; + padding: 6px 12px; + background: #2d2d2d; + border-bottom: 1px solid #404040; + gap: 12px; + flex-shrink: 0; +} + +.toolbar h1 { + font-size: 14px; + font-weight: 600; + white-space: nowrap; +} + +.toolbar select, .toolbar button { + background: #3c3c3c; + color: #d4d4d4; + border: 1px solid #555; + padding: 4px 10px; + border-radius: 4px; + font-size: 12px; + cursor: pointer; +} + +.toolbar select:hover, .toolbar button:hover { + background: #4c4c4c; +} + +.toolbar .spacer { flex: 1; } + +/* Main content area */ +.main-content { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Left panel — editors */ +.editor-panel { + display: flex; + flex-direction: column; + width: 50%; + min-width: 300px; + border-right: 1px solid #404040; +} + +.editor-section { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.editor-section + .editor-section { + border-top: 1px solid #404040; +} + +.editor-label { + padding: 4px 12px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #888; + background: #252526; + flex-shrink: 0; +} + +.editor-container { + flex: 1; + overflow: hidden; +} + +/* Right panel — preview */ +.preview-panel { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.preview-tabs { + display: flex; + background: #252526; + border-bottom: 1px solid #404040; + flex-shrink: 0; +} + +.preview-tabs button { + background: none; + color: #888; + border: none; + padding: 6px 16px; + font-size: 12px; + cursor: pointer; + border-bottom: 2px solid transparent; +} + +.preview-tabs button.active { + color: #d4d4d4; + border-bottom-color: #007acc; +} + +.preview-content { + flex: 1; + overflow: auto; + display: flex; + align-items: center; + justify-content: center; + background: #1a1a1a; +} + +.preview-content img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + image-rendering: auto; +} + +.tab-pane { display: none; width: 100%; height: 100%; } +.tab-pane.active { + display: flex; + align-items: center; + justify-content: center; +} + +#errors-pane { + align-items: flex-start; + justify-content: flex-start; + padding: 12px; + font-family: monospace; + font-size: 13px; + color: #f48771; + white-space: pre-wrap; +} + +#layout-pane { + align-items: flex-start; + justify-content: flex-start; + padding: 12px; + font-family: monospace; + font-size: 12px; + overflow: auto; +} + +/* Status bar */ +.status-bar { + display: flex; + align-items: center; + padding: 2px 12px; + background: #007acc; + color: #fff; + font-size: 12px; + flex-shrink: 0; + gap: 16px; +} + +.status-bar.error { background: #c72e2e; } + +/* Drop zone overlay */ +.drop-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 122, 204, 0.2); + border: 3px dashed #007acc; + z-index: 1000; + align-items: center; + justify-content: center; + font-size: 1.5em; + pointer-events: none; +} + +.drop-overlay.visible { display: flex; } +``` + +**Step 2: Rewrite index.html** + +```html + + + + + + FlexRender Playground + + + + + + +
+
+
Loading FlexRender WASM runtime...
+
+ +
+ +
+

FlexRender Playground

+ +
+ + +
+ + +
+ +
+
+
Template (YAML)
+
+
+
+
Data (JSON)
+
+
+
+ + +
+
+ + + +
+
+
+ +
+
+
+
+
+
+ + +
+ Ready +
+
+ + +
+ Drop fonts, images, or content files here +
+ + +``` + +**Step 3: Rewrite main.js with Monaco + debounce rendering** + +```javascript +import { dotnet } from './_framework/dotnet.js'; + +// --- Monaco Editor setup (CDN) --- +const MONACO_CDN = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min'; + +function loadScript(src) { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = src; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); +} + +// --- .NET WASM initialization --- +const { getAssemblyExports, getConfig, runMain } = await dotnet + .withApplicationArguments('start') + .create(); + +const config = getConfig(); +const exports = await getAssemblyExports(config.mainAssemblyName); +const api = exports.FlexRender.Playground.PlaygroundApi; + +await runMain(); + +// --- Load Monaco from CDN --- +window.require = { paths: { vs: `${MONACO_CDN}/vs` } }; +await loadScript(`${MONACO_CDN}/vs/loader.js`); + +await new Promise((resolve) => { + window.require(['vs/editor/editor.main'], resolve); +}); + +const monaco = window.monaco; + +// --- Create editors --- +const defaultYaml = `canvas: + width: 400 + height: 200 + background: "#ffffff" +elements: + - type: text + content: "Hello, FlexRender!" + size: 28 + color: "#333333" + padding: "30" +`; + +const defaultJson = `{}`; + +const yamlEditor = monaco.editor.create(document.getElementById('yaml-editor'), { + value: defaultYaml, + language: 'yaml', + theme: 'vs-dark', + minimap: { enabled: false }, + fontSize: 13, + tabSize: 2, + automaticLayout: true, + scrollBeyondLastLine: false, +}); + +const jsonEditor = monaco.editor.create(document.getElementById('json-editor'), { + value: defaultJson, + language: 'json', + theme: 'vs-dark', + minimap: { enabled: false }, + fontSize: 13, + tabSize: 2, + automaticLayout: true, + scrollBeyondLastLine: false, +}); + +// --- UI elements --- +const statusBar = document.getElementById('status-bar'); +const statusText = document.getElementById('status-text'); +const previewImg = document.getElementById('preview-img'); +const errorsPane = document.getElementById('errors-pane'); +const layoutPane = document.getElementById('layout-pane'); + +// --- Tabs --- +document.querySelectorAll('.preview-tabs button').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.preview-tabs button').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); + btn.classList.add('active'); + document.getElementById(`${btn.dataset.tab}-pane`).classList.add('active'); + }); +}); + +// --- Render function --- +let renderTimeout = null; +let lastObjectUrl = null; + +function render() { + const yaml = yamlEditor.getValue(); + const json = jsonEditor.getValue(); + + statusBar.classList.remove('error'); + statusText.textContent = 'Rendering...'; + + try { + // Validate first + const errorsJson = api.Validate(yaml); + const errors = JSON.parse(errorsJson); + + if (errors.length > 0) { + errorsPane.textContent = errors.map(e => `Line ${e.line}: ${e.message}`).join('\n'); + statusBar.classList.add('error'); + statusText.textContent = `${errors.length} error(s)`; + return; + } + + errorsPane.textContent = ''; + + // Render + const start = performance.now(); + const dataArg = json.trim() === '{}' || json.trim() === '' ? null : json; + const pngBytes = api.RenderToPng(yaml, dataArg); + const elapsed = (performance.now() - start).toFixed(0); + + if (pngBytes && pngBytes.length > 0) { + if (lastObjectUrl) URL.revokeObjectURL(lastObjectUrl); + const blob = new Blob([pngBytes], { type: 'image/png' }); + lastObjectUrl = URL.createObjectURL(blob); + previewImg.src = lastObjectUrl; + statusText.textContent = `Rendered in ${elapsed}ms · ${(pngBytes.length / 1024).toFixed(1)} KB`; + } else { + statusText.textContent = 'Render returned empty result'; + } + } catch (e) { + errorsPane.textContent = e.message || String(e); + statusBar.classList.add('error'); + statusText.textContent = 'Error'; + } +} + +// --- Debounced render on editor changes --- +function scheduleRender() { + clearTimeout(renderTimeout); + renderTimeout = setTimeout(render, 300); +} + +yamlEditor.onDidChangeModelContent(scheduleRender); +jsonEditor.onDidChangeModelContent(scheduleRender); + +// --- Export buttons --- +document.getElementById('btn-export-png').addEventListener('click', () => { + const yaml = yamlEditor.getValue(); + const json = jsonEditor.getValue(); + const dataArg = json.trim() === '{}' || json.trim() === '' ? null : json; + + try { + const pngBytes = api.RenderToPng(yaml, dataArg); + if (pngBytes && pngBytes.length > 0) { + const blob = new Blob([pngBytes], { type: 'image/png' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'flexrender-output.png'; + a.click(); + } + } catch (e) { + alert('Export failed: ' + (e.message || e)); + } +}); + +// --- Drag & drop --- +const dropOverlay = document.getElementById('drop-overlay'); +let dragCounter = 0; + +const FONT_EXTENSIONS = ['.ttf', '.otf', '.woff2']; +const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.svg', '.gif', '.webp']; +const CONTENT_EXTENSIONS = ['.ndc', '.txt']; + +document.addEventListener('dragenter', (e) => { + e.preventDefault(); + dragCounter++; + dropOverlay.classList.add('visible'); +}); + +document.addEventListener('dragleave', (e) => { + e.preventDefault(); + dragCounter--; + if (dragCounter === 0) dropOverlay.classList.remove('visible'); +}); + +document.addEventListener('dragover', (e) => e.preventDefault()); + +document.addEventListener('drop', async (e) => { + e.preventDefault(); + dragCounter = 0; + dropOverlay.classList.remove('visible'); + + for (const file of e.dataTransfer.files) { + const name = file.name.toLowerCase(); + const ext = '.' + name.split('.').pop(); + const buffer = new Uint8Array(await file.arrayBuffer()); + + if (FONT_EXTENSIONS.includes(ext)) { + api.LoadFont(file.name, buffer); + statusText.textContent = `Loaded font: ${file.name}`; + } else if (IMAGE_EXTENSIONS.includes(ext)) { + api.LoadImage(file.name, buffer); + statusText.textContent = `Loaded image: ${file.name}`; + } else if (CONTENT_EXTENSIONS.includes(ext)) { + api.LoadContent(file.name, buffer); + statusText.textContent = `Loaded content: ${file.name}`; + } else { + statusText.textContent = `Unsupported file type: ${ext}`; + continue; + } + + // Re-render with new resources + scheduleRender(); + } +}); + +// --- Show app, trigger initial render --- +document.getElementById('loading').style.display = 'none'; +document.getElementById('app').style.display = 'flex'; +render(); +``` + +**Step 4: Verify in browser** + +```bash +dotnet run --project src/FlexRender.Playground +``` + +Expected: Full IDE layout with YAML editor (left top), JSON editor (left bottom), preview (right). Editing YAML triggers re-render after 300ms debounce. + +**Step 5: Commit** + +```bash +git add src/FlexRender.Playground/wwwroot/ +git commit -m "feat(playground): add Monaco Editor UI with debounced live preview" +``` + +--- + +## Task 6: Example gallery + +Built-in example templates selectable from the dropdown. + +**Files:** +- Modify: `src/FlexRender.Playground/wwwroot/main.js` (add example loading logic) + +**Step 1: Add examples object to main.js** + +Add examples as inline JS objects (avoids file loading complexity). Copy 3-4 representative examples from `examples/` directory: + +```javascript +const EXAMPLES = { + 'Simple Text': { + yaml: `canvas: + width: 400 + height: 150 + background: "#ffffff" +elements: + - type: text + content: "Hello, FlexRender!" + size: 28 + color: "#333333" + padding: "30"`, + json: '{}' + }, + 'Flex Layout': { + yaml: `canvas: + width: 400 + height: 200 + background: "#f5f5f5" +elements: + - type: flex + direction: row + gap: "10" + padding: "20" + children: + - type: flex + background: "#4CAF50" + padding: "20" + grow: 1 + children: + - type: text + content: "Left" + color: "#ffffff" + size: 16 + - type: flex + background: "#2196F3" + padding: "20" + grow: 2 + children: + - type: text + content: "Right (grow: 2)" + color: "#ffffff" + size: 16`, + json: '{}' + }, + 'Data Binding': { + yaml: `canvas: + width: 400 + height: 250 + background: "#ffffff" +elements: + - type: flex + padding: "20" + gap: "8" + children: + - type: text + content: "{{title}}" + size: 24 + color: "#333" + - type: text + content: "By {{author}}" + size: 14 + color: "#888" + - type: each + array: items + children: + - type: text + content: "- {{item}}" + size: 14 + color: "#555"`, + json: `{ + "title": "Shopping List", + "author": "FlexRender", + "items": ["Apples", "Bread", "Milk", "Cheese"] +}` + } +}; +``` + +**Step 2: Populate dropdown and wire up selection** + +Add after editor creation in main.js: + +```javascript +// Populate examples dropdown +const examplesSelect = document.getElementById('examples'); +for (const name of Object.keys(EXAMPLES)) { + const option = document.createElement('option'); + option.value = name; + option.textContent = name; + examplesSelect.appendChild(option); +} + +examplesSelect.addEventListener('change', () => { + const example = EXAMPLES[examplesSelect.value]; + if (example) { + yamlEditor.setValue(example.yaml); + jsonEditor.setValue(example.json); + } +}); +``` + +**Step 3: Test examples in browser** + +```bash +dotnet run --project src/FlexRender.Playground +``` + +Expected: Dropdown shows examples, selecting one loads YAML + JSON and triggers render. + +**Step 4: Commit** + +```bash +git add src/FlexRender.Playground/wwwroot/main.js +git commit -m "feat(playground): add example gallery with built-in templates" +``` + +--- + +## Task 7: monaco-yaml integration with JSON Schema + +Add YAML autocomplete and validation using monaco-yaml + a FlexRender JSON Schema. + +**Files:** +- Create: `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json` +- Modify: `src/FlexRender.Playground/wwwroot/main.js` + +**Step 1: Create JSON Schema for FlexRender templates** + +Based on `KnownProperties.cs`, create a JSON Schema covering element types and their properties. + +Create `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`: + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "FlexRender Template", + "type": "object", + "properties": { + "canvas": { + "type": "object", + "properties": { + "width": { "type": "integer", "description": "Canvas width in pixels" }, + "height": { "type": "integer", "description": "Canvas height in pixels" }, + "background": { "type": "string", "description": "Background color (hex, e.g. #ffffff)" }, + "fixed": { "type": "boolean", "description": "Whether canvas size is fixed" } + }, + "required": ["width", "height"] + }, + "fonts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "family": { "type": "string" }, + "src": { "type": "string" }, + "weight": { "type": ["string", "integer"] }, + "style": { "type": "string", "enum": ["normal", "italic"] } + }, + "required": ["family", "src"] + } + }, + "elements": { + "type": "array", + "items": { "$ref": "#/definitions/element" } + } + }, + "required": ["canvas", "elements"], + "definitions": { + "flexItemProperties": { + "type": "object", + "properties": { + "grow": { "type": "number", "description": "Flex grow factor" }, + "shrink": { "type": "number", "description": "Flex shrink factor" }, + "basis": { "type": "string", "description": "Flex basis (px, %, auto)" }, + "order": { "type": "integer", "description": "Display order" }, + "display": { "type": "string", "enum": ["flex", "none"] }, + "alignSelf": { "type": "string", "enum": ["auto", "start", "center", "end", "stretch", "baseline"] }, + "width": { "type": ["string", "integer"], "description": "Width (px, %, em, auto)" }, + "height": { "type": ["string", "integer"], "description": "Height (px, %, em, auto)" }, + "minWidth": { "type": ["string", "integer"] }, + "maxWidth": { "type": ["string", "integer"] }, + "minHeight": { "type": ["string", "integer"] }, + "maxHeight": { "type": ["string", "integer"] }, + "padding": { "type": ["string", "integer"], "description": "CSS-like padding shorthand" }, + "margin": { "type": ["string", "integer"], "description": "CSS-like margin shorthand (supports auto)" }, + "background": { "type": "string", "description": "Background color or CSS gradient" }, + "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, + "rotate": { "type": "string" }, + "boxShadow": { "type": "string", "description": "offsetX offsetY blurRadius color" }, + "borderRadius": { "type": ["string", "integer"] }, + "position": { "type": "string", "enum": ["static", "relative", "absolute"] }, + "top": { "type": ["string", "integer"] }, + "right": { "type": ["string", "integer"] }, + "bottom": { "type": ["string", "integer"] }, + "left": { "type": ["string", "integer"] }, + "aspectRatio": { "type": "number" } + } + }, + "element": { + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["text", "flex", "image", "qr", "barcode", "separator", "svg", "table", "each", "if", "content"] + } + }, + "allOf": [ + { + "if": { "properties": { "type": { "const": "text" } } }, + "then": { + "properties": { + "content": { "type": "string", "description": "Text content (supports {{expressions}})" }, + "font": { "type": "string" }, + "fontFamily": { "type": "string" }, + "size": { "type": "number", "description": "Font size in pixels" }, + "color": { "type": "string" }, + "align": { "type": "string", "enum": ["left", "center", "right"] }, + "wrap": { "type": "boolean" }, + "overflow": { "type": "string", "enum": ["visible", "hidden", "ellipsis"] }, + "maxLines": { "type": "integer" }, + "lineHeight": { "type": "number" } + } + } + }, + { + "if": { "properties": { "type": { "const": "flex" } } }, + "then": { + "properties": { + "direction": { "type": "string", "enum": ["row", "column", "row-reverse", "column-reverse"] }, + "wrap": { "type": "string", "enum": ["nowrap", "wrap", "wrap-reverse"] }, + "gap": { "type": ["string", "integer"] }, + "columnGap": { "type": ["string", "integer"] }, + "rowGap": { "type": ["string", "integer"] }, + "justify": { "type": "string", "enum": ["start", "center", "end", "space-between", "space-around", "space-evenly"] }, + "align": { "type": "string", "enum": ["start", "center", "end", "stretch", "baseline"] }, + "alignContent": { "type": "string", "enum": ["start", "center", "end", "stretch", "space-between", "space-around", "space-evenly"] }, + "overflow": { "type": "string", "enum": ["visible", "hidden"] }, + "children": { "type": "array", "items": { "$ref": "#/definitions/element" } } + } + } + }, + { + "if": { "properties": { "type": { "const": "image" } } }, + "then": { + "properties": { + "src": { "type": "string", "description": "Image source path or URL" }, + "objectFit": { "type": "string", "enum": ["fill", "contain", "cover", "none"] } + }, + "required": ["src"] + } + }, + { + "if": { "properties": { "type": { "const": "qr" } } }, + "then": { + "properties": { + "data": { "type": "string", "description": "QR code data" }, + "size": { "type": "integer" }, + "foreground": { "type": "string" }, + "errorCorrection": { "type": "string", "enum": ["L", "M", "Q", "H"] } + }, + "required": ["data"] + } + }, + { + "if": { "properties": { "type": { "const": "each" } } }, + "then": { + "properties": { + "array": { "type": "string", "description": "Path to array in data" }, + "as": { "type": "string", "description": "Iterator variable name" }, + "children": { "type": "array", "items": { "$ref": "#/definitions/element" } } + }, + "required": ["array", "children"] + } + }, + { + "if": { "properties": { "type": { "const": "if" } } }, + "then": { + "properties": { + "condition": { "type": "string" }, + "equals": {}, + "notEquals": {}, + "in": { "type": "array" }, + "notIn": { "type": "array" }, + "contains": { "type": "string" }, + "greaterThan": { "type": "number" }, + "lessThan": { "type": "number" }, + "hasItems": { "type": "boolean" }, + "then": { "type": "array", "items": { "$ref": "#/definitions/element" } }, + "else": { "type": "array", "items": { "$ref": "#/definitions/element" } } + }, + "required": ["condition", "then"] + } + }, + { + "if": { "properties": { "type": { "const": "separator" } } }, + "then": { + "properties": { + "color": { "type": "string" }, + "thickness": { "type": "number" }, + "style": { "type": "string", "enum": ["solid", "dashed", "dotted"] } + } + } + }, + { + "if": { "properties": { "type": { "const": "content" } } }, + "then": { + "properties": { + "source": { "type": "string", "description": "Content source path" }, + "format": { "type": "string", "enum": ["ndc", "markdown", "html"], "description": "Content format" }, + "options": { "type": "object" } + }, + "required": ["source"] + } + } + ] + } + ] + } + } +} +``` + +> **Note:** This is a starting schema. It can be refined later to match `KnownProperties.cs` exactly. The key is getting autocomplete for `type` values and per-type properties. + +**Step 2: Integrate monaco-yaml into main.js** + +Replace the Monaco loading section with: + +```javascript +// Load Monaco + monaco-yaml +// monaco-yaml requires ES module import; use dynamic import after Monaco loader +window.require = { paths: { vs: `${MONACO_CDN}/vs` } }; +await loadScript(`${MONACO_CDN}/vs/loader.js`); + +await new Promise((resolve) => { + window.require(['vs/editor/editor.main'], resolve); +}); + +const monaco = window.monaco; + +// Configure YAML schema for autocomplete +// monaco-yaml needs to be loaded as ESM separately +// For now, Monaco's built-in YAML mode provides syntax highlighting +// Full monaco-yaml integration can be added as a follow-up + +// Load schema for reference +const schemaResponse = await fetch('schemas/flexrender-template.json'); +const flexrenderSchema = await schemaResponse.json(); +``` + +> **Note:** Full `monaco-yaml` integration (with npm-based ESM bundling) is complex to wire into a CDN-only setup. For MVP, use Monaco's built-in YAML syntax highlighting. `monaco-yaml` integration can be added in a follow-up task with a bundler (esbuild/vite). + +**Step 3: Commit** + +```bash +git add src/FlexRender.Playground/wwwroot/schemas/ src/FlexRender.Playground/wwwroot/main.js +git commit -m "feat(playground): add FlexRender JSON Schema for YAML autocomplete" +``` + +--- + +## Task 8: GitHub Actions deployment + +CI/CD pipeline to build and deploy to GitHub Pages. + +**Files:** +- Create: `.github/workflows/playground.yml` + +**Step 1: Write GitHub Actions workflow** + +```yaml +name: Deploy Playground + +on: + push: + branches: [main] + paths: + - 'src/FlexRender.Playground/**' + - 'src/FlexRender.Core/**' + - 'src/FlexRender.Yaml/**' + - 'src/FlexRender.Skia.Render/**' + - '.github/workflows/playground.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - 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: Compress with Brotli + run: | + find publish/wwwroot -type f \( -name "*.js" -o -name "*.wasm" -o -name "*.dll" -o -name "*.json" -o -name "*.css" -o -name "*.html" \) -exec brotli -f -q 11 {} \; + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: publish/wwwroot + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 +``` + +**Step 2: Commit** + +```bash +git add .github/workflows/playground.yml +git commit -m "build(playground): add GitHub Actions workflow for Pages deployment" +``` + +--- + +## Task 9: Polish and integration testing + +Final polish: verify full pipeline, fix any issues, add README section. + +**Files:** +- Modify: various files as needed for bug fixes + +**Step 1: Full integration test** + +```bash +dotnet run --project src/FlexRender.Playground +``` + +Test checklist: +- [ ] Page loads, spinner shows, then IDE appears +- [ ] Default template renders in preview +- [ ] Editing YAML triggers re-render after 300ms +- [ ] Editing JSON data triggers re-render +- [ ] Validation errors show in Errors tab +- [ ] Example dropdown loads examples +- [ ] Export PNG downloads a file +- [ ] Drag & drop font file → re-render with custom font +- [ ] Drag & drop image file → use in `type: image` with `src: "filename.png"` +- [ ] Drag & drop .ndc file → use in `type: content` with `source: "file.ndc"` +- [ ] Status bar shows render time and file size + +**Step 2: Fix any issues found** + +Address build errors, runtime errors, or UI glitches found during testing. + +**Step 3: Final commit** + +```bash +git add -A +git commit -m "feat(playground): complete WASM playground MVP with Monaco Editor" +``` + +--- + +## Execution Order Summary + +| Task | Description | Depends On | Risk | +|------|-------------|-----------|------| +| 1 | Scaffold wasmbrowser project | — | HIGH (WASM + SkiaSharp compatibility) | +| 2 | MemoryResourceLoader | 1 | LOW | +| 3 | PlaygroundApi (JSExport) | 2 | MEDIUM (JSExport marshalling) | +| 4 | Minimal HTML/JS shell | 3 | HIGH (validates full WASM pipeline) | +| 5 | Monaco Editor UI | 4 | LOW | +| 6 | Example gallery | 5 | LOW | +| 7 | JSON Schema + autocomplete | 5 | LOW | +| 8 | GitHub Actions deployment | 5 | LOW | +| 9 | Polish and testing | 5-8 | LOW | + +**Critical path:** Tasks 1 → 4. If SkiaSharp WASM doesn't work, we'll know at Task 4 and can pivot to SVG renderer. From 2a5f2a0fa583cf63f018e61291bcc1faa2e2d1a6 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Mon, 9 Mar 2026 18:44:24 +0300 Subject: [PATCH 02/25] feat(playground): scaffold wasmbrowser project with FlexRender dependencies --- Directory.Packages.props | 1 + FlexRender.slnx | 3 ++ .../FlexRender.Playground.csproj | 24 ++++++++++++++ src/FlexRender.Playground/Program.cs | 3 ++ .../Properties/AssemblyInfo.cs | 4 +++ .../Properties/launchSettings.json | 13 ++++++++ src/FlexRender.Playground/wwwroot/index.html | 26 +++++++++++++++ src/FlexRender.Playground/wwwroot/main.js | 32 +++++++++++++++++++ 8 files changed, 106 insertions(+) create mode 100644 src/FlexRender.Playground/FlexRender.Playground.csproj create mode 100644 src/FlexRender.Playground/Program.cs create mode 100644 src/FlexRender.Playground/Properties/AssemblyInfo.cs create mode 100644 src/FlexRender.Playground/Properties/launchSettings.json create mode 100644 src/FlexRender.Playground/wwwroot/index.html create mode 100644 src/FlexRender.Playground/wwwroot/main.js diff --git a/Directory.Packages.props b/Directory.Packages.props index 6229774..0d95290 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + diff --git a/FlexRender.slnx b/FlexRender.slnx index 75e63f0..75fba12 100644 --- a/FlexRender.slnx +++ b/FlexRender.slnx @@ -1,4 +1,7 @@ + + + diff --git a/src/FlexRender.Playground/FlexRender.Playground.csproj b/src/FlexRender.Playground/FlexRender.Playground.csproj new file mode 100644 index 0000000..8a9f31f --- /dev/null +++ b/src/FlexRender.Playground/FlexRender.Playground.csproj @@ -0,0 +1,24 @@ + + + net10.0 + net10.0 + Exe + true + true + true + false + false + + + + + + + + + + + + + + diff --git a/src/FlexRender.Playground/Program.cs b/src/FlexRender.Playground/Program.cs new file mode 100644 index 0000000..61d130e --- /dev/null +++ b/src/FlexRender.Playground/Program.cs @@ -0,0 +1,3 @@ +using System.Runtime.InteropServices.JavaScript; + +Console.WriteLine("FlexRender Playground loaded"); diff --git a/src/FlexRender.Playground/Properties/AssemblyInfo.cs b/src/FlexRender.Playground/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..9ad9b57 --- /dev/null +++ b/src/FlexRender.Playground/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +[assembly:System.Runtime.Versioning.SupportedOSPlatform("browser")] diff --git a/src/FlexRender.Playground/Properties/launchSettings.json b/src/FlexRender.Playground/Properties/launchSettings.json new file mode 100644 index 0000000..b966dfa --- /dev/null +++ b/src/FlexRender.Playground/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "FlexRender.Playground": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:7262;http://localhost:5249", + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}" + } + } +} diff --git a/src/FlexRender.Playground/wwwroot/index.html b/src/FlexRender.Playground/wwwroot/index.html new file mode 100644 index 0000000..acb552f --- /dev/null +++ b/src/FlexRender.Playground/wwwroot/index.html @@ -0,0 +1,26 @@ + + + + + + + FlexRender.Playground + + + + + + + + +

Stopwatch

+

+ Time elapsed in .NET is loading... +

+

+ + +

+ + + diff --git a/src/FlexRender.Playground/wwwroot/main.js b/src/FlexRender.Playground/wwwroot/main.js new file mode 100644 index 0000000..82c86ed --- /dev/null +++ b/src/FlexRender.Playground/wwwroot/main.js @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +import { dotnet } from './_framework/dotnet.js' + +const { setModuleImports, getAssemblyExports, getConfig, runMain } = await dotnet + .withApplicationArguments("start") + .create(); + +setModuleImports('main.js', { + dom: { + setInnerText: (selector, time) => document.querySelector(selector).innerText = time + } +}); + +const config = getConfig(); +const exports = await getAssemblyExports(config.mainAssemblyName); + +document.getElementById('reset').addEventListener('click', e => { + exports.StopwatchSample.Reset(); + e.preventDefault(); +}); + +const pauseButton = document.getElementById('pause'); +pauseButton.addEventListener('click', e => { + const isRunning = exports.StopwatchSample.Toggle(); + pauseButton.innerText = isRunning ? 'Pause' : 'Start'; + e.preventDefault(); +}); + +// run the C# Main() method and keep the runtime process running and executing further API calls +await runMain(); \ No newline at end of file From 4b93e27b0cbd6021eedba9be2c3bb3b93db98908 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Mon, 9 Mar 2026 18:48:06 +0300 Subject: [PATCH 03/25] feat(playground): add MemoryResourceLoader for browser file uploads --- src/FlexRender.Core/FlexRender.Core.csproj | 1 + .../MemoryResourceLoader.cs | 91 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 src/FlexRender.Playground/MemoryResourceLoader.cs diff --git a/src/FlexRender.Core/FlexRender.Core.csproj b/src/FlexRender.Core/FlexRender.Core.csproj index 32a2f97..7039027 100644 --- a/src/FlexRender.Core/FlexRender.Core.csproj +++ b/src/FlexRender.Core/FlexRender.Core.csproj @@ -12,6 +12,7 @@ +
diff --git a/src/FlexRender.Playground/MemoryResourceLoader.cs b/src/FlexRender.Playground/MemoryResourceLoader.cs new file mode 100644 index 0000000..3926551 --- /dev/null +++ b/src/FlexRender.Playground/MemoryResourceLoader.cs @@ -0,0 +1,91 @@ +using FlexRender.Abstractions; + +namespace FlexRender.Playground; + +/// +/// In-memory resource loader for browser-uploaded files (fonts, images, NDC content). +/// Uploaded resources take highest priority so they override built-in loaders. +/// +internal sealed class MemoryResourceLoader : IResourceLoader +{ + private readonly Dictionary _resources = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Priority 10 ensures uploaded files override all other loaders. + public int Priority => 10; + + /// + public bool CanHandle(string uri) + { + if (string.IsNullOrWhiteSpace(uri)) + { + return false; + } + + return _resources.ContainsKey(NormalizePath(uri)); + } + + /// + public Task Load(string uri, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(uri); + + var normalized = NormalizePath(uri); + if (_resources.TryGetValue(normalized, out var data)) + { + Stream stream = new MemoryStream(data, writable: false); + return Task.FromResult(stream); + } + + return Task.FromResult(null); + } + + /// + /// Stores a resource in memory, keyed by its normalized name. + /// + /// The resource name or path. + /// The raw bytes of the resource. + public void AddResource(string name, byte[] data) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(data); + + _resources[NormalizePath(name)] = data; + } + + /// + /// Removes a previously stored resource. + /// + /// The resource name or path. + public void RemoveResource(string name) + { + ArgumentNullException.ThrowIfNull(name); + + _resources.Remove(NormalizePath(name)); + } + + /// + /// Removes all stored resources. + /// + public void Clear() + { + _resources.Clear(); + } + + /// + /// Strips leading "./" or "/" from a path to produce a canonical lookup key. + /// + private static string NormalizePath(string path) + { + if (path.StartsWith("./", StringComparison.Ordinal)) + { + path = path[2..]; + } + else if (path.StartsWith('/')) + { + path = path[1..]; + } + + return path; + } +} From 56812d9de91abd5b327af5de5eb365c343bb4e5b Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Mon, 9 Mar 2026 18:48:10 +0300 Subject: [PATCH 04/25] feat(playground): add PlaygroundApi with JSExport render/validate/load methods --- src/FlexRender.Playground/PlaygroundApi.cs | 221 +++++++++++++++++++++ src/FlexRender.Playground/Program.cs | 3 +- 2 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 src/FlexRender.Playground/PlaygroundApi.cs diff --git a/src/FlexRender.Playground/PlaygroundApi.cs b/src/FlexRender.Playground/PlaygroundApi.cs new file mode 100644 index 0000000..a12b0c6 --- /dev/null +++ b/src/FlexRender.Playground/PlaygroundApi.cs @@ -0,0 +1,221 @@ +using System.Runtime.InteropServices.JavaScript; +using System.Text.Json; +using FlexRender.Abstractions; +using FlexRender.Configuration; +using FlexRender.Content.Ndc; +using FlexRender.Parsing; +using FlexRender.Yaml; + +namespace FlexRender.Playground; + +/// +/// C# API surface exported to JavaScript via JSExport for the WASM playground. +/// +internal static partial class PlaygroundApi +{ + private static IFlexRender? _render; + private static MemoryResourceLoader? _memoryLoader; + private static TemplateParser? _parser; + + /// + /// Creates the FlexRender pipeline with an in-memory resource loader, Skia backend, and NDC support. + /// Must be called once before any other method. + /// + [JSExport] + public static void Initialize() + { + try + { + _memoryLoader = new MemoryResourceLoader(); + _parser = new TemplateParser(); + + var builder = new FlexRenderBuilder() + .WithNdc() + .WithSkia(); + + // Insert memory loader at highest priority so uploaded files win + builder.ResourceLoaders.Insert(0, _memoryLoader); + + _render = builder.Build(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"FlexRender initialization failed: {ex}"); + throw; + } + } + + /// + /// Renders a YAML template to PNG bytes. + /// + /// YAML template string. + /// Optional JSON object with template data, or null. + /// PNG image bytes, or an empty array on error. + [JSExport] + public static byte[] RenderToPng(string yaml, string? dataJson) + { + try + { + if (_render is null) + { + Console.Error.WriteLine("PlaygroundApi not initialized. Call Initialize() first."); + return []; + } + + ObjectValue? data = null; + if (!string.IsNullOrWhiteSpace(dataJson)) + { + data = ParseJsonData(dataJson); + } + + return _render.RenderYaml(yaml, data, ImageFormat.Png, _parser) + .GetAwaiter() + .GetResult(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"RenderToPng error: {ex.Message}"); + return []; + } + } + + /// + /// Validates a YAML template and returns a JSON array of errors. + /// + /// YAML template string. + /// JSON string: [] on success, or [{"message":"...","line":0}] on error. + [JSExport] + public static string Validate(string yaml) + { + try + { + _parser ??= new TemplateParser(); + _parser.Parse(yaml); + return "[]"; + } + catch (TemplateParseException ex) + { + var errors = new[] + { + new { message = ex.Message, line = ex.Line ?? 0 } + }; + return JsonSerializer.Serialize(errors); + } + catch (Exception ex) + { + var errors = new[] + { + new { message = ex.Message, line = 0 } + }; + return JsonSerializer.Serialize(errors); + } + } + + /// + /// Stores a font in the in-memory resource loader. + /// + /// Font file name (e.g. "Roboto-Regular.ttf"). + /// Raw font bytes. + [JSExport] + public static void LoadFont(string name, byte[] data) + { + try + { + _memoryLoader?.AddResource(name, data); + } + catch (Exception ex) + { + Console.Error.WriteLine($"LoadFont error: {ex.Message}"); + } + } + + /// + /// Stores an image in the in-memory resource loader. + /// + /// Image path as referenced in the template (e.g. "logo.png"). + /// Raw image bytes. + [JSExport] + public static void LoadImage(string path, byte[] data) + { + try + { + _memoryLoader?.AddResource(path, data); + } + catch (Exception ex) + { + Console.Error.WriteLine($"LoadImage error: {ex.Message}"); + } + } + + /// + /// Stores NDC or other content data in the in-memory resource loader. + /// + /// Content path as referenced in the template. + /// Raw content bytes. + [JSExport] + public static void LoadContent(string path, byte[] data) + { + try + { + _memoryLoader?.AddResource(path, data); + } + catch (Exception ex) + { + Console.Error.WriteLine($"LoadContent error: {ex.Message}"); + } + } + + /// + /// Parses a JSON string into an for template data binding. + /// Mirrors the logic from FlexRender.Cli.DataLoader. + /// + private static ObjectValue ParseJsonData(string json) + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + if (root.ValueKind != JsonValueKind.Object) + { + throw new JsonException("Root element must be a JSON object"); + } + + return (ObjectValue)ConvertElement(root); + } + + private static TemplateValue ConvertElement(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => new StringValue(element.GetString()!), + JsonValueKind.Number => new NumberValue(element.GetDecimal()), + JsonValueKind.True => new BoolValue(true), + JsonValueKind.False => new BoolValue(false), + JsonValueKind.Null => NullValue.Instance, + JsonValueKind.Array => ConvertArray(element), + JsonValueKind.Object => ConvertObject(element), + _ => NullValue.Instance + }; + } + + private static ArrayValue ConvertArray(JsonElement element) + { + var items = new List(); + foreach (var item in element.EnumerateArray()) + { + items.Add(ConvertElement(item)); + } + + return new ArrayValue(items); + } + + private static ObjectValue ConvertObject(JsonElement element) + { + var obj = new ObjectValue(); + foreach (var property in element.EnumerateObject()) + { + obj[property.Name] = ConvertElement(property.Value); + } + + return obj; + } +} diff --git a/src/FlexRender.Playground/Program.cs b/src/FlexRender.Playground/Program.cs index 61d130e..808eb52 100644 --- a/src/FlexRender.Playground/Program.cs +++ b/src/FlexRender.Playground/Program.cs @@ -1,3 +1,4 @@ using System.Runtime.InteropServices.JavaScript; -Console.WriteLine("FlexRender Playground loaded"); +FlexRender.Playground.PlaygroundApi.Initialize(); +Console.WriteLine("FlexRender Playground ready"); From 314c4092c13d97b27b4cb4bfcd868e4dce2def5f Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Mon, 9 Mar 2026 18:50:32 +0300 Subject: [PATCH 05/25] feat(playground): add minimal HTML/JS shell with WASM render test --- src/FlexRender.Playground/wwwroot/index.html | 76 ++++++++++++++------ src/FlexRender.Playground/wwwroot/main.js | 70 +++++++++++------- 2 files changed, 102 insertions(+), 44 deletions(-) diff --git a/src/FlexRender.Playground/wwwroot/index.html b/src/FlexRender.Playground/wwwroot/index.html index acb552f..655168c 100644 --- a/src/FlexRender.Playground/wwwroot/index.html +++ b/src/FlexRender.Playground/wwwroot/index.html @@ -1,26 +1,62 @@ - - - - + - FlexRender.Playground - - - - - + + + FlexRender Playground + + + + - -

Stopwatch

-

- Time elapsed in .NET is loading... -

-

- - -

+
+
+
Loading FlexRender WASM runtime...
+
+
+
+

FlexRender Playground — WASM Test

+ +

+        
+
Ready
+
- diff --git a/src/FlexRender.Playground/wwwroot/main.js b/src/FlexRender.Playground/wwwroot/main.js index 82c86ed..afd45f8 100644 --- a/src/FlexRender.Playground/wwwroot/main.js +++ b/src/FlexRender.Playground/wwwroot/main.js @@ -1,32 +1,54 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. +import { dotnet } from './_framework/dotnet.js'; -import { dotnet } from './_framework/dotnet.js' - -const { setModuleImports, getAssemblyExports, getConfig, runMain } = await dotnet - .withApplicationArguments("start") +const { getAssemblyExports, getConfig, runMain } = await dotnet + .withApplicationArguments('start') .create(); -setModuleImports('main.js', { - dom: { - setInnerText: (selector, time) => document.querySelector(selector).innerText = time - } -}); - const config = getConfig(); const exports = await getAssemblyExports(config.mainAssemblyName); +const api = exports.FlexRender.Playground.PlaygroundApi; + +await runMain(); -document.getElementById('reset').addEventListener('click', e => { - exports.StopwatchSample.Reset(); - e.preventDefault(); -}); +// Hide loading, show app +document.getElementById('loading').style.display = 'none'; +document.getElementById('app').style.display = 'block'; -const pauseButton = document.getElementById('pause'); -pauseButton.addEventListener('click', e => { - const isRunning = exports.StopwatchSample.Toggle(); - pauseButton.innerText = isRunning ? 'Pause' : 'Start'; - e.preventDefault(); -}); +// Test render with a minimal template +const testYaml = `canvas: + width: 300 + height: 100 + background: "#ffffff" +elements: + - type: text + content: "Hello from WASM!" + size: 24 + color: "#333333" + padding: "20" +`; -// run the C# Main() method and keep the runtime process running and executing further API calls -await runMain(); \ No newline at end of file +const statusEl = document.getElementById('status'); +const errorEl = document.getElementById('error'); +const imgEl = document.getElementById('preview-img'); + +try { + statusEl.textContent = 'Rendering...'; + const start = performance.now(); + + const pngBytes = api.RenderToPng(testYaml, null); + const elapsed = (performance.now() - start).toFixed(0); + + if (pngBytes && pngBytes.length > 0) { + const blob = new Blob([pngBytes], { type: 'image/png' }); + imgEl.src = URL.createObjectURL(blob); + statusEl.textContent = `Rendered in ${elapsed}ms · ${pngBytes.length} bytes`; + } else { + errorEl.textContent = 'Render returned empty result — check browser console for errors'; + statusEl.textContent = 'Error'; + statusEl.style.background = '#c72e2e'; + } +} catch (e) { + errorEl.textContent = e.message || String(e); + statusEl.textContent = 'Error'; + statusEl.style.background = '#c72e2e'; +} From fd7aefba2e95c215f3ab78c1b9406766b121aa1a Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Mon, 9 Mar 2026 18:57:24 +0300 Subject: [PATCH 06/25] feat(playground): add Monaco Editor UI with debounced live preview Replace minimal test page with full IDE layout: Monaco YAML/JSON editors, preview panel with tabs (Preview/Layout/Errors), toolbar with examples dropdown and export buttons, drag-and-drop for fonts/images/content, and 300ms debounced re-rendering. Fix implicit HotReload package conflict with Central Package Management. --- Directory.Packages.props | 2 +- .../FlexRender.Playground.csproj | 1 - src/FlexRender.Playground/wwwroot/index.html | 88 ++--- src/FlexRender.Playground/wwwroot/main.js | 326 ++++++++++++++++-- src/FlexRender.Playground/wwwroot/style.css | 219 ++++++++++++ 5 files changed, 558 insertions(+), 78 deletions(-) create mode 100644 src/FlexRender.Playground/wwwroot/style.css diff --git a/Directory.Packages.props b/Directory.Packages.props index 0d95290..84abca0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + diff --git a/src/FlexRender.Playground/FlexRender.Playground.csproj b/src/FlexRender.Playground/FlexRender.Playground.csproj index 8a9f31f..eb1fbc7 100644 --- a/src/FlexRender.Playground/FlexRender.Playground.csproj +++ b/src/FlexRender.Playground/FlexRender.Playground.csproj @@ -1,7 +1,6 @@ net10.0 - net10.0 Exe true true diff --git a/src/FlexRender.Playground/wwwroot/index.html b/src/FlexRender.Playground/wwwroot/index.html index 655168c..834306c 100644 --- a/src/FlexRender.Playground/wwwroot/index.html +++ b/src/FlexRender.Playground/wwwroot/index.html @@ -4,59 +4,63 @@ FlexRender Playground + -
Loading FlexRender WASM runtime...
+
-
-

FlexRender Playground — WASM Test

- -

+        
+

FlexRender Playground

+ +
+ +
-
Ready
+ +
+
+
+
Template (YAML)
+
+
+
+
Data (JSON)
+
+
+
+ +
+
+ + + +
+
+
+ +
+
+
+
+
+
+ +
+ Ready +
+
+ +
+ Drop fonts, images, or content files here
diff --git a/src/FlexRender.Playground/wwwroot/main.js b/src/FlexRender.Playground/wwwroot/main.js index afd45f8..d92fe25 100644 --- a/src/FlexRender.Playground/wwwroot/main.js +++ b/src/FlexRender.Playground/wwwroot/main.js @@ -1,5 +1,6 @@ import { dotnet } from './_framework/dotnet.js'; +// --- .NET WASM initialization --- const { getAssemblyExports, getConfig, runMain } = await dotnet .withApplicationArguments('start') .create(); @@ -10,45 +11,302 @@ const api = exports.FlexRender.Playground.PlaygroundApi; await runMain(); -// Hide loading, show app -document.getElementById('loading').style.display = 'none'; -document.getElementById('app').style.display = 'block'; +// --- Load Monaco Editor from CDN --- +const MONACO_CDN = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min'; + +function loadScript(src) { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = src; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); +} -// Test render with a minimal template -const testYaml = `canvas: - width: 300 - height: 100 +window.require = { paths: { vs: `${MONACO_CDN}/vs` } }; +await loadScript(`${MONACO_CDN}/vs/loader.js`); + +const monaco = await new Promise((resolve) => { + window.require(['vs/editor/editor.main'], () => resolve(window.monaco)); +}); + +// --- Built-in examples --- +const EXAMPLES = { + 'Simple Text': { + yaml: `canvas: + width: 400 + height: 150 background: "#ffffff" elements: - type: text - content: "Hello from WASM!" - size: 24 + content: "Hello, FlexRender!" + size: 28 color: "#333333" + padding: "30"`, + json: '{}' + }, + 'Flex Layout': { + yaml: `canvas: + width: 400 + height: 200 + background: "#f5f5f5" +elements: + - type: flex + direction: row + gap: "10" padding: "20" -`; - -const statusEl = document.getElementById('status'); -const errorEl = document.getElementById('error'); -const imgEl = document.getElementById('preview-img'); - -try { - statusEl.textContent = 'Rendering...'; - const start = performance.now(); - - const pngBytes = api.RenderToPng(testYaml, null); - const elapsed = (performance.now() - start).toFixed(0); - - if (pngBytes && pngBytes.length > 0) { - const blob = new Blob([pngBytes], { type: 'image/png' }); - imgEl.src = URL.createObjectURL(blob); - statusEl.textContent = `Rendered in ${elapsed}ms · ${pngBytes.length} bytes`; - } else { - errorEl.textContent = 'Render returned empty result — check browser console for errors'; - statusEl.textContent = 'Error'; - statusEl.style.background = '#c72e2e'; + children: + - type: flex + background: "#4CAF50" + padding: "20" + grow: 1 + children: + - type: text + content: "Left" + color: "#ffffff" + size: 16 + - type: flex + background: "#2196F3" + padding: "20" + grow: 2 + children: + - type: text + content: "Right (grow: 2)" + color: "#ffffff" + size: 16`, + json: '{}' + }, + 'Data Binding': { + yaml: `canvas: + width: 400 + height: 300 + background: "#ffffff" +elements: + - type: flex + padding: "20" + gap: "8" + children: + - type: text + content: "{{title}}" + size: 24 + color: "#333" + - type: text + content: "By {{author}}" + size: 14 + color: "#888" + - type: separator + color: "#eee" + - type: each + array: items + children: + - type: text + content: "\u2022 {{item}}" + size: 14 + color: "#555"`, + json: `{ + "title": "Shopping List", + "author": "FlexRender", + "items": ["Apples", "Bread", "Milk", "Cheese"] +}` + } +}; + +// --- Create Monaco editors --- +const defaultYaml = EXAMPLES['Simple Text'].yaml; +const defaultJson = '{}'; + +const yamlEditor = monaco.editor.create(document.getElementById('yaml-editor'), { + value: defaultYaml, + language: 'yaml', + theme: 'vs-dark', + minimap: { enabled: false }, + fontSize: 13, + tabSize: 2, + automaticLayout: true, + scrollBeyondLastLine: false, +}); + +const jsonEditor = monaco.editor.create(document.getElementById('json-editor'), { + value: defaultJson, + language: 'json', + theme: 'vs-dark', + minimap: { enabled: false }, + fontSize: 13, + tabSize: 2, + automaticLayout: true, + scrollBeyondLastLine: false, +}); + +// --- UI elements --- +const statusBar = document.getElementById('status-bar'); +const statusText = document.getElementById('status-text'); +const previewImg = document.getElementById('preview-img'); +const errorsPane = document.getElementById('errors-pane'); +const layoutPane = document.getElementById('layout-pane'); + +// --- Tab switching --- +document.querySelectorAll('.preview-tabs button').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.preview-tabs button').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); + btn.classList.add('active'); + document.getElementById(`${btn.dataset.tab}-pane`).classList.add('active'); + }); +}); + +// --- Render function --- +let renderTimeout = null; +let lastObjectUrl = null; + +function render() { + const yaml = yamlEditor.getValue(); + const json = jsonEditor.getValue(); + + statusBar.classList.remove('error'); + statusText.textContent = 'Rendering...'; + + try { + // Validate + const errorsJson = api.Validate(yaml); + const errors = JSON.parse(errorsJson); + + if (errors.length > 0) { + errorsPane.textContent = errors.map(e => + e.line > 0 ? `Line ${e.line}: ${e.message}` : e.message + ).join('\n'); + statusBar.classList.add('error'); + statusText.textContent = `${errors.length} error(s)`; + // Switch to errors tab + document.querySelectorAll('.preview-tabs button').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); + document.querySelector('[data-tab="errors"]').classList.add('active'); + document.getElementById('errors-pane').classList.add('active'); + return; + } + + errorsPane.textContent = ''; + + // Render + const start = performance.now(); + const dataArg = json.trim() === '{}' || json.trim() === '' ? null : json; + const pngBytes = api.RenderToPng(yaml, dataArg); + const elapsed = (performance.now() - start).toFixed(0); + + if (pngBytes && pngBytes.length > 0) { + if (lastObjectUrl) URL.revokeObjectURL(lastObjectUrl); + const blob = new Blob([pngBytes], { type: 'image/png' }); + lastObjectUrl = URL.createObjectURL(blob); + previewImg.src = lastObjectUrl; + statusText.textContent = `Rendered in ${elapsed}ms \u00b7 ${(pngBytes.length / 1024).toFixed(1)} KB`; + } else { + statusText.textContent = 'Render returned empty \u2014 check console'; + } + } catch (e) { + errorsPane.textContent = e.message || String(e); + statusBar.classList.add('error'); + statusText.textContent = 'Error'; } -} catch (e) { - errorEl.textContent = e.message || String(e); - statusEl.textContent = 'Error'; - statusEl.style.background = '#c72e2e'; } + +// --- Debounced render --- +function scheduleRender() { + clearTimeout(renderTimeout); + renderTimeout = setTimeout(render, 300); +} + +yamlEditor.onDidChangeModelContent(scheduleRender); +jsonEditor.onDidChangeModelContent(scheduleRender); + +// --- Examples dropdown --- +const examplesSelect = document.getElementById('examples'); +for (const name of Object.keys(EXAMPLES)) { + const option = document.createElement('option'); + option.value = name; + option.textContent = name; + examplesSelect.appendChild(option); +} + +examplesSelect.addEventListener('change', () => { + const example = EXAMPLES[examplesSelect.value]; + if (example) { + yamlEditor.setValue(example.yaml); + jsonEditor.setValue(example.json); + } +}); + +// --- Export PNG --- +document.getElementById('btn-export-png').addEventListener('click', () => { + const yaml = yamlEditor.getValue(); + const json = jsonEditor.getValue(); + const dataArg = json.trim() === '{}' || json.trim() === '' ? null : json; + + try { + const pngBytes = api.RenderToPng(yaml, dataArg); + if (pngBytes && pngBytes.length > 0) { + const blob = new Blob([pngBytes], { type: 'image/png' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'flexrender-output.png'; + a.click(); + URL.revokeObjectURL(a.href); + } + } catch (e) { + alert('Export failed: ' + (e.message || e)); + } +}); + +// --- Drag & drop --- +const dropOverlay = document.getElementById('drop-overlay'); +let dragCounter = 0; + +const FONT_EXT = ['.ttf', '.otf', '.woff2']; +const IMAGE_EXT = ['.png', '.jpg', '.jpeg', '.svg', '.gif', '.webp']; +const CONTENT_EXT = ['.ndc', '.txt']; + +document.addEventListener('dragenter', (e) => { + e.preventDefault(); + dragCounter++; + dropOverlay.classList.add('visible'); +}); + +document.addEventListener('dragleave', (e) => { + e.preventDefault(); + dragCounter--; + if (dragCounter === 0) dropOverlay.classList.remove('visible'); +}); + +document.addEventListener('dragover', (e) => e.preventDefault()); + +document.addEventListener('drop', async (e) => { + e.preventDefault(); + dragCounter = 0; + dropOverlay.classList.remove('visible'); + + const loaded = []; + for (const file of e.dataTransfer.files) { + const ext = '.' + file.name.split('.').pop().toLowerCase(); + const buffer = new Uint8Array(await file.arrayBuffer()); + + if (FONT_EXT.includes(ext)) { + api.LoadFont(file.name, buffer); + loaded.push(`font: ${file.name}`); + } else if (IMAGE_EXT.includes(ext)) { + api.LoadImage(file.name, buffer); + loaded.push(`image: ${file.name}`); + } else if (CONTENT_EXT.includes(ext)) { + api.LoadContent(file.name, buffer); + loaded.push(`content: ${file.name}`); + } + } + + if (loaded.length > 0) { + statusText.textContent = `Loaded: ${loaded.join(', ')}`; + scheduleRender(); + } +}); + +// --- Show app & initial render --- +document.getElementById('loading').style.display = 'none'; +document.getElementById('app').style.display = 'flex'; +render(); diff --git a/src/FlexRender.Playground/wwwroot/style.css b/src/FlexRender.Playground/wwwroot/style.css new file mode 100644 index 0000000..e3b8398 --- /dev/null +++ b/src/FlexRender.Playground/wwwroot/style.css @@ -0,0 +1,219 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: system-ui, -apple-system, sans-serif; + background: #1e1e1e; + color: #d4d4d4; + height: 100vh; + overflow: hidden; +} + +#loading { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + font-size: 1.2em; + flex-direction: column; + gap: 12px; +} + +#loading .spinner { + width: 32px; + height: 32px; + border: 3px solid #333; + border-top-color: #007acc; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +#app { + display: none; + flex-direction: column; + height: 100vh; +} + +.toolbar { + display: flex; + align-items: center; + padding: 6px 12px; + background: #2d2d2d; + border-bottom: 1px solid #404040; + gap: 12px; + flex-shrink: 0; +} + +.toolbar h1 { + font-size: 14px; + font-weight: 600; + white-space: nowrap; +} + +.toolbar select, .toolbar button { + background: #3c3c3c; + color: #d4d4d4; + border: 1px solid #555; + padding: 4px 10px; + border-radius: 4px; + font-size: 12px; + cursor: pointer; +} + +.toolbar select:hover, .toolbar button:hover { + background: #4c4c4c; +} + +.toolbar .spacer { flex: 1; } + +.main-content { + display: flex; + flex: 1; + overflow: hidden; +} + +.editor-panel { + display: flex; + flex-direction: column; + width: 50%; + min-width: 300px; + border-right: 1px solid #404040; +} + +.editor-section { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.editor-section + .editor-section { + border-top: 1px solid #404040; +} + +.editor-label { + padding: 4px 12px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #888; + background: #252526; + flex-shrink: 0; +} + +.editor-container { + flex: 1; + overflow: hidden; +} + +.preview-panel { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.preview-tabs { + display: flex; + background: #252526; + border-bottom: 1px solid #404040; + flex-shrink: 0; +} + +.preview-tabs button { + background: none; + color: #888; + border: none; + padding: 6px 16px; + font-size: 12px; + cursor: pointer; + border-bottom: 2px solid transparent; +} + +.preview-tabs button.active { + color: #d4d4d4; + border-bottom-color: #007acc; +} + +.preview-tabs button:hover { + color: #d4d4d4; +} + +.preview-content { + flex: 1; + overflow: auto; + display: flex; + align-items: center; + justify-content: center; + background: #1a1a1a; + position: relative; +} + +.tab-pane { display: none; width: 100%; height: 100%; } +.tab-pane.active { + display: flex; + align-items: center; + justify-content: center; +} + +#preview-pane img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + image-rendering: auto; +} + +#errors-pane { + align-items: flex-start; + justify-content: flex-start; + padding: 12px; + font-family: 'Cascadia Code', 'Fira Code', monospace; + font-size: 13px; + color: #f48771; + white-space: pre-wrap; + overflow: auto; +} + +#errors-pane:empty::after { + content: 'No errors'; + color: #4ec9b0; +} + +#layout-pane { + align-items: flex-start; + justify-content: flex-start; + padding: 12px; + font-family: 'Cascadia Code', 'Fira Code', monospace; + font-size: 12px; + overflow: auto; + color: #9cdcfe; +} + +.status-bar { + display: flex; + align-items: center; + padding: 2px 12px; + background: #007acc; + color: #fff; + font-size: 12px; + flex-shrink: 0; + gap: 16px; +} + +.status-bar.error { background: #c72e2e; } + +.drop-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 122, 204, 0.15); + border: 3px dashed #007acc; + z-index: 1000; + align-items: center; + justify-content: center; + font-size: 1.5em; + pointer-events: none; +} + +.drop-overlay.visible { display: flex; } From 89911ec5e4928e5377f5a762b3d580efe9b1f677 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Mon, 9 Mar 2026 20:06:52 +0300 Subject: [PATCH 07/25] =?UTF-8?q?fix(playground):=20resolve=20WASM=20rende?= =?UTF-8?q?ring=20=E2=80=94=20SkiaSharp=20native=20linking,=20font=20loadi?= =?UTF-8?q?ng=20via=20ResourceLoaders,=20bitmap=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add explicit NativeFileReference for SkiaSharp WASM (workaround for empty SkiaSharpStaticLibraryPath) - Embed Inter-Regular.ttf as default font since WASM has no system fonts - Add async font preloading via IResourceLoader chain (FontManager.PreloadFontFromResourcesAsync) - Make TemplatePreprocessor.RegisterFonts async, falling back to resource loaders when File.Exists fails - MemoryResourceLoader: also match by Path.GetFileName for absolute path lookups - Guard bitmap dimensions to minimum 1px to prevent SKImage.FromBitmap returning null - Disable static asset fingerprinting for simpler dev workflow - Fix examples: use correct YAML keys (layout: not elements:, path: not src:, add fixed: both) --- .../FlexRender.Playground.csproj | 16 ++++- .../Fonts/Inter-Regular.ttf | 3 + .../MemoryResourceLoader.cs | 11 +++- src/FlexRender.Playground/PlaygroundApi.cs | 26 +++++++- .../Properties/launchSettings.json | 4 +- src/FlexRender.Playground/wwwroot/index.html | 2 +- src/FlexRender.Playground/wwwroot/main.js | 18 +++++- .../Rendering/FontManager.cs | 59 ++++++++++++++++++- .../Rendering/RenderingEngine.cs | 6 +- .../Rendering/SkiaRenderer.cs | 21 +++---- .../Rendering/TemplatePreprocessor.cs | 30 ++++++++-- 11 files changed, 165 insertions(+), 31 deletions(-) create mode 100755 src/FlexRender.Playground/Fonts/Inter-Regular.ttf diff --git a/src/FlexRender.Playground/FlexRender.Playground.csproj b/src/FlexRender.Playground/FlexRender.Playground.csproj index eb1fbc7..c185856 100644 --- a/src/FlexRender.Playground/FlexRender.Playground.csproj +++ b/src/FlexRender.Playground/FlexRender.Playground.csproj @@ -4,13 +4,15 @@ Exe true true - true + + false false - + + @@ -20,4 +22,14 @@ + + + + + + + + + + diff --git a/src/FlexRender.Playground/Fonts/Inter-Regular.ttf b/src/FlexRender.Playground/Fonts/Inter-Regular.ttf new file mode 100755 index 0000000..e675cc0 --- /dev/null +++ b/src/FlexRender.Playground/Fonts/Inter-Regular.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e5f90a0138b38de4cf4d779ad78391974ea1df776b9164842bdcbb60ce383c5 +size 342680 diff --git a/src/FlexRender.Playground/MemoryResourceLoader.cs b/src/FlexRender.Playground/MemoryResourceLoader.cs index 3926551..db3b44e 100644 --- a/src/FlexRender.Playground/MemoryResourceLoader.cs +++ b/src/FlexRender.Playground/MemoryResourceLoader.cs @@ -22,7 +22,8 @@ public bool CanHandle(string uri) return false; } - return _resources.ContainsKey(NormalizePath(uri)); + return _resources.ContainsKey(NormalizePath(uri)) + || _resources.ContainsKey(Path.GetFileName(uri)); } /// @@ -37,6 +38,14 @@ public bool CanHandle(string uri) return Task.FromResult(stream); } + // Also try by filename only (handles absolute paths from ResolveFontPath) + var fileName = Path.GetFileName(uri); + if (_resources.TryGetValue(fileName, out data)) + { + Stream stream = new MemoryStream(data, writable: false); + return Task.FromResult(stream); + } + return Task.FromResult(null); } diff --git a/src/FlexRender.Playground/PlaygroundApi.cs b/src/FlexRender.Playground/PlaygroundApi.cs index a12b0c6..8c06b35 100644 --- a/src/FlexRender.Playground/PlaygroundApi.cs +++ b/src/FlexRender.Playground/PlaygroundApi.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Runtime.InteropServices.JavaScript; using System.Text.Json; using FlexRender.Abstractions; @@ -29,6 +30,9 @@ public static void Initialize() _memoryLoader = new MemoryResourceLoader(); _parser = new TemplateParser(); + // Load embedded default font (WASM has no system fonts) + LoadEmbeddedFont("Inter-Regular.ttf"); + var builder = new FlexRenderBuilder() .WithNdc() .WithSkia(); @@ -37,6 +41,7 @@ public static void Initialize() builder.ResourceLoaders.Insert(0, _memoryLoader); _render = builder.Build(); + Console.WriteLine("FlexRender engine initialized successfully"); } catch (Exception ex) { @@ -68,13 +73,14 @@ public static byte[] RenderToPng(string yaml, string? dataJson) data = ParseJsonData(dataJson); } - return _render.RenderYaml(yaml, data, ImageFormat.Png, _parser) + var result = _render.RenderYaml(yaml, data, ImageFormat.Png, _parser) .GetAwaiter() .GetResult(); + return result; } catch (Exception ex) { - Console.Error.WriteLine($"RenderToPng error: {ex.Message}"); + Console.Error.WriteLine($"RenderToPng error: {ex}"); return []; } } @@ -165,6 +171,22 @@ public static void LoadContent(string path, byte[] data) } } + private static void LoadEmbeddedFont(string resourceName) + { + var assembly = Assembly.GetExecutingAssembly(); + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream is null) + { + Console.Error.WriteLine($"Embedded font not found: {resourceName}"); + return; + } + + using var ms = new MemoryStream(); + stream.CopyTo(ms); + _memoryLoader!.AddResource(resourceName, ms.ToArray()); + Console.WriteLine($"Loaded embedded font: {resourceName}"); + } + /// /// Parses a JSON string into an for template data binding. /// Mirrors the logic from FlexRender.Cli.DataLoader. diff --git a/src/FlexRender.Playground/Properties/launchSettings.json b/src/FlexRender.Playground/Properties/launchSettings.json index b966dfa..c9f508b 100644 --- a/src/FlexRender.Playground/Properties/launchSettings.json +++ b/src/FlexRender.Playground/Properties/launchSettings.json @@ -2,12 +2,10 @@ "profiles": { "FlexRender.Playground": { "commandName": "Project", - "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, - "applicationUrl": "https://localhost:7262;http://localhost:5249", - "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}" + "applicationUrl": "http://localhost:5249" } } } diff --git a/src/FlexRender.Playground/wwwroot/index.html b/src/FlexRender.Playground/wwwroot/index.html index 834306c..2494e19 100644 --- a/src/FlexRender.Playground/wwwroot/index.html +++ b/src/FlexRender.Playground/wwwroot/index.html @@ -7,7 +7,7 @@ - +
diff --git a/src/FlexRender.Playground/wwwroot/main.js b/src/FlexRender.Playground/wwwroot/main.js index d92fe25..ae5fe21 100644 --- a/src/FlexRender.Playground/wwwroot/main.js +++ b/src/FlexRender.Playground/wwwroot/main.js @@ -37,8 +37,12 @@ const EXAMPLES = { yaml: `canvas: width: 400 height: 150 + fixed: both background: "#ffffff" -elements: +fonts: + - name: main + path: Inter-Regular.ttf +layout: - type: text content: "Hello, FlexRender!" size: 28 @@ -50,8 +54,12 @@ elements: yaml: `canvas: width: 400 height: 200 + fixed: both background: "#f5f5f5" -elements: +fonts: + - name: main + path: Inter-Regular.ttf +layout: - type: flex direction: row gap: "10" @@ -81,8 +89,12 @@ elements: yaml: `canvas: width: 400 height: 300 + fixed: both background: "#ffffff" -elements: +fonts: + - name: main + path: Inter-Regular.ttf +layout: - type: flex padding: "20" gap: "8" diff --git a/src/FlexRender.Skia.Render/Rendering/FontManager.cs b/src/FlexRender.Skia.Render/Rendering/FontManager.cs index 4cc3c00..fd2d344 100644 --- a/src/FlexRender.Skia.Render/Rendering/FontManager.cs +++ b/src/FlexRender.Skia.Render/Rendering/FontManager.cs @@ -17,9 +17,26 @@ public sealed class FontManager : IFontManager, IDisposable private readonly ConcurrentDictionary _variantTypefaces = new(); private readonly ConcurrentDictionary _fontPaths = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _fontFallbacks = new(StringComparer.OrdinalIgnoreCase); + private readonly IReadOnlyList _resourceLoaders; private string _defaultFallback = "Arial"; private bool _disposed; + /// + /// Initializes a new instance with no resource loaders (file-system only). + /// + public FontManager() : this([]) { } + + /// + /// Initializes a new instance with the specified resource loaders for font resolution. + /// When a font file path cannot be found on disk, the resource loaders are tried in priority order. + /// + /// Resource loaders to use for font resolution. + public FontManager(IReadOnlyList resourceLoaders) + { + ArgumentNullException.ThrowIfNull(resourceLoaders); + _resourceLoaders = resourceLoaders; + } + /// /// Gets a typeface by font name, using fallback if necessary. /// This method is thread-safe and uses atomic GetOrAdd operations. @@ -114,7 +131,7 @@ public SKTypeface GetTypeface(string fontName, FontWeight weight, FontStyle styl /// The loaded typeface or a fallback. private SKTypeface LoadTypeface(string fontName) { - // Try to load from registered path + // Try to load from registered path on disk if (_fontPaths.TryGetValue(fontName, out var path) && File.Exists(path)) { var typeface = SKTypeface.FromFile(path); @@ -138,6 +155,46 @@ private SKTypeface LoadTypeface(string fontName) return SKTypeface.FromFamilyName(_defaultFallback) ?? SKTypeface.Default; } + /// + /// Asynchronously pre-loads a font from resource loaders and caches the typeface. + /// Should be called during font registration (before rendering) to avoid sync-over-async. + /// + /// The font name to register. + /// The resource key (file name or path) to look up. + /// Cancellation token. + /// True if font was loaded from a resource loader; otherwise, false. + public async Task PreloadFontFromResourcesAsync(string name, string resourceKey, CancellationToken cancellationToken = default) + { + foreach (var loader in _resourceLoaders.OrderBy(l => l.Priority)) + { + if (!loader.CanHandle(resourceKey)) + continue; + + try + { + using var stream = await loader.Load(resourceKey, cancellationToken).ConfigureAwait(false); + if (stream is null) + continue; + + var typeface = SKTypeface.FromStream(stream); + if (typeface is null) + continue; + + // Cache directly so GetTypeface returns it synchronously + _typefaces.TryRemove(name, out var old); + old?.Dispose(); + _typefaces[name] = typeface; + return true; + } + catch + { + // Resource loader failed, try next + } + } + + return false; + } + /// /// Loads a typeface by searching registered fonts' FamilyName metadata, then system fonts. /// diff --git a/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs b/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs index f735e99..e8a6cfe 100644 --- a/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs +++ b/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs @@ -93,7 +93,7 @@ internal RenderingEngine( /// /// Core canvas rendering logic. Accepts a pre-processed template and computed layout node. /// The caller is responsible for calling and - /// before invoking this method. + /// before invoking this method. /// /// The canvas to render to. /// The already-processed template (after pipeline expansion). @@ -136,7 +136,7 @@ internal void RenderToCanvas( /// /// Core bitmap rendering logic. Accepts a pre-processed template and computed layout node. /// The caller is responsible for calling and - /// before invoking this method. + /// before invoking this method. /// /// The bitmap to render to. /// The already-processed template (after pipeline expansion). @@ -701,7 +701,7 @@ internal async Task> PreloadImagesAsync( return new Dictionary(0, StringComparer.Ordinal); var processedTemplate = await _pipeline.ProcessAsync(template, data).ConfigureAwait(false); - _preprocessor.RegisterFonts(processedTemplate); + await _preprocessor.RegisterFontsAsync(processedTemplate, cancellationToken).ConfigureAwait(false); return await LoadImageCache(processedTemplate, cancellationToken).ConfigureAwait(false); } diff --git a/src/FlexRender.Skia.Render/Rendering/SkiaRenderer.cs b/src/FlexRender.Skia.Render/Rendering/SkiaRenderer.cs index 5ad9082..7975553 100644 --- a/src/FlexRender.Skia.Render/Rendering/SkiaRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/SkiaRenderer.cs @@ -110,7 +110,7 @@ public SkiaRenderer( var expander = filterRegistry is not null ? new TemplateExpander(limits, filterRegistry, contentParserRegistry, resourceLoaders) : new TemplateExpander(limits, contentParserRegistry, resourceLoaders); - _fontManager = new FontManager(); + _fontManager = new FontManager(resourceLoaders ?? []); _defaultRenderOptions = deterministicRendering ? RenderOptions.Deterministic : RenderOptions.Default; _textRenderer = new TextRenderer(_fontManager); _layoutEngine = new LayoutEngine(_limits); @@ -159,7 +159,7 @@ public async Task ComputeLayoutAsync(Template template, ObjectValue ArgumentNullException.ThrowIfNull(data); var processedTemplate = await _pipeline.ProcessAsync(template, data).ConfigureAwait(false); - _preprocessor.RegisterFonts(processedTemplate); + await _preprocessor.RegisterFontsAsync(processedTemplate).ConfigureAwait(false); return _layoutEngine.ComputeLayout(processedTemplate); } @@ -182,7 +182,7 @@ public async Task MeasureAsync(Template template, ObjectValue data, Canc cancellationToken.ThrowIfCancellationRequested(); var processedTemplate = await _pipeline.ProcessAsync(template, data).ConfigureAwait(false); - _preprocessor.RegisterFonts(processedTemplate); + await _preprocessor.RegisterFontsAsync(processedTemplate, cancellationToken).ConfigureAwait(false); var rootNode = _layoutEngine.ComputeLayout(processedTemplate); @@ -221,7 +221,7 @@ public async Task Render( cancellationToken.ThrowIfCancellationRequested(); var processedTemplate = await _renderingEngine.ProcessTemplate(layoutTemplate, data, _defaultRenderOptions).ConfigureAwait(false); - _preprocessor.RegisterFonts(processedTemplate); + await _preprocessor.RegisterFontsAsync(processedTemplate, cancellationToken).ConfigureAwait(false); var imageCache = await _renderingEngine.PreloadImagesFromProcessedAsync(processedTemplate, cancellationToken).ConfigureAwait(false); @@ -282,7 +282,7 @@ public async Task Render( cancellationToken.ThrowIfCancellationRequested(); var processedTemplate = await _renderingEngine.ProcessTemplate(layoutTemplate, data, _defaultRenderOptions).ConfigureAwait(false); - _preprocessor.RegisterFonts(processedTemplate); + await _preprocessor.RegisterFontsAsync(processedTemplate, cancellationToken).ConfigureAwait(false); var imageCache = await _renderingEngine.PreloadImagesFromProcessedAsync(processedTemplate, cancellationToken).ConfigureAwait(false); @@ -330,7 +330,7 @@ public async Task RenderToPng( cancellationToken.ThrowIfCancellationRequested(); var processedTemplate = await _renderingEngine.ProcessTemplate(layoutTemplate, data, renderOptions).ConfigureAwait(false); - _preprocessor.RegisterFonts(processedTemplate); + await _preprocessor.RegisterFontsAsync(processedTemplate, cancellationToken).ConfigureAwait(false); var imageCache = await _renderingEngine.PreloadImagesFromProcessedAsync(processedTemplate, cancellationToken).ConfigureAwait(false); @@ -345,7 +345,8 @@ public async Task RenderToPng( ? new SKSize(rootNode.Height, rootNode.Width) : new SKSize(rootNode.Width, rootNode.Height); - using var bitmap = new SKBitmap((int)size.Width, (int)size.Height); + // Ensure minimum 1px dimensions to avoid invalid bitmap (e.g. auto-height with no content) + using var bitmap = new SKBitmap(Math.Max(1, (int)size.Width), Math.Max(1, (int)size.Height)); _renderingEngine.RenderToBitmapCore(bitmap, processedTemplate, rootNode, default, imageCache, renderOptions); using var image = SKImage.FromBitmap(bitmap); @@ -395,7 +396,7 @@ public async Task RenderToJpeg( cancellationToken.ThrowIfCancellationRequested(); var processedTemplate = await _renderingEngine.ProcessTemplate(layoutTemplate, data, renderOptions).ConfigureAwait(false); - _preprocessor.RegisterFonts(processedTemplate); + await _preprocessor.RegisterFontsAsync(processedTemplate, cancellationToken).ConfigureAwait(false); var imageCache = await _renderingEngine.PreloadImagesFromProcessedAsync(processedTemplate, cancellationToken).ConfigureAwait(false); @@ -453,7 +454,7 @@ public async Task RenderToBmp( cancellationToken.ThrowIfCancellationRequested(); var processedTemplate = await _renderingEngine.ProcessTemplate(layoutTemplate, data, renderOptions).ConfigureAwait(false); - _preprocessor.RegisterFonts(processedTemplate); + await _preprocessor.RegisterFontsAsync(processedTemplate, cancellationToken).ConfigureAwait(false); var imageCache = await _renderingEngine.PreloadImagesFromProcessedAsync(processedTemplate, cancellationToken).ConfigureAwait(false); @@ -507,7 +508,7 @@ public async Task RenderToRaw( cancellationToken.ThrowIfCancellationRequested(); var processedTemplate = await _renderingEngine.ProcessTemplate(layoutTemplate, data, renderOptions).ConfigureAwait(false); - _preprocessor.RegisterFonts(processedTemplate); + await _preprocessor.RegisterFontsAsync(processedTemplate, cancellationToken).ConfigureAwait(false); var imageCache = await _renderingEngine.PreloadImagesFromProcessedAsync(processedTemplate, cancellationToken).ConfigureAwait(false); diff --git a/src/FlexRender.Skia.Render/Rendering/TemplatePreprocessor.cs b/src/FlexRender.Skia.Render/Rendering/TemplatePreprocessor.cs index 2100734..f05d2da 100644 --- a/src/FlexRender.Skia.Render/Rendering/TemplatePreprocessor.cs +++ b/src/FlexRender.Skia.Render/Rendering/TemplatePreprocessor.cs @@ -28,31 +28,51 @@ internal TemplatePreprocessor( /// /// Registers all fonts defined in the template with the font manager. + /// Tries file system first, then falls back to resource loaders (async) for WASM support. /// /// The processed template containing font definitions. - internal void RegisterFonts(Template template) + /// Cancellation token. + internal async Task RegisterFontsAsync(Template template, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(template); - RegisterTemplateFonts(template); + await RegisterTemplateFontsAsync(template, cancellationToken).ConfigureAwait(false); } /// /// Registers all fonts defined in the template with the font manager. /// If a font named "default" is defined, it is also registered as "main" /// to serve as the default font for elements without an explicit font specification. + /// Falls back to resource loaders when font file is not found on disk. /// /// The template containing font definitions. - private void RegisterTemplateFonts(Template template) + /// Cancellation token. + private async Task RegisterTemplateFontsAsync(Template template, CancellationToken cancellationToken) { foreach (var (fontName, fontDef) in template.Fonts) { var resolvedPath = ResolveFontPath(fontDef.Path); - _fontManager.RegisterFont(fontName, resolvedPath, fontDef.Fallback); + var registered = _fontManager.RegisterFont(fontName, resolvedPath, fontDef.Fallback); + + // If file not found on disk, try resource loaders (e.g. MemoryResourceLoader for WASM) + if (!registered) + { + if (!await _fontManager.PreloadFontFromResourcesAsync(fontName, resolvedPath, cancellationToken).ConfigureAwait(false)) + { + await _fontManager.PreloadFontFromResourcesAsync(fontName, fontDef.Path, cancellationToken).ConfigureAwait(false); + } + } // Register "default" font also as "main" for elements without explicit font if (string.Equals(fontName, "default", StringComparison.OrdinalIgnoreCase)) { - _fontManager.RegisterFont("main", resolvedPath, fontDef.Fallback); + var mainRegistered = _fontManager.RegisterFont("main", resolvedPath, fontDef.Fallback); + if (!mainRegistered) + { + if (!await _fontManager.PreloadFontFromResourcesAsync("main", resolvedPath, cancellationToken).ConfigureAwait(false)) + { + await _fontManager.PreloadFontFromResourcesAsync("main", fontDef.Path, cancellationToken).ConfigureAwait(false); + } + } } } } From ff83e9af4f46e89a6d2cf7ec5490f43b7e9db770 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 10 Mar 2026 10:42:21 +0300 Subject: [PATCH 08/25] feat(playground): add unified LoadResource/RemoveResource/ListResources API --- .../MemoryResourceLoader.cs | 8 + src/FlexRender.Playground/PlaygroundApi.cs | 422 ++++++++++++++++++ 2 files changed, 430 insertions(+) diff --git a/src/FlexRender.Playground/MemoryResourceLoader.cs b/src/FlexRender.Playground/MemoryResourceLoader.cs index db3b44e..6473afc 100644 --- a/src/FlexRender.Playground/MemoryResourceLoader.cs +++ b/src/FlexRender.Playground/MemoryResourceLoader.cs @@ -81,6 +81,14 @@ public void Clear() _resources.Clear(); } + /// + /// Returns all stored resource paths. + /// + public IReadOnlyList ListResources() + { + return [.. _resources.Keys]; + } + /// /// Strips leading "./" or "/" from a path to produce a canonical lookup key. /// diff --git a/src/FlexRender.Playground/PlaygroundApi.cs b/src/FlexRender.Playground/PlaygroundApi.cs index 8c06b35..567804c 100644 --- a/src/FlexRender.Playground/PlaygroundApi.cs +++ b/src/FlexRender.Playground/PlaygroundApi.cs @@ -1,11 +1,17 @@ using System.Reflection; using System.Runtime.InteropServices.JavaScript; using System.Text.Json; +using System.Text.Json.Nodes; using FlexRender.Abstractions; using FlexRender.Configuration; using FlexRender.Content.Ndc; +using FlexRender.Layout; using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using FlexRender.Skia; using FlexRender.Yaml; +using SkiaSharp; namespace FlexRender.Playground; @@ -85,6 +91,83 @@ public static byte[] RenderToPng(string yaml, string? dataJson) } } + /// + /// Renders a YAML template to PNG bytes with a debug overlay showing layout boundaries, + /// element type colors, and per-glyph text boundaries. + /// + /// YAML template string. + /// Optional JSON object with template data, or null. + /// PNG image bytes with debug overlay, or an empty array on error. + [JSExport] + public static byte[] RenderDebugPng(string yaml, string? dataJson) + { + try + { + if (_render is not SkiaRender skiaRender) + { + Console.Error.WriteLine("PlaygroundApi not initialized or not using SkiaRender."); + return []; + } + + _parser ??= new TemplateParser(); + var template = _parser.Parse(yaml); + + ObjectValue data = new(); + if (!string.IsNullOrWhiteSpace(dataJson)) + { + data = ParseJsonData(dataJson); + } + + // Render normal PNG + var pngBytes = skiaRender.RenderToPng(template, data) + .GetAwaiter() + .GetResult(); + + // Compute layout for debug overlay + var root = skiaRender.ComputeLayout(template, data) + .GetAwaiter() + .GetResult(); + + var size = skiaRender.Measure(template, data) + .GetAwaiter() + .GetResult(); + + var bitmapWidth = Math.Max(1, (int)Math.Ceiling(size.Width)); + var bitmapHeight = Math.Max(1, (int)Math.Ceiling(size.Height)); + + using var bitmap = new SKBitmap(bitmapWidth, bitmapHeight); + using var canvas = new SKCanvas(bitmap); + + // Draw the rendered PNG onto the canvas + using var rendered = SKBitmap.Decode(pngBytes); + if (rendered is not null) + { + canvas.DrawBitmap(rendered, 0, 0); + } + + // Draw debug overlay + DrawDebugOverlay(canvas, root, 0, 0, skiaRender.FontManager); + + // Encode to PNG + using var image = SKImage.FromBitmap(bitmap); + using var encoded = image.Encode(SKEncodedImageFormat.Png, 100); + if (encoded is null) + { + Console.Error.WriteLine("RenderDebugPng: failed to encode debug image"); + return []; + } + + using var ms = new MemoryStream(); + encoded.SaveTo(ms); + return ms.ToArray(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"RenderDebugPng error: {ex}"); + return []; + } + } + /// /// Validates a YAML template and returns a JSON array of errors. /// @@ -171,6 +254,336 @@ public static void LoadContent(string path, byte[] data) } } + /// + /// Stores a resource (font, image, content, or any file) in the in-memory VFS. + /// + /// Resource path as referenced in the template. + /// Raw resource bytes. + [JSExport] + public static void LoadResource(string path, byte[] data) + { + try + { + _memoryLoader?.AddResource(path, data); + } + catch (Exception ex) + { + Console.Error.WriteLine($"LoadResource error: {ex.Message}"); + } + } + + /// + /// Removes a resource from the in-memory VFS. + /// + /// Resource path to remove. + [JSExport] + public static void RemoveResource(string path) + { + try + { + _memoryLoader?.RemoveResource(path); + } + catch (Exception ex) + { + Console.Error.WriteLine($"RemoveResource error: {ex.Message}"); + } + } + + /// + /// Lists all resource paths currently stored in the in-memory VFS. + /// + /// JSON array of resource path strings. + [JSExport] + public static string ListResources() + { + try + { + var paths = _memoryLoader?.ListResources() ?? []; + return JsonSerializer.Serialize(paths); + } + catch (Exception ex) + { + Console.Error.WriteLine($"ListResources error: {ex.Message}"); + return "[]"; + } + } + + /// + /// Computes the layout tree for a YAML template and returns it as a JSON string. + /// + /// YAML template string. + /// Optional JSON object with template data, or null. + /// JSON string representing the layout tree, or an empty object on error. + [JSExport] + public static string GetLayout(string yaml, string? dataJson) + { + try + { + if (_render is not SkiaRender skiaRender) + { + Console.Error.WriteLine("PlaygroundApi not initialized or not using SkiaRender."); + return "{}"; + } + + _parser ??= new TemplateParser(); + var template = _parser.Parse(yaml); + + ObjectValue data = new(); + if (!string.IsNullOrWhiteSpace(dataJson)) + { + data = ParseJsonData(dataJson); + } + + var layoutNode = skiaRender.ComputeLayout(template, data) + .GetAwaiter() + .GetResult(); + + var json = SerializeLayoutNode(layoutNode); + return json.ToJsonString(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"GetLayout error: {ex}"); + return "{}"; + } + } + + private static JsonObject SerializeLayoutNode(LayoutNode node) + { + var obj = new JsonObject + { + ["type"] = node.Element.Type.ToString().ToLowerInvariant(), + ["x"] = Math.Round(node.X, 1), + ["y"] = Math.Round(node.Y, 1), + ["w"] = Math.Round(node.Width, 1), + ["h"] = Math.Round(node.Height, 1) + }; + + // Computed properties + if (node.ComputedFontSize > 0) + obj["fontSize"] = Math.Round(node.ComputedFontSize, 1); + + if (node.Baseline > 0) + obj["baseline"] = Math.Round(node.Baseline, 1); + + if (node.ComputedLineHeight > 0) + obj["lineHeight"] = Math.Round(node.ComputedLineHeight, 1); + + if (node.TextLines is not null) + obj["textLines"] = node.TextLines.Count; + + if (node.Direction != TextDirection.Ltr) + obj["direction"] = node.Direction.ToString().ToLowerInvariant(); + + // Element-specific properties + SerializeElementProperties(obj, node.Element); + + var children = new JsonArray(); + foreach (var child in node.Children) + { + children.Add(SerializeLayoutNode(child)); + } + + obj["children"] = children; + return obj; + } + + /// + /// Adds element-specific properties to the JSON object based on the element type. + /// + /// The JSON object to add properties to. + /// The template element. + private static void SerializeElementProperties(JsonObject obj, TemplateElement element) + { + switch (element) + { + case FlexElement f: + obj["direction"] = f.Direction.ToString().ToLowerInvariant(); + if (!string.IsNullOrEmpty(f.FontSize.Value)) + obj["fontSizeSpec"] = f.FontSize.Value; + if (f.Align.Value != AlignItems.Stretch) + obj["align"] = f.Align.Value.ToString().ToLowerInvariant(); + if (f.Justify.Value != JustifyContent.Start) + obj["justify"] = f.Justify.Value.ToString().ToLowerInvariant(); + break; + + case TextElement t: + obj["content"] = Truncate(t.Content.Value, 50); + if (!string.IsNullOrEmpty(t.Size.Value)) + obj["size"] = t.Size.Value; + if (!string.IsNullOrEmpty(t.Font.Value)) + obj["font"] = t.Font.Value; + if (!string.IsNullOrEmpty(t.FontFamily.Value)) + obj["fontFamily"] = t.FontFamily.Value; + if (t.FontWeight.Value != Parsing.Ast.FontWeight.Normal) + obj["fontWeight"] = t.FontWeight.Value.ToString().ToLowerInvariant(); + if (t.FontStyle.Value != Parsing.Ast.FontStyle.Normal) + obj["fontStyle"] = t.FontStyle.Value.ToString().ToLowerInvariant(); + if (!string.IsNullOrEmpty(t.Color.Value)) + obj["color"] = t.Color.Value; + break; + + case ImageElement: + obj["indicator"] = "image"; + break; + + case QrElement: + obj["indicator"] = "qr"; + break; + + case BarcodeElement: + obj["indicator"] = "barcode"; + break; + + case SeparatorElement: + obj["indicator"] = "separator"; + break; + + case SvgElement: + obj["indicator"] = "svg"; + break; + + case TableElement: + obj["indicator"] = "table"; + break; + + case EachElement: + obj["indicator"] = "each"; + break; + + case IfElement: + obj["indicator"] = "if"; + break; + + case ContentElement: + obj["indicator"] = "content"; + break; + } + } + + /// + /// Draws debug overlay rectangles on the canvas showing element boundaries + /// with colors coded by element type. + /// + /// The canvas to draw on. + /// The current layout node. + /// The X offset from parent. + /// The Y offset from parent. + /// The font manager for creating fonts to measure glyph widths. + private static void DrawDebugOverlay(SKCanvas canvas, LayoutNode node, float offsetX, float offsetY, FontManager fontManager) + { + var x = node.X + offsetX; + var y = node.Y + offsetY; + + // Choose color based on element type + var color = node.Element switch + { + FlexElement => SKColors.Blue, + TextElement => SKColors.Green, + QrElement => SKColors.Purple, + BarcodeElement => SKColors.Orange, + ImageElement => SKColors.Cyan, + _ => SKColors.Gray + }; + + // Draw fill for flex containers + if (node.Element is FlexElement) + { + using var fillPaint = new SKPaint + { + Color = color.WithAlpha(30), + Style = SKPaintStyle.Fill + }; + canvas.DrawRect(x, y, node.Width, node.Height, fillPaint); + } + + // Draw stroke + using var strokePaint = new SKPaint + { + Color = color.WithAlpha(180), + Style = SKPaintStyle.Stroke, + StrokeWidth = 1 + }; + + canvas.DrawRect(x, y, node.Width, node.Height, strokePaint); + + // Draw per-glyph boundaries for text elements + if (node.Element is TextElement text && !string.IsNullOrEmpty(text.Content.Value)) + { + DrawGlyphBoundaries(canvas, text, x, y, node, fontManager); + } + + // Recursively draw children + foreach (var child in node.Children) + { + DrawDebugOverlay(canvas, child, x, y, fontManager); + } + } + + /// + /// Draws per-glyph boundary rectangles for a text element. + /// Spaces are highlighted with a distinct color to make whitespace visible. + /// + /// The canvas to draw on. + /// The text element. + /// The absolute X position of the text element. + /// The absolute Y position of the text element. + /// The layout node for the text element. + /// The font manager for creating fonts. + private static void DrawGlyphBoundaries( + SKCanvas canvas, + TextElement text, + float x, + float y, + LayoutNode node, + FontManager fontManager) + { + var fontSize = node.ComputedFontSize > 0 ? node.ComputedFontSize : 16f; + var typeface = fontManager.GetTypeface(text.Font.Value, text.FontFamily.Value, text.FontWeight.Value, text.FontStyle.Value); + using var font = new SKFont(typeface, FontSizeResolver.Resolve(text.Size.Value, fontSize)); + + var content = text.Content.Value; + + // Compute per-character advance widths using cumulative measurement. + // GetGlyphWidths returns ink bounds (visual shape width), not advance width, + // so spaces would appear zero-width. Cumulative MeasureText gives correct advances. + var advances = new float[content.Length]; + float prevWidth = 0; + for (var i = 0; i < content.Length; i++) + { + var cumWidth = font.MeasureText(content.AsSpan(0, i + 1)); + advances[i] = cumWidth - prevWidth; + prevWidth = cumWidth; + } + + using var glyphStroke = new SKPaint + { + Color = SKColors.Red.WithAlpha(100), + Style = SKPaintStyle.Stroke, + StrokeWidth = 0.5f + }; + using var spaceFill = new SKPaint + { + Color = SKColors.Yellow.WithAlpha(60), + Style = SKPaintStyle.Fill + }; + + var glyphX = x; + for (var i = 0; i < advances.Length; i++) + { + var w = advances[i]; + + // Highlight spaces with yellow fill + if (content[i] == ' ') + { + canvas.DrawRect(glyphX, y, w, node.Height, spaceFill); + } + + canvas.DrawRect(glyphX, y, w, node.Height, glyphStroke); + glyphX += w; + } + } + private static void LoadEmbeddedFont(string resourceName) { var assembly = Assembly.GetExecutingAssembly(); @@ -240,4 +653,13 @@ private static ObjectValue ConvertObject(JsonElement element) return obj; } + + /// + /// Truncates a string to the specified maximum length, appending ellipsis if needed. + /// + /// The string to truncate. + /// The maximum length. + /// The truncated string with ellipsis if it exceeded the limit. + private static string Truncate(string s, int max) => + s.Length <= max ? s : s[..(max - 3)] + "..."; } From 446082b73493a9a6758b4a324f4cdf8fb95dcb2f Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 10 Mar 2026 10:42:56 +0300 Subject: [PATCH 09/25] feat(playground): add VFS module with IndexedDB persistence --- src/FlexRender.Playground/wwwroot/vfs.mjs | 234 ++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 src/FlexRender.Playground/wwwroot/vfs.mjs diff --git a/src/FlexRender.Playground/wwwroot/vfs.mjs b/src/FlexRender.Playground/wwwroot/vfs.mjs new file mode 100644 index 0000000..9bcfecb --- /dev/null +++ b/src/FlexRender.Playground/wwwroot/vfs.mjs @@ -0,0 +1,234 @@ +// Virtual File System for FlexRender Playground. +// Manages an in-memory Map with IndexedDB persistence. + +const DB_NAME = 'flexrender-vfs'; +const DB_VERSION = 1; +const STORE_NAME = 'files'; + +/** @type {Map} */ +const files = new Map(); + +/** @type {Set<(event: string, path: string) => void>} */ +const listeners = new Set(); + +const FONT_EXT = new Set(['.ttf', '.otf', '.woff2', '.woff']); +const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.svg', '.gif', '.webp', '.bmp']); +const CONTENT_EXT = new Set(['.ndc', '.txt', '.html', '.md']); + +/** Detect resource type from file extension. */ +export function detectType(path) { + const ext = ('.' + path.split('.').pop()).toLowerCase(); + if (FONT_EXT.has(ext)) return 'font'; + if (IMAGE_EXT.has(ext)) return 'image'; + if (CONTENT_EXT.has(ext)) return 'content'; + return 'other'; +} + +/** Normalize path: strip leading ./ or /, collapse double slashes. */ +function normalizePath(p) { + p = p.replace(/\\/g, '/'); + if (p.startsWith('./')) p = p.slice(2); + if (p.startsWith('/')) p = p.slice(1); + return p.replace(/\/+/g, '/'); +} + +/** Subscribe to VFS changes. Callback receives (event, path). Events: 'add', 'remove', 'rename', 'clear'. */ +export function subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); +} + +function notify(event, path) { + for (const fn of listeners) { + try { fn(event, path); } catch (e) { console.warn('VFS listener error:', e); } + } +} + +// --- IndexedDB helpers --- + +function openDb() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function dbPut(path, entry) { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).put({ data: entry.data.buffer, type: entry.type }, path); + tx.oncomplete = () => { db.close(); resolve(); }; + tx.onerror = () => { db.close(); reject(tx.error); }; + }); +} + +async function dbDelete(path) { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).delete(path); + tx.oncomplete = () => { db.close(); resolve(); }; + tx.onerror = () => { db.close(); reject(tx.error); }; + }); +} + +async function dbClear() { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).clear(); + tx.oncomplete = () => { db.close(); resolve(); }; + tx.onerror = () => { db.close(); reject(tx.error); }; + }); +} + +async function dbGetAll() { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const req = store.openCursor(); + const entries = []; + req.onsuccess = () => { + const cursor = req.result; + if (cursor) { + entries.push({ path: cursor.key, data: new Uint8Array(cursor.value.data), type: cursor.value.type }); + cursor.continue(); + } else { + db.close(); + resolve(entries); + } + }; + req.onerror = () => { db.close(); reject(req.error); }; + }); +} + +// --- Public API --- + +/** Add or overwrite a file. */ +export async function addFile(path, data, type) { + path = normalizePath(path); + if (!type) type = detectType(path); + files.set(path, { data, type }); + await dbPut(path, { data, type }); + notify('add', path); +} + +/** Remove a file. */ +export async function removeFile(path) { + path = normalizePath(path); + if (!files.has(path)) return; + files.delete(path); + await dbDelete(path); + notify('remove', path); +} + +/** Rename/move a file. */ +export async function renameFile(oldPath, newPath) { + oldPath = normalizePath(oldPath); + newPath = normalizePath(newPath); + const entry = files.get(oldPath); + if (!entry) return; + files.delete(oldPath); + files.set(newPath, entry); + await dbDelete(oldPath); + await dbPut(newPath, entry); + notify('rename', oldPath); + notify('add', newPath); +} + +/** Get a file entry. */ +export function getFile(path) { + return files.get(normalizePath(path)) || null; +} + +/** Get all file paths sorted. */ +export function listFiles() { + return [...files.keys()].sort(); +} + +/** Get all entries as [{path, data, type}]. */ +export function allEntries() { + return [...files.entries()].map(([path, entry]) => ({ path, ...entry })); +} + +/** Check if a path exists. */ +export function exists(path) { + return files.has(normalizePath(path)); +} + +/** Clear all files. */ +export async function clearAll() { + files.clear(); + await dbClear(); + notify('clear', ''); +} + +/** Load all files from IndexedDB into memory. Call once at startup. */ +export async function restore() { + try { + const entries = await dbGetAll(); + for (const e of entries) { + files.set(e.path, { data: e.data, type: e.type }); + } + return entries.length; + } catch (err) { + console.warn('VFS restore from IndexedDB failed:', err); + return 0; + } +} + +/** + * Build a tree structure from flat file paths. + * Returns: { name, path, children, isDir, type? }[] + */ +export function buildTree() { + const root = []; + + for (const [path, entry] of files) { + const parts = path.split('/'); + let current = root; + + for (let i = 0; i < parts.length; i++) { + const name = parts[i]; + const isLast = i === parts.length - 1; + const partialPath = parts.slice(0, i + 1).join('/'); + + let node = current.find(n => n.name === name); + if (!node) { + node = { + name, + path: partialPath, + isDir: !isLast, + children: [], + type: isLast ? entry.type : undefined, + }; + current.push(node); + } + if (!isLast) { + node.isDir = true; + current = node.children; + } + } + } + + function sortTree(nodes) { + nodes.sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + for (const n of nodes) { + if (n.children.length) sortTree(n.children); + } + } + sortTree(root); + return root; +} From 72be3f09c3c24735151e4fc06947aca2e070eb00 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 10 Mar 2026 10:43:01 +0300 Subject: [PATCH 10/25] feat(playground): add splitter module for resizable panels --- .../wwwroot/splitter.mjs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/FlexRender.Playground/wwwroot/splitter.mjs diff --git a/src/FlexRender.Playground/wwwroot/splitter.mjs b/src/FlexRender.Playground/wwwroot/splitter.mjs new file mode 100644 index 0000000..5afb484 --- /dev/null +++ b/src/FlexRender.Playground/wwwroot/splitter.mjs @@ -0,0 +1,96 @@ +// Draggable splitter logic for resizable panels. +// Reads data-split attribute from .splitter elements to identify panel pairs. + +const STORAGE_KEY = 'flexrender-panel-sizes'; + +/** @type {Record} */ +let savedSizes = {}; + +try { + savedSizes = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); +} catch { /* ignore */ } + +function saveSizes() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(savedSizes)); +} + +/** + * Initialize all splitters in the document. + * Call once after DOM is ready. + * + * @param {Record} config + */ +export function initSplitters(config) { + for (const [name, { before, after, container, direction }] of Object.entries(config)) { + const splitter = document.querySelector(`.splitter[data-split="${name}"]`); + if (!splitter) continue; + + // Restore saved size + if (savedSizes[name] !== undefined) { + applySavedSize(before, after, container, direction, savedSizes[name]); + } + + let startPos = 0; + let startSize = 0; + + function onMouseDown(e) { + e.preventDefault(); + startPos = direction === 'v' ? e.clientX : e.clientY; + startSize = direction === 'v' ? before.getBoundingClientRect().width : before.getBoundingClientRect().height; + splitter.classList.add('active'); + document.body.style.cursor = direction === 'v' ? 'col-resize' : 'row-resize'; + document.body.style.userSelect = 'none'; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + } + + function onMouseMove(e) { + const currentPos = direction === 'v' ? e.clientX : e.clientY; + const delta = currentPos - startPos; + const containerSize = direction === 'v' + ? container.getBoundingClientRect().width + : container.getBoundingClientRect().height; + + const newSize = Math.max(50, Math.min(containerSize - 50, startSize + delta)); + const pct = (newSize / containerSize) * 100; + + before.style.flex = `0 0 ${pct}%`; + after.style.flex = '1 1 0%'; + savedSizes[name] = pct; + } + + function onMouseUp() { + splitter.classList.remove('active'); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + saveSizes(); + window.dispatchEvent(new Event('resize')); + } + + splitter.addEventListener('mousedown', onMouseDown); + } +} + +function applySavedSize(before, after, container, direction, pct) { + before.style.flex = `0 0 ${pct}%`; + after.style.flex = '1 1 0%'; +} + +/** + * Set up collapsible sections. Clicking .collapsible labels toggles the parent .editor-section. + */ +export function initCollapsible() { + document.querySelectorAll('.editor-label.collapsible').forEach(label => { + label.addEventListener('click', (e) => { + if (e.target.closest('.files-toolbar')) return; + const section = label.closest('.editor-section'); + if (section) { + section.classList.toggle('collapsed'); + window.dispatchEvent(new Event('resize')); + } + }); + }); +} From 2266b1c56b47284c01ac019e7e9904ac3721a50c Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 10 Mar 2026 10:44:35 +0300 Subject: [PATCH 11/25] feat(playground): add Files panel and splitter markup to HTML --- src/FlexRender.Playground/wwwroot/index.html | 50 ++++++++++++++++---- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/src/FlexRender.Playground/wwwroot/index.html b/src/FlexRender.Playground/wwwroot/index.html index 2494e19..ef9fe97 100644 --- a/src/FlexRender.Playground/wwwroot/index.html +++ b/src/FlexRender.Playground/wwwroot/index.html @@ -27,30 +27,64 @@

FlexRender Playground

-
-
+
+
Template (YAML)
-
-
Data (JSON)
+
+
+
Data (JSON)
+
+
+
+ Files + + + + +
+
+
+
+
- +
- +
+
- +
+ + +
-
+
+
+
+ Layout Inspector + +
+
+
From 7a45aea02042cedacba73b8ab952a3cf6726503c Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 10 Mar 2026 10:45:07 +0300 Subject: [PATCH 12/25] feat(playground): add CSS for splitters, file tree, context menu, collapsible panels --- src/FlexRender.Playground/wwwroot/style.css | 415 +++++++++++++++++++- 1 file changed, 403 insertions(+), 12 deletions(-) diff --git a/src/FlexRender.Playground/wwwroot/style.css b/src/FlexRender.Playground/wwwroot/style.css index e3b8398..dadd771 100644 --- a/src/FlexRender.Playground/wwwroot/style.css +++ b/src/FlexRender.Playground/wwwroot/style.css @@ -76,9 +76,9 @@ body { .editor-panel { display: flex; flex-direction: column; - width: 50%; - min-width: 300px; - border-right: 1px solid #404040; + flex: 0 0 50%; + min-width: 200px; + border-right: none; } .editor-section { @@ -116,11 +116,25 @@ body { .preview-tabs { display: flex; + align-items: center; background: #252526; border-bottom: 1px solid #404040; flex-shrink: 0; } +.preview-tabs .spacer { flex: 1; } + +#zoom-select { + background: #3c3c3c; + color: #d4d4d4; + border: 1px solid #555; + padding: 2px 6px; + border-radius: 3px; + font-size: 11px; + cursor: pointer; + margin-right: 8px; +} + .preview-tabs button { background: none; color: #888; @@ -148,6 +162,7 @@ body { justify-content: center; background: #1a1a1a; position: relative; + min-height: 0; } .tab-pane { display: none; width: 100%; height: 100%; } @@ -157,12 +172,7 @@ body { justify-content: center; } -#preview-pane img { - max-width: 100%; - max-height: 100%; - object-fit: contain; - image-rendering: auto; -} +/* preview-pane image styles moved to #preview-image-wrap img */ #errors-pane { align-items: flex-start; @@ -180,14 +190,193 @@ body { color: #4ec9b0; } +/* --- Layout inspector section (below preview) --- */ +#layout-section { + display: flex; + flex-direction: column; + border-top: 1px solid #404040; + flex-shrink: 0; + max-height: 40%; + transition: max-height 0.2s; +} + +#layout-section.collapsed { + max-height: 28px; +} + +#layout-section.collapsed #layout-pane { + display: none; +} + +#layout-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 12px; + background: #252526; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #888; + cursor: pointer; + user-select: none; + flex-shrink: 0; +} + +#layout-header:hover { + color: #bbb; +} + +#layout-toggle { + background: none; + border: none; + color: #888; + font-size: 10px; + cursor: pointer; + padding: 0 4px; + transition: transform 0.2s; +} + +#layout-section.collapsed #layout-toggle { + transform: rotate(-90deg); +} + #layout-pane { - align-items: flex-start; - justify-content: flex-start; - padding: 12px; + padding: 8px 12px; font-family: 'Cascadia Code', 'Fira Code', monospace; font-size: 12px; overflow: auto; color: #9cdcfe; + flex: 1; + min-height: 60px; +} + +#layout-pane:empty::after { + content: 'No layout data — render a template first'; + color: #666; +} + +/* --- Preview image wrapper with highlight overlay --- */ +#preview-image-wrap { + position: relative; + display: inline-block; + max-width: 100%; + max-height: 100%; +} + +#preview-image-wrap img { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +#highlight-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 10; +} + +.layout-tree { + width: 100%; + font-family: 'Cascadia Code', 'Fira Code', monospace; + font-size: 12px; + line-height: 1.6; +} + +.layout-tree details { + margin-left: 16px; +} + +.layout-tree > details { + margin-left: 0; +} + +.layout-tree summary { + cursor: pointer; + list-style: none; + padding: 1px 4px; + border-radius: 3px; +} + +.layout-tree summary:hover, +.layout-tree .layout-leaf:hover { + background: rgba(0, 122, 204, 0.15); +} + +.layout-tree summary::before { + content: '\25B6'; + display: inline-block; + width: 14px; + font-size: 9px; + transition: transform 0.15s; +} + +.layout-tree details[open] > summary::before { + transform: rotate(90deg); +} + +.layout-leaf { + margin-left: 16px; + padding: 1px 4px 1px 18px; +} + +.node-type { + font-weight: 600; + border-radius: 3px; + padding: 0 4px; +} + +.node-type[data-type="flex"] { color: #4caf50; } +.node-type[data-type="text"] { color: #2196f3; } +.node-type[data-type="image"] { color: #ff9800; } +.node-type[data-type="qr"] { color: #9c27b0; } +.node-type[data-type="barcode"] { color: #795548; } +.node-type[data-type="separator"] { color: #9e9e9e; } +.node-type[data-type="each"] { color: #00bcd4; } +.node-type[data-type="if"] { color: #ffeb3b; } +.node-type[data-type="table"] { color: #e91e63; } +.node-type[data-type="svg"] { color: #3f51b5; } +.node-type[data-type="content"] { color: #ff5722; } + +.node-dims { + color: #666; + font-weight: 400; +} + +.node-props { + color: #888; + font-weight: 400; + font-size: 11px; +} + +.layout-error { + color: #f48771; + padding: 8px; +} + +/* Debug overlay toggle */ +#overlay-toggle { + font-size: 11px; + color: #aaa; + display: flex; + align-items: center; + gap: 4px; + margin-right: 8px; + cursor: pointer; + user-select: none; +} + +#overlay-toggle:hover { + color: #ddd; +} + +#overlay-toggle input { + cursor: pointer; } .status-bar { @@ -217,3 +406,205 @@ body { } .drop-overlay.visible { display: flex; } + +/* --- Draggable splitters --- */ +.splitter-v { + width: 5px; + cursor: col-resize; + background: #404040; + flex-shrink: 0; + transition: background 0.15s; +} + +.splitter-h { + height: 5px; + cursor: row-resize; + background: #404040; + flex-shrink: 0; + transition: background 0.15s; +} + +.splitter-v:hover, .splitter-h:hover, +.splitter-v.active, .splitter-h.active { + background: #007acc; +} + +/* --- Collapsible editor sections --- */ +.editor-label.collapsible { + cursor: pointer; + user-select: none; + display: flex; + align-items: center; + gap: 4px; +} + +.editor-label.collapsible:hover { + color: #bbb; +} + +.collapse-icon { + font-size: 10px; + transition: transform 0.2s; +} + +.editor-section.collapsed .collapse-icon { + transform: rotate(-90deg); +} + +.editor-section.collapsed .editor-container, +.editor-section.collapsed .files-container { + display: none; +} + +.editor-section.collapsed { + flex: 0 0 auto !important; +} + +/* --- Files panel --- */ +.files-container { + flex: 1; + overflow: auto; + padding: 4px 0; +} + +.files-toolbar { + margin-left: auto; +} + +.files-toolbar button { + background: none; + border: none; + color: #888; + font-size: 12px; + cursor: pointer; + padding: 0 4px; +} + +.files-toolbar button:hover { + color: #ddd; +} + +/* --- File tree --- */ +.files-tree { + font-size: 12px; + line-height: 1.6; + padding: 0 8px; +} + +.files-tree:empty::after { + content: 'Drop files here or click + to add a folder'; + color: #666; + padding: 8px; + display: block; +} + +.ft-node { + cursor: default; + padding: 1px 4px; + border-radius: 3px; + white-space: nowrap; + display: flex; + align-items: center; + gap: 4px; +} + +.ft-node:hover { + background: rgba(0, 122, 204, 0.15); +} + +.ft-node.selected { + background: rgba(0, 122, 204, 0.3); +} + +.ft-node.drag-over { + outline: 1px dashed #007acc; +} + +.ft-icon { + font-size: 14px; + width: 16px; + text-align: center; + flex-shrink: 0; +} + +.ft-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.ft-name-input { + background: #3c3c3c; + color: #d4d4d4; + border: 1px solid #007acc; + font-size: 12px; + padding: 0 4px; + outline: none; + width: 100%; +} + +.ft-children { + margin-left: 16px; +} + +.ft-dir > .ft-node .ft-arrow { + display: inline-block; + width: 10px; + font-size: 8px; + transition: transform 0.15s; + text-align: center; + flex-shrink: 0; +} + +.ft-dir.open > .ft-node .ft-arrow { + transform: rotate(90deg); +} + +.ft-dir:not(.open) > .ft-children { + display: none; +} + +.ft-size { + color: #666; + font-size: 10px; + margin-left: auto; + flex-shrink: 0; +} + +/* --- Context menu --- */ +.ctx-menu { + position: fixed; + background: #2d2d2d; + border: 1px solid #555; + border-radius: 4px; + padding: 4px 0; + min-width: 160px; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + font-size: 12px; +} + +.ctx-menu-item { + padding: 4px 16px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; +} + +.ctx-menu-item:hover { + background: #094771; +} + +.ctx-menu-sep { + height: 1px; + background: #404040; + margin: 4px 0; +} + +.ctx-menu-shortcut { + color: #666; + margin-left: auto; + font-size: 11px; +} From 0b9339c9a1dd58660653a98bc08fc88ec794ef07 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 10 Mar 2026 10:49:10 +0300 Subject: [PATCH 13/25] feat(playground): wire VFS tree UI, context menu, drag & drop, splitters into main.js --- src/FlexRender.Playground/wwwroot/main.js | 621 ++++++++++++++++++++-- 1 file changed, 563 insertions(+), 58 deletions(-) diff --git a/src/FlexRender.Playground/wwwroot/main.js b/src/FlexRender.Playground/wwwroot/main.js index ae5fe21..9ed1f13 100644 --- a/src/FlexRender.Playground/wwwroot/main.js +++ b/src/FlexRender.Playground/wwwroot/main.js @@ -11,25 +11,36 @@ const api = exports.FlexRender.Playground.PlaygroundApi; await runMain(); -// --- Load Monaco Editor from CDN --- -const MONACO_CDN = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min'; - -function loadScript(src) { - return new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = src; - script.onload = resolve; - script.onerror = reject; - document.head.appendChild(script); - }); -} +// --- VFS & Splitter modules --- +import * as vfs from './vfs.mjs'; +import { initSplitters, initCollapsible } from './splitter.mjs'; + +// --- Monaco Editor via modern-monaco from CDN --- +const { init } = await import('https://esm.sh/modern-monaco'); +const monaco = await init({ + langs: ['yaml', 'json'], + themes: ['one-dark-pro'], +}); -window.require = { paths: { vs: `${MONACO_CDN}/vs` } }; -await loadScript(`${MONACO_CDN}/vs/loader.js`); +// --- Custom YAML autocomplete (schema-driven, no workers) --- +try { + const { registerYamlAutocomplete } = await import('./yaml-autocomplete.mjs'); + const schemaResponse = await fetch('schemas/flexrender-template.json'); + const flexrenderSchema = await schemaResponse.json(); + registerYamlAutocomplete(monaco, flexrenderSchema); + console.log('YAML autocomplete registered with FlexRender schema'); +} catch (e) { + console.warn('YAML autocomplete setup failed:', e.message); +} -const monaco = await new Promise((resolve) => { - window.require(['vs/editor/editor.main'], () => resolve(window.monaco)); -}); +// --- Restore VFS from IndexedDB and sync to WASM --- +const restoredCount = await vfs.restore(); +if (restoredCount > 0) { + for (const entry of vfs.allEntries()) { + api.LoadResource(entry.path, entry.data); + } + console.log(`Restored ${restoredCount} files from IndexedDB`); +} // --- Built-in examples --- const EXAMPLES = { @@ -128,21 +139,28 @@ layout: const defaultYaml = EXAMPLES['Simple Text'].yaml; const defaultJson = '{}'; +const yamlModelUri = monaco.Uri.parse('file:///template.yaml'); +const yamlModel = monaco.editor.createModel(defaultYaml, 'yaml', yamlModelUri); + const yamlEditor = monaco.editor.create(document.getElementById('yaml-editor'), { - value: defaultYaml, - language: 'yaml', - theme: 'vs-dark', + model: yamlModel, + theme: 'one-dark-pro', minimap: { enabled: false }, fontSize: 13, tabSize: 2, automaticLayout: true, scrollBeyondLastLine: false, + quickSuggestions: { + other: true, + comments: false, + strings: true, + }, }); const jsonEditor = monaco.editor.create(document.getElementById('json-editor'), { value: defaultJson, language: 'json', - theme: 'vs-dark', + theme: 'one-dark-pro', minimap: { enabled: false }, fontSize: 13, tabSize: 2, @@ -157,14 +175,439 @@ const previewImg = document.getElementById('preview-img'); const errorsPane = document.getElementById('errors-pane'); const layoutPane = document.getElementById('layout-pane'); -// --- Tab switching --- -document.querySelectorAll('.preview-tabs button').forEach(btn => { - btn.addEventListener('click', () => { - document.querySelectorAll('.preview-tabs button').forEach(b => b.classList.remove('active')); - document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); - btn.classList.add('active'); - document.getElementById(`${btn.dataset.tab}-pane`).classList.add('active'); +// --- Debug overlay toggle --- +const previewTabs = document.querySelector('.preview-tabs'); +const zoomSelect = document.getElementById('zoom-select'); + +const overlayToggle = document.createElement('label'); +overlayToggle.id = 'overlay-toggle'; +overlayToggle.innerHTML = ' Debug overlay'; +previewTabs.insertBefore(overlayToggle, zoomSelect); + +const overlayCheckbox = document.getElementById('overlay-checkbox'); +let debugMode = false; + +overlayCheckbox.addEventListener('change', () => { + debugMode = overlayCheckbox.checked; + scheduleRender(); +}); + +// --- Layout tree builder (with data attributes for highlight) --- +let canvasWidth = 0; +let canvasHeight = 0; + +function buildLayoutTree(node, depth) { + if (!node || !node.type) return ''; + const dims = `${node.w}\u00d7${node.h} @ (${node.x}, ${node.y})`; + const hasChildren = node.children && node.children.length > 0; + const openAttr = depth < 2 ? ' open' : ''; + const dataAttrs = `data-x="${node.x}" data-y="${node.y}" data-w="${node.w}" data-h="${node.h}"`; + + const props = []; + if (node.content) props.push(`"${node.content}"`); + if (node.font) props.push(`font=${node.font}`); + if (node.fontFamily) props.push(`family=${node.fontFamily}`); + if (node.size) props.push(`size=${node.size}`); + if (node.color) props.push(`color=${node.color}`); + if (node.fontWeight) props.push(`weight=${node.fontWeight}`); + if (node.fontStyle) props.push(`style=${node.fontStyle}`); + if (node.direction && node.type === 'Flex') props.push(node.direction); + if (node.align) props.push(`align=${node.align}`); + if (node.justify) props.push(`justify=${node.justify}`); + if (node.fontSize) props.push(`fontSize=${node.fontSize}px`); + if (node.textLines) props.push(`lines=${node.textLines}`); + + const propsStr = props.length > 0 ? ` [${props.join(', ')}]` : ''; + + if (hasChildren) { + const childrenHtml = node.children.map(c => buildLayoutTree(c, depth + 1)).join(''); + return `${node.type} ${dims}${propsStr}${childrenHtml}`; + } + return `
${node.type} ${dims}${propsStr}
`; +} + +// --- File tree rendering --- +const filesTree = document.getElementById('files-tree'); + +function renderFileTree() { + const tree = vfs.buildTree(); + filesTree.innerHTML = tree.length === 0 ? '' : renderTreeNodes(tree); +} + +function renderTreeNodes(nodes) { + return nodes.map(node => { + if (node.isDir) { + return `
+
+ + 📁 + ${escHtml(node.name)} +
+
${renderTreeNodes(node.children)}
+
`; + } + const icon = node.type === 'font' ? '🔤' : node.type === 'image' ? '🖼' : '📄'; + const file = vfs.getFile(node.path); + const size = file ? formatFileSize(file.data.length) : ''; + return `
+
+ ${icon} + ${escHtml(node.name)} + ${size} +
+
`; + }).join(''); +} + +function escHtml(s) { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function formatFileSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; +} + +// Toggle directory open/closed + click to copy path +filesTree.addEventListener('click', (e) => { + const dir = e.target.closest('.ft-dir'); + const node = e.target.closest('.ft-node'); + if (dir && node && node.dataset.dir) { + dir.classList.toggle('open'); + return; + } + if (node && !node.dataset.dir) { + navigator.clipboard.writeText(node.dataset.path).then(() => { + statusText.textContent = `Copied: ${node.dataset.path}`; + }); + } +}); + +// Re-render tree when VFS changes +vfs.subscribe(() => renderFileTree()); +renderFileTree(); + +// --- New folder button --- +document.getElementById('btn-add-folder').addEventListener('click', (e) => { + e.stopPropagation(); + const name = prompt('Folder name:'); + if (!name || !name.trim()) return; + vfs.addFile(name.trim() + '/.gitkeep', new Uint8Array(0), 'other'); +}); + +// --- Context menu --- +let ctxMenu = null; + +function showContextMenu(x, y, items) { + hideContextMenu(); + ctxMenu = document.createElement('div'); + ctxMenu.className = 'ctx-menu'; + ctxMenu.style.left = x + 'px'; + ctxMenu.style.top = y + 'px'; + + for (const item of items) { + if (item === '---') { + const sep = document.createElement('div'); + sep.className = 'ctx-menu-sep'; + ctxMenu.appendChild(sep); + continue; + } + const el = document.createElement('div'); + el.className = 'ctx-menu-item'; + el.innerHTML = `${item.label}${item.shortcut ? `${item.shortcut}` : ''}`; + el.addEventListener('click', () => { hideContextMenu(); item.action(); }); + ctxMenu.appendChild(el); + } + + document.body.appendChild(ctxMenu); + const rect = ctxMenu.getBoundingClientRect(); + if (rect.right > window.innerWidth) ctxMenu.style.left = (window.innerWidth - rect.width - 4) + 'px'; + if (rect.bottom > window.innerHeight) ctxMenu.style.top = (window.innerHeight - rect.height - 4) + 'px'; +} + +function hideContextMenu() { + if (ctxMenu) { ctxMenu.remove(); ctxMenu = null; } +} + +document.addEventListener('click', hideContextMenu); +document.addEventListener('contextmenu', (e) => { + if (!e.target.closest('.files-tree')) hideContextMenu(); +}); + +filesTree.addEventListener('contextmenu', (e) => { + e.preventDefault(); + const node = e.target.closest('.ft-node'); + if (!node) return; + + const path = node.dataset.path; + const isDir = !!node.dataset.dir; + const items = []; + + items.push({ label: 'Copy path', action: () => navigator.clipboard.writeText(path) }); + items.push({ + label: 'Rename', + action: () => startRename(node, path, isDir), + }); + + if (isDir) { + items.push({ + label: 'New folder inside', + action: () => { + const name = prompt('Folder name:'); + if (name?.trim()) vfs.addFile(path + '/' + name.trim() + '/.gitkeep', new Uint8Array(0), 'other'); + }, + }); + } else { + items.push({ + label: 'Duplicate', + action: () => { + const entry = vfs.getFile(path); + if (!entry) return; + const parts = path.split('/'); + const filename = parts.pop(); + const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''; + const base = ext ? filename.slice(0, -ext.length) : filename; + const newName = base + '-copy' + ext; + const newPath = [...parts, newName].join('/'); + vfs.addFile(newPath, entry.data, entry.type); + }, + }); + } + + items.push('---'); + items.push({ + label: 'Delete', + action: () => { + if (isDir) { + for (const f of vfs.listFiles()) { + if (f.startsWith(path + '/')) vfs.removeFile(f); + } + } else { + vfs.removeFile(path); + } + }, }); + + showContextMenu(e.clientX, e.clientY, items); +}); + +function startRename(node, path, isDir) { + const nameSpan = node.querySelector('.ft-name'); + const oldName = nameSpan.textContent; + const input = document.createElement('input'); + input.className = 'ft-name-input'; + input.value = oldName; + nameSpan.replaceWith(input); + input.focus(); + input.select(); + + function commit() { + const newName = input.value.trim(); + input.replaceWith(nameSpan); + if (!newName || newName === oldName) return; + + if (isDir) { + const prefix = path + '/'; + const parts = path.split('/'); + parts[parts.length - 1] = newName; + const newPrefix = parts.join('/') + '/'; + for (const f of vfs.listFiles()) { + if (f.startsWith(prefix)) { + vfs.renameFile(f, newPrefix + f.slice(prefix.length)); + } + } + } else { + const parts = path.split('/'); + parts[parts.length - 1] = newName; + vfs.renameFile(path, parts.join('/')); + } + } + + input.addEventListener('blur', commit); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); input.blur(); } + if (e.key === 'Escape') { input.value = oldName; input.blur(); } + }); +} + +// --- Internal drag & drop (move files between folders) --- +filesTree.addEventListener('dragstart', (e) => { + const node = e.target.closest('.ft-node'); + if (!node) return; + e.dataTransfer.setData('text/x-vfs-path', node.dataset.path); + e.dataTransfer.effectAllowed = 'move'; +}); + +filesTree.addEventListener('dragover', (e) => { + if (!e.dataTransfer.types.includes('text/x-vfs-path')) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + filesTree.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over')); + const dir = e.target.closest('.ft-dir'); + if (dir) dir.querySelector(':scope > .ft-node')?.classList.add('drag-over'); +}); + +filesTree.addEventListener('dragleave', (e) => { + const node = e.target.closest('.ft-node'); + if (node) node.classList.remove('drag-over'); +}); + +filesTree.addEventListener('drop', (e) => { + filesTree.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over')); + const sourcePath = e.dataTransfer.getData('text/x-vfs-path'); + if (!sourcePath) return; + e.preventDefault(); + + const targetDir = e.target.closest('.ft-dir'); + const targetPath = targetDir ? targetDir.dataset.path : ''; + if (sourcePath === targetPath) return; + + const fileName = sourcePath.split('/').pop(); + const newPath = targetPath ? targetPath + '/' + fileName : fileName; + if (sourcePath === newPath) return; + + const isDir = vfs.listFiles().some(f => f.startsWith(sourcePath + '/')); + if (isDir) { + const prefix = sourcePath + '/'; + const newPrefix = newPath + '/'; + for (const f of vfs.listFiles()) { + if (f.startsWith(prefix)) vfs.renameFile(f, newPrefix + f.slice(prefix.length)); + } + } else { + vfs.renameFile(sourcePath, newPath); + } +}); + +// --- Tab switching (preview / errors only) --- +function switchToTab(tabName) { + document.querySelectorAll('.preview-tabs button').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); + document.querySelector(`[data-tab="${tabName}"]`).classList.add('active'); + document.getElementById(`${tabName}-pane`).classList.add('active'); +} + +document.querySelectorAll('.preview-tabs button').forEach(btn => { + btn.addEventListener('click', () => switchToTab(btn.dataset.tab)); +}); + +// --- Layout inspector toggle --- +const layoutSection = document.getElementById('layout-section'); +document.getElementById('layout-header').addEventListener('click', () => { + layoutSection.classList.toggle('collapsed'); +}); + +// --- Canvas highlight overlay for layout tree hover --- +const highlightCanvas = document.getElementById('highlight-canvas'); +const highlightCtx = highlightCanvas.getContext('2d'); +const previewImageWrap = document.getElementById('preview-image-wrap'); + +function syncCanvasSize() { + // Use the image's CSS (layout) size before any transform scaling. + // Since canvas is inside the same scaled wrapper, we match the un-scaled img size. + const w = previewImg.offsetWidth; + const h = previewImg.offsetHeight; + if (w === 0) return; + highlightCanvas.width = w * devicePixelRatio; + highlightCanvas.height = h * devicePixelRatio; + highlightCanvas.style.width = w + 'px'; + highlightCanvas.style.height = h + 'px'; + highlightCtx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); +} + +function showHighlight(x, y, w, h) { + if (!previewImg.naturalWidth || canvasWidth === 0) return; + syncCanvasSize(); + const imgW = previewImg.offsetWidth; + const imgH = previewImg.offsetHeight; + if (imgW === 0) return; + + const scaleX = imgW / canvasWidth; + const scaleY = imgH / canvasHeight; + const px = x * scaleX; + const py = y * scaleY; + const pw = w * scaleX; + const ph = h * scaleY; + + highlightCtx.clearRect(0, 0, imgW, imgH); + + // Fill with semi-transparent color + highlightCtx.fillStyle = 'rgba(255, 90, 50, 0.12)'; + highlightCtx.fillRect(px, py, pw, ph); + + // Thick border like debug overlay + highlightCtx.strokeStyle = 'rgba(255, 90, 50, 0.8)'; + highlightCtx.lineWidth = 2; + highlightCtx.strokeRect(px, py, pw, ph); + + // Dimension label + highlightCtx.font = '10px system-ui, sans-serif'; + highlightCtx.fillStyle = 'rgba(255, 90, 50, 0.9)'; + const label = `${Math.round(w)}\u00d7${Math.round(h)}`; + const textY = py > 14 ? py - 3 : py + ph + 12; + highlightCtx.fillText(label, px + 2, textY); +} + +function hideHighlight() { + const imgW = previewImg.offsetWidth; + const imgH = previewImg.offsetHeight; + highlightCtx.clearRect(0, 0, imgW, imgH); +} + +layoutPane.addEventListener('mouseover', (e) => { + const target = e.target.closest('summary[data-x], .layout-leaf[data-x]'); + if (!target) return; + showHighlight( + parseFloat(target.dataset.x), + parseFloat(target.dataset.y), + parseFloat(target.dataset.w), + parseFloat(target.dataset.h) + ); +}); + +layoutPane.addEventListener('mouseout', (e) => { + const target = e.target.closest('summary[data-x], .layout-leaf[data-x]'); + if (target) hideHighlight(); +}); + +// --- Preview zoom & scroll --- +let zoomLevel = 1; +const previewContent = document.querySelector('.preview-content'); + +function applyZoom(level) { + zoomLevel = level; + if (level === 1) { + previewImageWrap.style.transform = ''; + } else { + previewImageWrap.style.transform = `scale(${level})`; + previewImageWrap.style.transformOrigin = 'top left'; + } + // Update dropdown to nearest preset (or clear custom) + const nearest = [...zoomSelect.options].find(o => o.value !== 'fit' && Math.abs(parseFloat(o.value) - level) < 0.05); + zoomSelect.value = nearest ? nearest.value : ''; +} + +zoomSelect.addEventListener('change', () => { + const val = zoomSelect.value; + if (val === 'fit') { + applyZoom(1); + previewImageWrap.style.transform = ''; + zoomSelect.value = 'fit'; + return; + } + applyZoom(parseFloat(val)); +}); + +previewContent.addEventListener('wheel', (e) => { + if (!e.ctrlKey && !e.metaKey) return; + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.1 : 0.1; + applyZoom(Math.max(0.25, Math.min(5, zoomLevel + delta))); +}, { passive: false }); + +// Double-click to reset zoom +previewContent.addEventListener('dblclick', () => { + applyZoom(1); }); // --- Render function --- @@ -179,7 +622,6 @@ function render() { statusText.textContent = 'Rendering...'; try { - // Validate const errorsJson = api.Validate(yaml); const errors = JSON.parse(errorsJson); @@ -189,20 +631,17 @@ function render() { ).join('\n'); statusBar.classList.add('error'); statusText.textContent = `${errors.length} error(s)`; - // Switch to errors tab - document.querySelectorAll('.preview-tabs button').forEach(b => b.classList.remove('active')); - document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); - document.querySelector('[data-tab="errors"]').classList.add('active'); - document.getElementById('errors-pane').classList.add('active'); + switchToTab('errors'); return; } errorsPane.textContent = ''; - // Render const start = performance.now(); const dataArg = json.trim() === '{}' || json.trim() === '' ? null : json; - const pngBytes = api.RenderToPng(yaml, dataArg); + const pngBytes = debugMode + ? api.RenderDebugPng(yaml, dataArg) + : api.RenderToPng(yaml, dataArg); const elapsed = (performance.now() - start).toFixed(0); if (pngBytes && pngBytes.length > 0) { @@ -210,7 +649,21 @@ function render() { const blob = new Blob([pngBytes], { type: 'image/png' }); lastObjectUrl = URL.createObjectURL(blob); previewImg.src = lastObjectUrl; - statusText.textContent = `Rendered in ${elapsed}ms \u00b7 ${(pngBytes.length / 1024).toFixed(1)} KB`; + const modeLabel = debugMode ? ' [debug]' : ''; + statusText.textContent = `Rendered in ${elapsed}ms \u00b7 ${(pngBytes.length / 1024).toFixed(1)} KB${modeLabel}`; + + try { + const layoutJson = api.GetLayout(yaml, dataArg); + const layoutData = JSON.parse(layoutJson); + canvasWidth = layoutData.w || 0; + canvasHeight = layoutData.h || 0; + layoutPane.innerHTML = '
' + buildLayoutTree(layoutData, 0) + '
'; + } catch (layoutErr) { + console.warn('Layout computation failed:', layoutErr); + layoutPane.innerHTML = '
Layout unavailable
'; + } + + switchToTab('preview'); } else { statusText.textContent = 'Render returned empty \u2014 check console'; } @@ -218,6 +671,7 @@ function render() { errorsPane.textContent = e.message || String(e); statusBar.classList.add('error'); statusText.textContent = 'Error'; + switchToTab('errors'); } } @@ -230,6 +684,17 @@ function scheduleRender() { yamlEditor.onDidChangeModelContent(scheduleRender); jsonEditor.onDidChangeModelContent(scheduleRender); +// Sync VFS changes to WASM MemoryResourceLoader +vfs.subscribe((event, path) => { + if (event === 'add') { + const entry = vfs.getFile(path); + if (entry && entry.data.length > 0) api.LoadResource(entry.path, entry.data); + } else if (event === 'remove') { + api.RemoveResource(path); + } + scheduleRender(); +}); + // --- Examples dropdown --- const examplesSelect = document.getElementById('examples'); for (const name of Object.keys(EXAMPLES)) { @@ -268,16 +733,13 @@ document.getElementById('btn-export-png').addEventListener('click', () => { } }); -// --- Drag & drop --- +// --- Drag & drop from OS --- const dropOverlay = document.getElementById('drop-overlay'); let dragCounter = 0; -const FONT_EXT = ['.ttf', '.otf', '.woff2']; -const IMAGE_EXT = ['.png', '.jpg', '.jpeg', '.svg', '.gif', '.webp']; -const CONTENT_EXT = ['.ndc', '.txt']; - document.addEventListener('dragenter', (e) => { e.preventDefault(); + if (e.dataTransfer.types.includes('text/x-vfs-path')) return; dragCounter++; dropOverlay.classList.add('visible'); }); @@ -291,33 +753,76 @@ document.addEventListener('dragleave', (e) => { document.addEventListener('dragover', (e) => e.preventDefault()); document.addEventListener('drop', async (e) => { + if (e.dataTransfer.getData('text/x-vfs-path')) return; e.preventDefault(); dragCounter = 0; dropOverlay.classList.remove('visible'); - const loaded = []; - for (const file of e.dataTransfer.files) { - const ext = '.' + file.name.split('.').pop().toLowerCase(); - const buffer = new Uint8Array(await file.arrayBuffer()); - - if (FONT_EXT.includes(ext)) { - api.LoadFont(file.name, buffer); - loaded.push(`font: ${file.name}`); - } else if (IMAGE_EXT.includes(ext)) { - api.LoadImage(file.name, buffer); - loaded.push(`image: ${file.name}`); - } else if (CONTENT_EXT.includes(ext)) { - api.LoadContent(file.name, buffer); - loaded.push(`content: ${file.name}`); + const items = e.dataTransfer.items; + const fileEntries = []; + + if (items) { + for (const item of items) { + const entry = item.webkitGetAsEntry?.(); + if (entry) { + await collectEntries(entry, '', fileEntries); + } + } + } + + if (fileEntries.length === 0) { + for (const file of e.dataTransfer.files) { + const buffer = new Uint8Array(await file.arrayBuffer()); + fileEntries.push({ path: file.name, data: buffer }); } } - if (loaded.length > 0) { - statusText.textContent = `Loaded: ${loaded.join(', ')}`; + for (const { path, data } of fileEntries) { + const type = vfs.detectType(path); + await vfs.addFile(path, data, type); + } + + if (fileEntries.length > 0) { + statusText.textContent = `Added ${fileEntries.length} file(s) to VFS`; scheduleRender(); } }); +async function collectEntries(entry, prefix, results) { + if (entry.isFile) { + const file = await new Promise((resolve) => entry.file(resolve)); + const data = new Uint8Array(await file.arrayBuffer()); + results.push({ path: prefix + entry.name, data }); + } else if (entry.isDirectory) { + const reader = entry.createReader(); + const entries = await new Promise((resolve) => reader.readEntries(resolve)); + for (const child of entries) { + await collectEntries(child, prefix + entry.name + '/', results); + } + } +} + +// --- Init resizable panels --- +const editorPanel = document.getElementById('editor-panel'); +const yamlSection = document.getElementById('yaml-section'); +const jsonSection = document.getElementById('json-section'); +const filesSection = document.getElementById('files-section'); +const previewPanel = document.querySelector('.preview-panel'); + +initSplitters({ + 'yaml-json': { before: yamlSection, after: jsonSection, container: editorPanel, direction: 'h' }, + 'json-files': { before: jsonSection, after: filesSection, container: editorPanel, direction: 'h' }, + 'editor-preview': { before: editorPanel, after: previewPanel, container: document.querySelector('.main-content'), direction: 'v' }, + 'preview-layout': { before: previewContent, after: layoutSection, container: previewPanel, direction: 'h' }, +}); + +initCollapsible(); + +// Ensure embedded font appears in VFS +if (!vfs.exists('Inter-Regular.ttf')) { + await vfs.addFile('Inter-Regular.ttf', new TextEncoder().encode('(embedded)'), 'font'); +} + // --- Show app & initial render --- document.getElementById('loading').style.display = 'none'; document.getElementById('app').style.display = 'flex'; From af6a9e1371a2055fc76fbbea5161e60019aefeb4 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 10 Mar 2026 10:58:17 +0300 Subject: [PATCH 14/25] feat(playground): add WASM font support with embedded Inter-Regular and async font loading Enable font rendering in WASM playground by embedding Inter-Regular.ttf as default font, adding async font preloading from resource loaders (MemoryResourceLoader), and fixing SkiaSharp native library linking. Disable asset fingerprinting for simpler dev workflow. --- src/FlexRender.Playground/FlexRender.Playground.csproj | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/FlexRender.Playground/FlexRender.Playground.csproj b/src/FlexRender.Playground/FlexRender.Playground.csproj index c185856..3267aad 100644 --- a/src/FlexRender.Playground/FlexRender.Playground.csproj +++ b/src/FlexRender.Playground/FlexRender.Playground.csproj @@ -4,15 +4,13 @@ Exe true true - - + true false false - - + From c2e9f6b1ea761e018967d692c6373597a6fa5bd0 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 10 Mar 2026 12:02:58 +0300 Subject: [PATCH 15/25] feat(playground): add project system, VFS autocomplete, ZIP export/import, examples - Per-project VFS: each example/user project has its own files, YAML, JSON - Auto-save to IndexedDB with debounce, project switching with race guard - YAML autocomplete: schema-driven property/value suggestions + VFS file paths - ZIP export/import: bundle project as ZIP, import via button or drag & drop - 6 built-in examples: Simple Text, Flex Layout, Data Binding, Image Scaling, Dynamic Receipt (with conditional rendering, QR, overlay image), NDC Receipt - Example assets loaded from example-assets/ on first seed - Security: XSS escaping in layout inspector, ZIP path traversal rejection - Reliability: readEntries batching, IDB versionchange handler, URL revoke defer - GitHub Pages CI workflow for automated deployment --- .github/workflows/playground.yml | 55 ++ .gitignore | 2 + .../wwwroot/codicon-37A3DWZT.ttf | 3 + .../example-assets/JetBrainsMono-Bold.ttf | 3 + .../example-assets/JetBrainsMono-Regular.ttf | 3 + .../wwwroot/example-assets/bank-receipt.ndc | 33 + .../wwwroot/example-assets/star-badge.png | 3 + .../wwwroot/example-assets/test-pattern.png | 3 + src/FlexRender.Playground/wwwroot/index.html | 11 +- src/FlexRender.Playground/wwwroot/main.js | 825 ++++++++++++++-- .../wwwroot/package.json | 5 + .../wwwroot/projects.mjs | 218 +++++ .../wwwroot/schemas/flexrender-template.json | 901 ++++++++++++++++++ src/FlexRender.Playground/wwwroot/style.css | 24 + src/FlexRender.Playground/wwwroot/vfs.mjs | 133 +-- .../wwwroot/yaml-autocomplete.mjs | 413 ++++++++ 16 files changed, 2468 insertions(+), 167 deletions(-) create mode 100644 .github/workflows/playground.yml create mode 100644 src/FlexRender.Playground/wwwroot/codicon-37A3DWZT.ttf create mode 100644 src/FlexRender.Playground/wwwroot/example-assets/JetBrainsMono-Bold.ttf create mode 100644 src/FlexRender.Playground/wwwroot/example-assets/JetBrainsMono-Regular.ttf create mode 100644 src/FlexRender.Playground/wwwroot/example-assets/bank-receipt.ndc create mode 100644 src/FlexRender.Playground/wwwroot/example-assets/star-badge.png create mode 100644 src/FlexRender.Playground/wwwroot/example-assets/test-pattern.png create mode 100644 src/FlexRender.Playground/wwwroot/package.json create mode 100644 src/FlexRender.Playground/wwwroot/projects.mjs create mode 100644 src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json create mode 100644 src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml new file mode 100644 index 0000000..600e715 --- /dev/null +++ b/.github/workflows/playground.yml @@ -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 diff --git a/.gitignore b/.gitignore index f8fbcdb..ca29b66 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,5 @@ docs/figma-wb-receipt-v2-metadata.txt .DS_Store Thumbs.db docs/plans +publish +node_modules diff --git a/src/FlexRender.Playground/wwwroot/codicon-37A3DWZT.ttf b/src/FlexRender.Playground/wwwroot/codicon-37A3DWZT.ttf new file mode 100644 index 0000000..9e36d3b --- /dev/null +++ b/src/FlexRender.Playground/wwwroot/codicon-37A3DWZT.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f1d5219934e96e83b8db162d60b4d8c09b5de1e7d38031cbafe4a3c0f2889c9 +size 80340 diff --git a/src/FlexRender.Playground/wwwroot/example-assets/JetBrainsMono-Bold.ttf b/src/FlexRender.Playground/wwwroot/example-assets/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000..97179f7 --- /dev/null +++ b/src/FlexRender.Playground/wwwroot/example-assets/JetBrainsMono-Bold.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5590990c82e097397517f275f430af4546e1c45cff408bde4255dad142479dcb +size 277828 diff --git a/src/FlexRender.Playground/wwwroot/example-assets/JetBrainsMono-Regular.ttf b/src/FlexRender.Playground/wwwroot/example-assets/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..87165a0 --- /dev/null +++ b/src/FlexRender.Playground/wwwroot/example-assets/JetBrainsMono-Regular.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0bf60ef0f83c5ed4d7a75d45838548b1f6873372dfac88f71804491898d138f +size 273900 diff --git a/src/FlexRender.Playground/wwwroot/example-assets/bank-receipt.ndc b/src/FlexRender.Playground/wwwroot/example-assets/bank-receipt.ndc new file mode 100644 index 0000000..fd11098 --- /dev/null +++ b/src/FlexRender.Playground/wwwroot/example-assets/bank-receipt.ndc @@ -0,0 +1,33 @@ +(I ntcnjdsq ~fyr l(fj) +vjcrdf, ek. ntcnjdfz 1 cnh 1 + ntk.(495)000-0000 +(I lfnf dhtvz ATM +(201-01-2025 10:00 ATM00005 +(Irfhnf(2: 999999*0000 +4(I~fkfyc ~fyrjvfnf +38(Ixtr 1 bp 2 +(Iwbrk lbcgtycthf(2: 100 +(Icevvs gj rfcctnfv dslfxb(2: +(Irfc pfuhe|tyj dslfyj jcnfnjr +1: 100, 50, 100 +2: 500, 200, 300 +3: 1000, 500, 500 +4: 5000, 1000, 4000 +(Ibnjuj jcnfnjr(2: 900 RUR + 3(I lfnf dhtvz ATM +(201-01-2025 10:00 ATM00005 +(Irfhnf(2: 999999*0000 +4(I~fkfyc ~fyrjvfnf + +8(Ixtr 2 bp 2 +(Iwbrk lbcgtycthf(2: 100 +(Irfcc (2: 1 2 3 4 +(Icnfnec(2: ERR, ERR, ERR, ERR +(Iltyjv.(2: 100, 500,1000,5000 +(Idfk. (2: RUR, RUR, RUR, RUR +(Ipfuhe|(2: 3, 5, 6, 1 +(Idslfyj(2: 1, 2, 2, 1 +(I~hfr (2: 1, 1, 1, 1 +(Ipflth|(2: 0, 0, 0, 0 + +(Ipflth|fyj rfhn(2: 0 diff --git a/src/FlexRender.Playground/wwwroot/example-assets/star-badge.png b/src/FlexRender.Playground/wwwroot/example-assets/star-badge.png new file mode 100644 index 0000000..15bc82c --- /dev/null +++ b/src/FlexRender.Playground/wwwroot/example-assets/star-badge.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:474b1a6c7d597c653663a3ffc21c9b9f0d8f1c7876e76e739badd84058e51e0f +size 789 diff --git a/src/FlexRender.Playground/wwwroot/example-assets/test-pattern.png b/src/FlexRender.Playground/wwwroot/example-assets/test-pattern.png new file mode 100644 index 0000000..37c2199 --- /dev/null +++ b/src/FlexRender.Playground/wwwroot/example-assets/test-pattern.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:81318708ea483a8684298e18f1c10885eb9a0996bde3a4465d6599d0f9ff67cd +size 14900 diff --git a/src/FlexRender.Playground/wwwroot/index.html b/src/FlexRender.Playground/wwwroot/index.html index ef9fe97..da5ba7c 100644 --- a/src/FlexRender.Playground/wwwroot/index.html +++ b/src/FlexRender.Playground/wwwroot/index.html @@ -18,12 +18,15 @@

FlexRender Playground

- + + + +
+ +
@@ -94,7 +97,7 @@

FlexRender Playground

- Drop fonts, images, or content files here + Drop fonts, images, content files, or .zip projects here
diff --git a/src/FlexRender.Playground/wwwroot/main.js b/src/FlexRender.Playground/wwwroot/main.js index 9ed1f13..f5a9902 100644 --- a/src/FlexRender.Playground/wwwroot/main.js +++ b/src/FlexRender.Playground/wwwroot/main.js @@ -11,8 +11,9 @@ const api = exports.FlexRender.Playground.PlaygroundApi; await runMain(); -// --- VFS & Splitter modules --- +// --- VFS, Splitter & Projects modules --- import * as vfs from './vfs.mjs'; +import * as projects from './projects.mjs'; import { initSplitters, initCollapsible } from './splitter.mjs'; // --- Monaco Editor via modern-monaco from CDN --- @@ -27,22 +28,15 @@ try { const { registerYamlAutocomplete } = await import('./yaml-autocomplete.mjs'); const schemaResponse = await fetch('schemas/flexrender-template.json'); const flexrenderSchema = await schemaResponse.json(); - registerYamlAutocomplete(monaco, flexrenderSchema); + registerYamlAutocomplete(monaco, flexrenderSchema, { + getVfsFiles: () => vfs.listFiles().map(p => ({ path: p, type: vfs.detectType(p) })), + }); console.log('YAML autocomplete registered with FlexRender schema'); } catch (e) { console.warn('YAML autocomplete setup failed:', e.message); } -// --- Restore VFS from IndexedDB and sync to WASM --- -const restoredCount = await vfs.restore(); -if (restoredCount > 0) { - for (const entry of vfs.allEntries()) { - api.LoadResource(entry.path, entry.data); - } - console.log(`Restored ${restoredCount} files from IndexedDB`); -} - -// --- Built-in examples --- +// --- Built-in examples (used to seed example projects on first run) --- const EXAMPLES = { 'Simple Text': { yaml: `canvas: @@ -132,15 +126,343 @@ layout: "author": "FlexRender", "items": ["Apples", "Bread", "Milk", "Cheese"] }` - } + }, + 'Image Scaling': { + yaml: `canvas: + fixed: width + width: 440 + background: "#ffffff" + +layout: + - type: flex + direction: column + gap: "20" + padding: "20" + children: + - type: text + content: "Image Fit Modes" + size: 20 + color: "#333" + fontWeight: bold + + - type: flex + direction: row + gap: "16" + children: + - type: flex + direction: column + gap: "4" + align: center + children: + - type: text + content: "contain" + size: 11 + color: "#888" + - type: flex + width: "120" + height: "120" + background: "#f0f0f0" + border: "1" + borderColor: "#ddd" + children: + - type: image + src: test-pattern.png + width: "120" + height: "120" + fit: contain + + - type: flex + direction: column + gap: "4" + align: center + children: + - type: text + content: "cover" + size: 11 + color: "#888" + - type: flex + width: "120" + height: "120" + background: "#f0f0f0" + border: "1" + borderColor: "#ddd" + children: + - type: image + src: test-pattern.png + width: "120" + height: "120" + fit: cover + + - type: flex + direction: column + gap: "4" + align: center + children: + - type: text + content: "fill" + size: 11 + color: "#888" + - type: flex + width: "120" + height: "120" + background: "#f0f0f0" + border: "1" + borderColor: "#ddd" + children: + - type: image + src: test-pattern.png + width: "120" + height: "120" + fit: fill`, + json: '{}', + assets: ['test-pattern.png'], + }, + 'Dynamic Receipt': { + yaml: `canvas: + fixed: width + width: 320 + background: "#ffffff" + +layout: + - type: flex + padding: "24 20" + gap: "12" + children: + - type: flex + gap: "4" + align: center + children: + - type: text + content: "{{shopName}}" + fontWeight: bold + size: 1.5em + align: center + color: "#1a1a1a" + - type: text + content: "{{address}}" + size: 0.85em + align: center + color: "#888888" + + - type: separator + style: dashed + color: "#cccccc" + + - type: if + condition: items + hasItems: true + then: + - type: flex + gap: "6" + children: + - type: each + array: items + as: item + children: + - type: flex + direction: row + justify: space-between + children: + - type: flex + direction: column + gap: "2" + shrink: 1 + children: + - type: text + content: "{{item.name}}" + size: 1em + color: "#333" + - type: if + condition: item.quantity + then: + - type: text + content: "x{{item.quantity}}" + size: 0.8em + color: "#888" + - type: text + content: "{{item.price}} $" + size: 1em + color: "#333" + align: right + + - type: separator + color: "#1a1a1a" + + - type: if + condition: discount + then: + - type: flex + gap: "6" + children: + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "Subtotal" + size: 0.9em + color: "#666" + - type: text + content: "{{subtotal}} $" + size: 0.9em + color: "#666" + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "Discount" + size: 0.9em + color: "#22c55e" + - type: text + content: "-{{discount}} $" + size: 0.9em + color: "#22c55e" + - type: separator + style: dashed + color: "#ccc" + + - type: flex + direction: row + justify: space-between + align: center + children: + - type: text + content: "TOTAL" + fontWeight: bold + size: 1.2em + color: "#1a1a1a" + - type: text + content: "{{total}} $" + fontWeight: bold + size: 1.2em + color: "#1a1a1a" + - type: if + condition: totalNumber + greaterThan: 10 + then: + - type: image + position: absolute + top: "-8" + left: "36" + rotate: 30 + src: star-badge.png + width: "24" + height: "24" + fit: contain + + - type: separator + style: dotted + color: "#ccc" + + - type: if + condition: paymentStatus + equals: "paid" + then: + - type: flex + align: center + gap: "4" + children: + - type: text + content: "PAID" + fontWeight: bold + size: 1.1em + color: "#22c55e" + align: center + - type: text + content: "Thank you!" + size: 0.85em + color: "#666" + align: center + elseIf: + condition: paymentStatus + equals: "pending" + then: + - type: flex + align: center + gap: "6" + children: + - type: qr + data: "{{paymentUrl}}" + size: 120 + errorCorrection: M + - type: text + content: "Scan to pay" + size: 0.75em + color: "#999" + align: center + else: + - type: text + content: "Payment required at counter" + size: 0.9em + color: "#ef4444" + align: center + + - type: separator + style: dotted + color: "#ccc" + + - type: text + content: "{{date}}" + size: 0.75em + align: center + color: "#999"`, + json: `{ + "shopName": "Coffee & Co", + "address": "123 Main St, Downtown", + "items": [ + {"name": "Cappuccino", "quantity": 2, "price": "4.50"}, + {"name": "Croissant", "price": "3.20"}, + {"name": "Fresh Juice", "quantity": 1, "price": "5.00"} + ], + "subtotal": "17.20", + "discount": "2.00", + "total": "15.20", + "totalNumber": 15.20, + "paymentStatus": "paid", + "paymentUrl": "https://pay.example.com/inv/12345", + "date": "2026-03-10 14:30" +}`, + assets: ['star-badge.png'], + }, + 'NDC Receipt': { + yaml: `# NDC (ATM receipt) format — binary terminal data rendered as a receipt +# The .ndc file in VFS contains raw ESC-sequence data from an ATM + +fonts: + - name: default + path: JetBrainsMono-Regular.ttf + - name: bold + path: JetBrainsMono-Bold.ttf + +canvas: + fixed: width + width: 576 + background: "#ffffff" + +layout: + - type: content + source: bank-receipt.ndc + format: ndc + options: + columns: 44 + font_family: JetBrains Mono + charsets: + I: + font: bold + font_style: bold + encoding: qwerty-jcuken + uppercase: true + "2": + font: default`, + json: '{}', + assets: ['bank-receipt.ndc', 'JetBrainsMono-Regular.ttf', 'JetBrainsMono-Bold.ttf'], + }, }; // --- Create Monaco editors --- -const defaultYaml = EXAMPLES['Simple Text'].yaml; -const defaultJson = '{}'; - const yamlModelUri = monaco.Uri.parse('file:///template.yaml'); -const yamlModel = monaco.editor.createModel(defaultYaml, 'yaml', yamlModelUri); +const yamlModel = monaco.editor.createModel('', 'yaml', yamlModelUri); const yamlEditor = monaco.editor.create(document.getElementById('yaml-editor'), { model: yamlModel, @@ -149,7 +471,8 @@ const yamlEditor = monaco.editor.create(document.getElementById('yaml-editor'), fontSize: 13, tabSize: 2, automaticLayout: true, - scrollBeyondLastLine: false, + scrollBeyondLastLine: true, + fixedOverflowWidgets: true, quickSuggestions: { other: true, comments: false, @@ -158,14 +481,15 @@ const yamlEditor = monaco.editor.create(document.getElementById('yaml-editor'), }); const jsonEditor = monaco.editor.create(document.getElementById('json-editor'), { - value: defaultJson, + value: '{}', language: 'json', theme: 'one-dark-pro', minimap: { enabled: false }, fontSize: 13, tabSize: 2, automaticLayout: true, - scrollBeyondLastLine: false, + scrollBeyondLastLine: true, + fixedOverflowWidgets: true, }); // --- UI elements --- @@ -175,6 +499,12 @@ const previewImg = document.getElementById('preview-img'); const errorsPane = document.getElementById('errors-pane'); const layoutPane = document.getElementById('layout-pane'); +// --- Project UI elements --- +const projectSelect = document.getElementById('project-select'); +const btnNewProject = document.getElementById('btn-new-project'); +const btnDeleteProject = document.getElementById('btn-delete-project'); +const btnResetExample = document.getElementById('btn-reset-example'); + // --- Debug overlay toggle --- const previewTabs = document.querySelector('.preview-tabs'); const zoomSelect = document.getElementById('zoom-select'); @@ -204,26 +534,28 @@ function buildLayoutTree(node, depth) { const dataAttrs = `data-x="${node.x}" data-y="${node.y}" data-w="${node.w}" data-h="${node.h}"`; const props = []; - if (node.content) props.push(`"${node.content}"`); - if (node.font) props.push(`font=${node.font}`); - if (node.fontFamily) props.push(`family=${node.fontFamily}`); - if (node.size) props.push(`size=${node.size}`); - if (node.color) props.push(`color=${node.color}`); - if (node.fontWeight) props.push(`weight=${node.fontWeight}`); - if (node.fontStyle) props.push(`style=${node.fontStyle}`); - if (node.direction && node.type === 'Flex') props.push(node.direction); - if (node.align) props.push(`align=${node.align}`); - if (node.justify) props.push(`justify=${node.justify}`); - if (node.fontSize) props.push(`fontSize=${node.fontSize}px`); - if (node.textLines) props.push(`lines=${node.textLines}`); + if (node.content) props.push(`"${escHtml(node.content)}"`); + if (node.font) props.push(`font=${escHtml(node.font)}`); + if (node.fontFamily) props.push(`family=${escHtml(node.fontFamily)}`); + if (node.size) props.push(`size=${escHtml(String(node.size))}`); + if (node.color) props.push(`color=${escHtml(node.color)}`); + if (node.fontWeight) props.push(`weight=${escHtml(String(node.fontWeight))}`); + if (node.fontStyle) props.push(`style=${escHtml(node.fontStyle)}`); + if (node.direction && node.type === 'Flex') props.push(escHtml(node.direction)); + if (node.align) props.push(`align=${escHtml(node.align)}`); + if (node.justify) props.push(`justify=${escHtml(node.justify)}`); + if (node.fontSize) props.push(`fontSize=${escHtml(String(node.fontSize))}px`); + if (node.textLines) props.push(`lines=${escHtml(String(node.textLines))}`); const propsStr = props.length > 0 ? ` [${props.join(', ')}]` : ''; + const safeType = escHtml(node.type); + const safeDims = escHtml(dims); if (hasChildren) { const childrenHtml = node.children.map(c => buildLayoutTree(c, depth + 1)).join(''); - return `${node.type} ${dims}${propsStr}${childrenHtml}`; + return `${safeType} ${safeDims}${propsStr}${childrenHtml}`; } - return `
${node.type} ${dims}${propsStr}
`; + return `
${safeType} ${safeDims}${propsStr}
`; } // --- File tree rendering --- @@ -503,8 +835,6 @@ const highlightCtx = highlightCanvas.getContext('2d'); const previewImageWrap = document.getElementById('preview-image-wrap'); function syncCanvasSize() { - // Use the image's CSS (layout) size before any transform scaling. - // Since canvas is inside the same scaled wrapper, we match the un-scaled img size. const w = previewImg.offsetWidth; const h = previewImg.offsetHeight; if (w === 0) return; @@ -531,16 +861,13 @@ function showHighlight(x, y, w, h) { highlightCtx.clearRect(0, 0, imgW, imgH); - // Fill with semi-transparent color highlightCtx.fillStyle = 'rgba(255, 90, 50, 0.12)'; highlightCtx.fillRect(px, py, pw, ph); - // Thick border like debug overlay highlightCtx.strokeStyle = 'rgba(255, 90, 50, 0.8)'; highlightCtx.lineWidth = 2; highlightCtx.strokeRect(px, py, pw, ph); - // Dimension label highlightCtx.font = '10px system-ui, sans-serif'; highlightCtx.fillStyle = 'rgba(255, 90, 50, 0.9)'; const label = `${Math.round(w)}\u00d7${Math.round(h)}`; @@ -582,7 +909,6 @@ function applyZoom(level) { previewImageWrap.style.transform = `scale(${level})`; previewImageWrap.style.transformOrigin = 'top left'; } - // Update dropdown to nearest preset (or clear custom) const nearest = [...zoomSelect.options].find(o => o.value !== 'fit' && Math.abs(parseFloat(o.value) - level) < 0.05); zoomSelect.value = nearest ? nearest.value : ''; } @@ -605,7 +931,6 @@ previewContent.addEventListener('wheel', (e) => { applyZoom(Math.max(0.25, Math.min(5, zoomLevel + delta))); }, { passive: false }); -// Double-click to reset zoom previewContent.addEventListener('dblclick', () => { applyZoom(1); }); @@ -684,34 +1009,374 @@ function scheduleRender() { yamlEditor.onDidChangeModelContent(scheduleRender); jsonEditor.onDidChangeModelContent(scheduleRender); -// Sync VFS changes to WASM MemoryResourceLoader +// --- Project management --- + +/** Currently loaded project (full object). */ +let currentProject = null; +let autoSaveTimeout = null; +let isSwitching = false; // Guard to prevent auto-save during project switch +let switchGeneration = 0; // Guard against rapid project switching race conditions + +/** Convert an example name to a stable slug ID. */ +function exampleSlug(name) { + return 'example-' + name.toLowerCase().replace(/[^a-z0-9]+/g, '-'); +} + +/** Populate the project selector dropdown. */ +async function refreshProjectSelect() { + const list = await projects.listProjects(); + projectSelect.innerHTML = ''; + + const examplesGroup = document.createElement('optgroup'); + examplesGroup.label = 'Examples'; + const userGroup = document.createElement('optgroup'); + userGroup.label = 'My Projects'; + + let hasExamples = false; + let hasUser = false; + + for (const p of list) { + const option = document.createElement('option'); + option.value = p.id; + option.textContent = p.name; + if (p.isExample) { + examplesGroup.appendChild(option); + hasExamples = true; + } else { + userGroup.appendChild(option); + hasUser = true; + } + } + + if (hasExamples) projectSelect.appendChild(examplesGroup); + if (hasUser) projectSelect.appendChild(userGroup); + + if (currentProject) { + projectSelect.value = currentProject.id; + } + + // Update delete/reset button visibility + updateProjectButtons(); +} + +/** Show/hide delete and reset buttons based on current project type. */ +function updateProjectButtons() { + if (!currentProject) return; + btnDeleteProject.style.display = currentProject.isExample ? 'none' : ''; + btnResetExample.style.display = currentProject.isExample ? '' : 'none'; +} + +/** Save the current project state (editors + VFS) to IndexedDB. */ +async function saveCurrentProject() { + if (!currentProject || isSwitching) return; + currentProject.yaml = yamlEditor.getValue(); + currentProject.json = jsonEditor.getValue(); + currentProject.files = vfs.exportFiles(); + await projects.saveProject(currentProject); +} + +/** Debounced auto-save (500ms). */ +function scheduleAutoSave() { + if (isSwitching) return; + clearTimeout(autoSaveTimeout); + autoSaveTimeout = setTimeout(() => saveCurrentProject(), 500); +} + +/** Switch to a different project by ID. Saves current first, then loads new. */ +async function switchProject(id) { + const myGeneration = ++switchGeneration; + isSwitching = true; + clearTimeout(autoSaveTimeout); + + // Save current project before switching + if (currentProject) { + currentProject.yaml = yamlEditor.getValue(); + currentProject.json = jsonEditor.getValue(); + currentProject.files = vfs.exportFiles(); + await projects.saveProject(currentProject); + } + if (myGeneration !== switchGeneration) return; + + // Load new project + const project = await projects.loadProject(id); + if (myGeneration !== switchGeneration) return; + if (!project) { + isSwitching = false; + console.warn('Project not found:', id); + return; + } + + currentProject = project; + projects.setCurrentProjectId(id); + + // Clear WASM resources before loading new VFS + for (const path of vfs.listFiles()) { + api.RemoveResource(path); + } + + // Set editor values (this will trigger onDidChangeModelContent, but auto-save is guarded) + yamlEditor.setValue(project.yaml); + jsonEditor.setValue(project.json); + + // Load VFS files — this triggers 'clear' then 'add' for each file, + // which syncs to WASM via the VFS subscriber + vfs.loadFromProject(project.files); + + // Update UI + projectSelect.value = id; + updateProjectButtons(); + + isSwitching = false; + scheduleRender(); +} + +// Sync VFS changes to WASM MemoryResourceLoader AND trigger auto-save vfs.subscribe((event, path) => { if (event === 'add') { const entry = vfs.getFile(path); - if (entry && entry.data.length > 0) api.LoadResource(entry.path, entry.data); + if (entry && entry.data.length > 0) api.LoadResource(path, entry.data); } else if (event === 'remove') { api.RemoveResource(path); } scheduleRender(); + scheduleAutoSave(); +}); + +// Auto-save on editor changes +yamlEditor.onDidChangeModelContent(scheduleAutoSave); +jsonEditor.onDidChangeModelContent(scheduleAutoSave); + +// --- Project selector change --- +projectSelect.addEventListener('change', () => { + const id = projectSelect.value; + if (id && (!currentProject || id !== currentProject.id)) { + switchProject(id); + } +}); + +// --- New project button --- +btnNewProject.addEventListener('click', async () => { + const name = prompt('Project name:'); + if (!name || !name.trim()) return; + const project = await projects.createProject(name.trim()); + await refreshProjectSelect(); + await switchProject(project.id); + statusText.textContent = `Created project: ${project.name}`; +}); + +// --- Delete project button --- +btnDeleteProject.addEventListener('click', async () => { + if (!currentProject || currentProject.isExample) return; + if (!confirm(`Delete project "${currentProject.name}"?`)) return; + + const deletedId = currentProject.id; + await projects.deleteProject(deletedId); + + // Switch to first available project + const list = await projects.listProjects(); + const nextId = list.length > 0 ? list[0].id : null; + + if (nextId) { + currentProject = null; // Clear so switchProject doesn't try to save deleted project + await refreshProjectSelect(); + await switchProject(nextId); + } + + statusText.textContent = 'Project deleted'; }); -// --- Examples dropdown --- -const examplesSelect = document.getElementById('examples'); -for (const name of Object.keys(EXAMPLES)) { - const option = document.createElement('option'); - option.value = name; - option.textContent = name; - examplesSelect.appendChild(option); +// --- Reset example button --- +btnResetExample.addEventListener('click', async () => { + if (!currentProject || !currentProject.isExample) return; + if (!confirm(`Reset "${currentProject.name}" to its default state?`)) return; + + // Find the original example data + const originalName = Object.keys(EXAMPLES).find(name => exampleSlug(name) === currentProject.id); + if (!originalName) return; + + const example = EXAMPLES[originalName]; + const files = await loadExampleAssets(example.assets); + const resetId = currentProject.id; + await projects.seedExample(resetId, originalName, example.yaml, example.json, files); + currentProject = null; // Prevent switchProject from overwriting the fresh seed + await switchProject(resetId); + statusText.textContent = `Reset example: ${originalName}`; +}); + +/** Fetch example asset files from example-assets/ directory. */ +async function loadExampleAssets(assetNames) { + if (!assetNames || assetNames.length === 0) return []; + const files = []; + for (const name of assetNames) { + try { + const resp = await fetch(`example-assets/${name}`); + if (!resp.ok) continue; + const data = new Uint8Array(await resp.arrayBuffer()); + files.push({ path: name, data, type: vfs.detectType(name) }); + } catch (e) { + console.warn(`Failed to load example asset: ${name}`, e); + } + } + return files; +} + +// --- Initialize projects on startup --- +async function initProjects() { + await projects.init(); + const list = await projects.listProjects(); + + // Seed any missing examples (supports adding new examples without clearing DB) + const existingIds = new Set(list.map(p => p.id)); + for (const [name, example] of Object.entries(EXAMPLES)) { + const id = exampleSlug(name); + if (!existingIds.has(id)) { + const files = await loadExampleAssets(example.assets); + await projects.seedExample(id, name, example.yaml, example.json, files); + } + } + + await refreshProjectSelect(); + + // Determine which project to load + let projectId = projects.getCurrentProjectId(); + + // Validate that the stored project still exists + if (projectId) { + const exists = await projects.projectExists(projectId); + if (!exists) projectId = null; + } + + // Default to first example + if (!projectId) { + const firstExampleName = Object.keys(EXAMPLES)[0]; + projectId = exampleSlug(firstExampleName); + } + + await switchProject(projectId); } -examplesSelect.addEventListener('change', () => { - const example = EXAMPLES[examplesSelect.value]; - if (example) { - yamlEditor.setValue(example.yaml); - jsonEditor.setValue(example.json); +// --- JSZip lazy loader (cached after first use) --- +let _jszip = null; +async function loadJSZip() { + if (!_jszip) { + _jszip = (await import('https://esm.sh/jszip')).default; + } + return _jszip; +} + +// --- Export ZIP --- +document.getElementById('btn-export-zip').addEventListener('click', async () => { + if (!currentProject) return; + try { + statusText.textContent = 'Exporting ZIP...'; + const JSZip = await loadJSZip(); + const zip = new JSZip(); + + // Add template.yaml + zip.file('template.yaml', yamlEditor.getValue()); + + // Add data.json (only if non-empty) + const jsonVal = jsonEditor.getValue().trim(); + if (jsonVal && jsonVal !== '{}') { + zip.file('data.json', jsonVal); + } + + // Add VFS files under files/ prefix + for (const entry of vfs.allEntries()) { + zip.file('files/' + entry.path, entry.data); + } + + const blob = await zip.generateAsync({ type: 'blob' }); + const safeName = currentProject.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_'); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = safeName + '.zip'; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 60000); + statusText.textContent = `Exported: ${safeName}.zip`; + } catch (e) { + statusText.textContent = 'ZIP export failed'; + console.error('ZIP export error:', e); + alert('ZIP export failed: ' + (e.message || e)); } }); +// --- Import ZIP --- +async function importZipFile(file) { + try { + statusText.textContent = 'Importing ZIP...'; + const JSZip = await loadJSZip(); + const zip = await JSZip.loadAsync(await file.arrayBuffer()); + + // Extract template.yaml (required) + let yamlContent = null; + let jsonContent = '{}'; + const vfsFiles = []; + + for (const [relativePath, zipEntry] of Object.entries(zip.files)) { + if (zipEntry.dir) continue; + + // Normalize: strip leading slashes + const name = relativePath.replace(/^\/+/, ''); + + if (name === 'template.yaml') { + yamlContent = await zipEntry.async('string'); + } else if (name === 'data.json') { + jsonContent = await zipEntry.async('string'); + } else if (name.startsWith('files/')) { + const vfsPath = name.slice('files/'.length); + if (vfsPath && !vfsPath.startsWith('/') && !vfsPath.includes('..')) { + const data = new Uint8Array(await zipEntry.async('arraybuffer')); + vfsFiles.push({ path: vfsPath, data, type: vfs.detectType(vfsPath) }); + } + } else { + // Files outside files/ directory (other than template.yaml/data.json) go into VFS + if (!name.startsWith('/') && !name.includes('..')) { + const data = new Uint8Array(await zipEntry.async('arraybuffer')); + vfsFiles.push({ path: name, data, type: vfs.detectType(name) }); + } + } + } + + if (!yamlContent) { + alert('Invalid ZIP: template.yaml not found.'); + statusText.textContent = 'Import failed: no template.yaml'; + return; + } + + // Derive project name from ZIP filename + let projectName = file.name.replace(/\.zip$/i, '').trim(); + if (!projectName) projectName = 'Imported Project'; + + // Create a new project with the extracted data + const project = await projects.createProject(projectName); + project.yaml = yamlContent; + project.json = jsonContent; + project.files = vfsFiles; + await projects.saveProject(project); + + await refreshProjectSelect(); + await switchProject(project.id); + statusText.textContent = `Imported project: ${projectName} (${vfsFiles.length} file(s))`; + } catch (e) { + statusText.textContent = 'ZIP import failed'; + console.error('ZIP import error:', e); + alert('ZIP import failed: ' + (e.message || e)); + } +} + +document.getElementById('btn-import-zip').addEventListener('click', () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.zip'; + input.addEventListener('change', async () => { + if (input.files.length > 0) { + await importZipFile(input.files[0]); + } + }); + input.click(); +}); + // --- Export PNG --- document.getElementById('btn-export-png').addEventListener('click', () => { const yaml = yamlEditor.getValue(); @@ -726,7 +1391,7 @@ document.getElementById('btn-export-png').addEventListener('click', () => { a.href = URL.createObjectURL(blob); a.download = 'flexrender-output.png'; a.click(); - URL.revokeObjectURL(a.href); + setTimeout(() => URL.revokeObjectURL(a.href), 60000); } } catch (e) { alert('Export failed: ' + (e.message || e)); @@ -758,6 +1423,26 @@ document.addEventListener('drop', async (e) => { dragCounter = 0; dropOverlay.classList.remove('visible'); + // Check for .zip files first — import them as projects + const droppedFiles = e.dataTransfer.files; + const zipFiles = []; + + for (let i = 0; i < droppedFiles.length; i++) { + const file = droppedFiles[i]; + if (file.name.toLowerCase().endsWith('.zip')) { + zipFiles.push(file); + } + } + + if (zipFiles.length > 0) { + for (const zipFile of zipFiles) { + await importZipFile(zipFile); + } + // If all dropped files are zips, stop here + if (zipFiles.length === droppedFiles.length) return; + } + + // Process non-zip files as VFS entries const items = e.dataTransfer.items; const fileEntries = []; @@ -771,15 +1456,21 @@ document.addEventListener('drop', async (e) => { } if (fileEntries.length === 0) { - for (const file of e.dataTransfer.files) { + for (const file of droppedFiles) { + if (file.name.toLowerCase().endsWith('.zip')) continue; // Already handled const buffer = new Uint8Array(await file.arrayBuffer()); fileEntries.push({ path: file.name, data: buffer }); } + } else { + // Filter out zip files that were already imported + const filtered = fileEntries.filter(f => !f.path.toLowerCase().endsWith('.zip')); + fileEntries.length = 0; + fileEntries.push(...filtered); } for (const { path, data } of fileEntries) { const type = vfs.detectType(path); - await vfs.addFile(path, data, type); + vfs.addFile(path, data, type); } if (fileEntries.length > 0) { @@ -795,8 +1486,13 @@ async function collectEntries(entry, prefix, results) { results.push({ path: prefix + entry.name, data }); } else if (entry.isDirectory) { const reader = entry.createReader(); - const entries = await new Promise((resolve) => reader.readEntries(resolve)); - for (const child of entries) { + const allEntries = []; + let batch; + do { + batch = await new Promise((resolve) => reader.readEntries(resolve)); + allEntries.push(...batch); + } while (batch.length > 0); + for (const child of allEntries) { await collectEntries(child, prefix + entry.name + '/', results); } } @@ -818,12 +1514,7 @@ initSplitters({ initCollapsible(); -// Ensure embedded font appears in VFS -if (!vfs.exists('Inter-Regular.ttf')) { - await vfs.addFile('Inter-Regular.ttf', new TextEncoder().encode('(embedded)'), 'font'); -} - -// --- Show app & initial render --- +// --- Show app & initialize projects --- document.getElementById('loading').style.display = 'none'; document.getElementById('app').style.display = 'flex'; -render(); +await initProjects(); diff --git a/src/FlexRender.Playground/wwwroot/package.json b/src/FlexRender.Playground/wwwroot/package.json new file mode 100644 index 0000000..54c0c06 --- /dev/null +++ b/src/FlexRender.Playground/wwwroot/package.json @@ -0,0 +1,5 @@ +{ + "name": "flexrender-playground", + "version": "1.0.0", + "private": true +} diff --git a/src/FlexRender.Playground/wwwroot/projects.mjs b/src/FlexRender.Playground/wwwroot/projects.mjs new file mode 100644 index 0000000..5d64b70 --- /dev/null +++ b/src/FlexRender.Playground/wwwroot/projects.mjs @@ -0,0 +1,218 @@ +// Project management for FlexRender Playground. +// Each project bundles YAML template, JSON data, and VFS files together. +// Persistence via IndexedDB; VFS is purely in-memory — this module owns storage. + +const DB_NAME = 'flexrender-projects'; +const DB_VERSION = 1; +const STORE_NAME = 'projects'; +const LS_KEY = 'flexrender-current-project'; + +/** @typedef {{path: string, data: Uint8Array, type: string}} VfsFile */ +/** @typedef {{id: string, name: string, yaml: string, json: string, files: VfsFile[], isExample: boolean, createdAt: number, updatedAt: number}} Project */ + +/** @type {IDBDatabase|null} */ +let db = null; + +function openDb() { + return new Promise((resolve, reject) => { + if (db) { resolve(db); return; } + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const database = req.result; + if (!database.objectStoreNames.contains(STORE_NAME)) { + database.createObjectStore(STORE_NAME, { keyPath: 'id' }); + } + }; + req.onsuccess = () => { + db = req.result; + db.onversionchange = () => { db.close(); db = null; }; + resolve(db); + }; + req.onerror = () => reject(req.error); + }); +} + +function txReadOnly() { + return db.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME); +} + +function txReadWrite() { + return db.transaction(STORE_NAME, 'readwrite').objectStore(STORE_NAME); +} + +function reqToPromise(req) { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +/** + * Serialize a project for IndexedDB storage. + * Uint8Array instances must be converted to ArrayBuffer for structured clone. + */ +function serializeForDb(project) { + return { + ...project, + files: project.files.map(f => ({ + path: f.path, + type: f.type, + data: f.data.buffer.slice(f.data.byteOffset, f.data.byteOffset + f.data.byteLength), + })), + }; +} + +/** + * Deserialize a project from IndexedDB storage. + * ArrayBuffer instances are converted back to Uint8Array. + */ +function deserializeFromDb(record) { + if (!record) return null; + return { + ...record, + files: (record.files || []).map(f => ({ + path: f.path, + type: f.type, + data: new Uint8Array(f.data), + })), + }; +} + +/** + * Initialize the projects database. Returns the list of all projects (summary only). + * @returns {Promise<{id: string, name: string, isExample: boolean, updatedAt: number}[]>} + */ +export async function init() { + await openDb(); + return listProjects(); +} + +/** + * List all projects (summary: id, name, isExample, updatedAt). + * @returns {Promise<{id: string, name: string, isExample: boolean, updatedAt: number}[]>} + */ +export async function listProjects() { + const store = txReadOnly(); + const all = await reqToPromise(store.getAll()); + return all.map(p => ({ + id: p.id, + name: p.name, + isExample: p.isExample, + updatedAt: p.updatedAt, + })).sort((a, b) => { + // Examples first, then by name + if (a.isExample !== b.isExample) return a.isExample ? -1 : 1; + return a.name.localeCompare(b.name); + }); +} + +/** + * Load a full project by ID. + * @param {string} id + * @returns {Promise} + */ +export async function loadProject(id) { + const store = txReadOnly(); + const record = await reqToPromise(store.get(id)); + return deserializeFromDb(record); +} + +/** + * Upsert a project. Updates the updatedAt timestamp. + * @param {Project} project + * @returns {Promise} + */ +export async function saveProject(project) { + project.updatedAt = Date.now(); + const store = txReadWrite(); + await reqToPromise(store.put(serializeForDb(project))); +} + +/** + * Delete a project by ID. Prevents deleting example projects. + * @param {string} id + * @returns {Promise} true if deleted, false if prevented + */ +export async function deleteProject(id) { + const project = await loadProject(id); + if (!project) return false; + if (project.isExample) return false; + const store = txReadWrite(); + await reqToPromise(store.delete(id)); + return true; +} + +/** + * Create a new empty project. + * @param {string} name + * @returns {Promise} + */ +export async function createProject(name) { + const now = Date.now(); + const project = { + id: crypto.randomUUID(), + name, + yaml: '', + json: '{}', + files: [], + isExample: false, + createdAt: now, + updatedAt: now, + }; + const store = txReadWrite(); + await reqToPromise(store.put(serializeForDb(project))); + return project; +} + +/** + * Create or reset an example project with the given slug, name, yaml, json, and files. + * @param {string} id - Stable slug ID for the example + * @param {string} name + * @param {string} yaml + * @param {string} json + * @param {VfsFile[]} files + * @returns {Promise} + */ +export async function seedExample(id, name, yaml, json, files = []) { + const now = Date.now(); + const project = { + id, + name, + yaml, + json, + files, + isExample: true, + createdAt: now, + updatedAt: now, + }; + const store = txReadWrite(); + await reqToPromise(store.put(serializeForDb(project))); + return project; +} + +/** + * Check if a project exists by ID. + * @param {string} id + * @returns {Promise} + */ +export async function projectExists(id) { + const store = txReadOnly(); + const key = await reqToPromise(store.getKey(id)); + return key !== undefined; +} + +/** + * Get the last-used project ID from localStorage. + * @returns {string|null} + */ +export function getCurrentProjectId() { + return localStorage.getItem(LS_KEY); +} + +/** + * Set the last-used project ID in localStorage. + * @param {string} id + */ +export function setCurrentProjectId(id) { + localStorage.setItem(LS_KEY, id); +} diff --git a/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json b/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json new file mode 100644 index 0000000..abace48 --- /dev/null +++ b/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json @@ -0,0 +1,901 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://flexrender.dev/schemas/flexrender-template.json", + "title": "FlexRender Template", + "description": "Schema for FlexRender YAML templates that define layout, styling, and data-bound rendering.", + "type": "object", + "required": ["canvas"], + "additionalProperties": false, + "properties": { + "template": { + "description": "Optional metadata about the template.", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "description": "Human-readable name for this template.", + "type": "string" + }, + "version": { + "description": "Version string for this template.", + "type": "string" + }, + "culture": { + "description": "Culture/locale code (e.g., 'en-US', 'de-DE') for number and date formatting.", + "type": "string" + } + } + }, + "canvas": { + "description": "Defines the output canvas dimensions and defaults.", + "type": "object", + "required": ["width", "height"], + "additionalProperties": false, + "properties": { + "width": { + "description": "Canvas width in pixels.", + "type": "integer", + "minimum": 1 + }, + "height": { + "description": "Canvas height in pixels.", + "type": "integer", + "minimum": 1 + }, + "background": { + "description": "Background color (hex string, e.g., '#ffffff').", + "type": "string" + }, + "fixed": { + "description": "Which dimensions are fixed vs. auto-sized.", + "type": "string", + "enum": ["width", "height", "both", "none"] + }, + "text-direction": { + "description": "Default text direction for the template.", + "type": "string", + "enum": ["ltr", "rtl"] + } + } + }, + "fonts": { + "description": "Font definitions available to text elements.", + "type": "array", + "items": { + "type": "object", + "required": ["name", "path"], + "additionalProperties": false, + "properties": { + "name": { + "description": "Logical name used to reference this font in elements.", + "type": "string" + }, + "path": { + "description": "Path to the font file (TTF, OTF, WOFF2).", + "type": "string" + }, + "fallback": { + "description": "Name of a fallback font if glyphs are missing.", + "type": "string" + } + } + } + }, + "layout": { + "description": "Root layout array containing the top-level elements.", + "type": "array", + "items": { + "$ref": "#/definitions/element" + } + } + }, + "definitions": { + "element": { + "description": "A layout element. The 'type' property determines which element-specific properties are valid.", + "type": "object", + "required": ["type"], + "allOf": [ + { + "if": { + "properties": { "type": { "const": "text" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/textElement" } + }, + { + "if": { + "properties": { "type": { "const": "flex" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/flexElement" } + }, + { + "if": { + "properties": { "type": { "const": "image" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/imageElement" } + }, + { + "if": { + "properties": { "type": { "const": "qr" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/qrElement" } + }, + { + "if": { + "properties": { "type": { "const": "barcode" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/barcodeElement" } + }, + { + "if": { + "properties": { "type": { "const": "separator" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/separatorElement" } + }, + { + "if": { + "properties": { "type": { "const": "svg" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/svgElement" } + }, + { + "if": { + "properties": { "type": { "const": "table" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/tableElement" } + }, + { + "if": { + "properties": { "type": { "const": "each" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/eachElement" } + }, + { + "if": { + "properties": { "type": { "const": "if" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/ifElement" } + }, + { + "if": { + "properties": { "type": { "const": "content" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/contentElement" } + } + ], + "properties": { + "type": { + "description": "The element type.", + "type": "string", + "enum": ["text", "flex", "image", "qr", "barcode", "separator", "svg", "table", "each", "if", "content"] + } + } + }, + + "flexItemProperties": { + "description": "Common flex-item properties shared by all visual elements.", + "type": "object", + "properties": { + "grow": { + "description": "Flex grow factor.", + "type": "number", + "minimum": 0 + }, + "shrink": { + "description": "Flex shrink factor.", + "type": "number", + "minimum": 0 + }, + "basis": { + "description": "Flex basis (initial main size).", + "type": ["number", "string"] + }, + "order": { + "description": "Order for flex item sorting.", + "type": "integer" + }, + "display": { + "description": "Display mode.", + "type": "string", + "enum": ["flex", "none"] + }, + "alignSelf": { + "description": "Override the parent's align for this item.", + "type": "string", + "enum": ["auto", "start", "center", "end", "stretch", "baseline"] + }, + "width": { + "description": "Element width (pixels or percentage string).", + "type": ["number", "string"] + }, + "height": { + "description": "Element height (pixels or percentage string).", + "type": ["number", "string"] + }, + "minWidth": { + "description": "Minimum width.", + "type": ["number", "string"] + }, + "min-width": { + "description": "Minimum width (kebab-case alias).", + "type": ["number", "string"] + }, + "maxWidth": { + "description": "Maximum width.", + "type": ["number", "string"] + }, + "max-width": { + "description": "Maximum width (kebab-case alias).", + "type": ["number", "string"] + }, + "minHeight": { + "description": "Minimum height.", + "type": ["number", "string"] + }, + "min-height": { + "description": "Minimum height (kebab-case alias).", + "type": ["number", "string"] + }, + "maxHeight": { + "description": "Maximum height.", + "type": ["number", "string"] + }, + "max-height": { + "description": "Maximum height (kebab-case alias).", + "type": ["number", "string"] + }, + "padding": { + "description": "Padding (single value or shorthand string, e.g., '10' or '10 20 10 20').", + "type": ["number", "string"] + }, + "margin": { + "description": "Margin (single value or shorthand string).", + "type": ["number", "string"] + }, + "background": { + "description": "Background color (hex string).", + "type": "string" + }, + "opacity": { + "description": "Opacity from 0.0 (transparent) to 1.0 (opaque).", + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "rotate": { + "description": "Rotation angle in degrees.", + "type": "number" + }, + "boxShadow": { + "description": "Box shadow definition string.", + "type": "string" + }, + "box-shadow": { + "description": "Box shadow (kebab-case alias).", + "type": "string" + }, + "borderRadius": { + "description": "Border radius (pixels or shorthand).", + "type": ["number", "string"] + }, + "border-radius": { + "description": "Border radius (kebab-case alias).", + "type": ["number", "string"] + }, + "position": { + "description": "Positioning mode.", + "type": "string", + "enum": ["static", "relative", "absolute"] + }, + "top": { + "description": "Top offset for positioned elements.", + "type": ["number", "string"] + }, + "right": { + "description": "Right offset for positioned elements.", + "type": ["number", "string"] + }, + "bottom": { + "description": "Bottom offset for positioned elements.", + "type": ["number", "string"] + }, + "left": { + "description": "Left offset for positioned elements.", + "type": ["number", "string"] + }, + "aspectRatio": { + "description": "Aspect ratio constraint (e.g., 1.5 or '16/9').", + "type": ["number", "string"] + }, + "aspect-ratio": { + "description": "Aspect ratio (kebab-case alias).", + "type": ["number", "string"] + }, + "border": { + "description": "Border shorthand (e.g., '1 solid #000').", + "type": "string" + }, + "borderWidth": { + "description": "Border width in pixels.", + "type": ["number", "string"] + }, + "border-width": { + "description": "Border width (kebab-case alias).", + "type": ["number", "string"] + }, + "borderColor": { + "description": "Border color (hex string).", + "type": "string" + }, + "border-color": { + "description": "Border color (kebab-case alias).", + "type": "string" + }, + "borderStyle": { + "description": "Border style.", + "type": "string", + "enum": ["solid", "dashed", "dotted"] + }, + "border-style": { + "description": "Border style (kebab-case alias).", + "type": "string", + "enum": ["solid", "dashed", "dotted"] + }, + "borderTop": { + "description": "Top border shorthand.", + "type": "string" + }, + "border-top": { + "description": "Top border (kebab-case alias).", + "type": "string" + }, + "borderRight": { + "description": "Right border shorthand.", + "type": "string" + }, + "border-right": { + "description": "Right border (kebab-case alias).", + "type": "string" + }, + "borderBottom": { + "description": "Bottom border shorthand.", + "type": "string" + }, + "border-bottom": { + "description": "Bottom border (kebab-case alias).", + "type": "string" + }, + "borderLeft": { + "description": "Left border shorthand.", + "type": "string" + }, + "border-left": { + "description": "Left border (kebab-case alias).", + "type": "string" + }, + "text-direction": { + "description": "Text direction override for this element.", + "type": "string", + "enum": ["ltr", "rtl"] + } + } + }, + + "textElement": { + "description": "Renders styled text content.", + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" } + ], + "properties": { + "type": { "const": "text" }, + "content": { + "description": "Text content to render. Supports {{variable}} data binding.", + "type": "string" + }, + "font": { + "description": "Logical font name (must match a name in the fonts array).", + "type": "string" + }, + "fontFamily": { + "description": "Font family name.", + "type": "string" + }, + "font-family": { + "description": "Font family (kebab-case alias).", + "type": "string" + }, + "size": { + "description": "Font size in pixels.", + "type": "number", + "minimum": 1 + }, + "color": { + "description": "Text color (hex string).", + "type": "string" + }, + "align": { + "description": "Horizontal text alignment.", + "type": "string", + "enum": ["left", "center", "right", "start", "end"] + }, + "wrap": { + "description": "Whether text wraps to multiple lines.", + "type": "boolean", + "default": true + }, + "overflow": { + "description": "Text overflow behavior.", + "type": "string", + "enum": ["ellipsis", "clip", "visible"] + }, + "maxLines": { + "description": "Maximum number of lines before truncation.", + "type": "integer", + "minimum": 1 + }, + "lineHeight": { + "description": "Line height multiplier.", + "type": "number" + }, + "fontWeight": { + "description": "Font weight (e.g., 'bold', 'normal', or numeric 100-900).", + "type": ["string", "number"] + }, + "fontStyle": { + "description": "Font style.", + "type": "string", + "enum": ["normal", "italic", "oblique"] + } + } + }, + + "flexElement": { + "description": "A flex container that arranges children using flexbox layout.", + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" } + ], + "properties": { + "type": { "const": "flex" }, + "direction": { + "description": "Flex direction.", + "type": "string", + "enum": ["row", "column", "rowReverse", "columnReverse"] + }, + "wrap": { + "description": "Flex wrap behavior.", + "type": "string", + "enum": ["noWrap", "wrap", "wrapReverse"] + }, + "gap": { + "description": "Gap between children (shorthand for both row and column gap).", + "type": ["number", "string"] + }, + "rowGap": { + "description": "Gap between rows.", + "type": ["number", "string"] + }, + "row-gap": { + "description": "Gap between rows (kebab-case alias).", + "type": ["number", "string"] + }, + "columnGap": { + "description": "Gap between columns.", + "type": ["number", "string"] + }, + "column-gap": { + "description": "Gap between columns (kebab-case alias).", + "type": ["number", "string"] + }, + "justify": { + "description": "Justify content along the main axis.", + "type": "string", + "enum": ["start", "center", "end", "spaceBetween", "spaceAround", "spaceEvenly"] + }, + "align": { + "description": "Align items along the cross axis.", + "type": "string", + "enum": ["start", "center", "end", "stretch", "baseline"] + }, + "alignContent": { + "description": "Align content for multi-line flex containers.", + "type": "string", + "enum": ["start", "center", "end", "stretch", "spaceBetween", "spaceAround", "spaceEvenly"] + }, + "align-content": { + "description": "Align content (kebab-case alias).", + "type": "string", + "enum": ["start", "center", "end", "stretch", "spaceBetween", "spaceAround", "spaceEvenly"] + }, + "overflow": { + "description": "Overflow behavior for content exceeding bounds.", + "type": "string", + "enum": ["visible", "hidden"] + }, + "children": { + "description": "Child elements arranged by this flex container.", + "type": "array", + "items": { + "$ref": "#/definitions/element" + } + } + } + }, + + "imageElement": { + "description": "Renders an image from a source path or URL.", + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" } + ], + "required": ["src"], + "properties": { + "type": { "const": "image" }, + "src": { + "description": "Image source path or URL.", + "type": "string" + }, + "fit": { + "description": "How the image fits within its bounds.", + "type": "string", + "enum": ["fill", "contain", "cover", "none"] + } + } + }, + + "qrElement": { + "description": "Renders a QR code.", + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" } + ], + "required": ["data"], + "properties": { + "type": { "const": "qr" }, + "data": { + "description": "Data to encode in the QR code. Supports {{variable}} binding.", + "type": "string" + }, + "size": { + "description": "QR code size in pixels.", + "type": "integer", + "minimum": 1 + }, + "foreground": { + "description": "Foreground (module) color.", + "type": "string" + }, + "errorCorrection": { + "description": "Error correction level.", + "type": "string", + "enum": ["l", "m", "q", "h"] + } + } + }, + + "barcodeElement": { + "description": "Renders a barcode.", + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" } + ], + "required": ["data"], + "properties": { + "type": { "const": "barcode" }, + "data": { + "description": "Data to encode in the barcode. Supports {{variable}} binding.", + "type": "string" + }, + "showText": { + "description": "Whether to display the encoded text below the barcode.", + "type": "boolean" + }, + "foreground": { + "description": "Barcode color.", + "type": "string" + }, + "format": { + "description": "Barcode format/symbology.", + "type": "string", + "enum": ["Code128", "Code39", "Ean13", "Ean8", "Upc"] + } + } + }, + + "separatorElement": { + "description": "Renders a horizontal or vertical separator line.", + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" } + ], + "properties": { + "type": { "const": "separator" }, + "orientation": { + "description": "Separator orientation.", + "type": "string", + "enum": ["horizontal", "vertical"] + }, + "style": { + "description": "Line style.", + "type": "string", + "enum": ["dotted", "dashed", "solid"] + }, + "thickness": { + "description": "Line thickness in pixels.", + "type": "number", + "minimum": 0 + }, + "color": { + "description": "Line color (hex string).", + "type": "string" + } + } + }, + + "svgElement": { + "description": "Renders an SVG image from a source path or inline content.", + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" } + ], + "properties": { + "type": { "const": "svg" }, + "src": { + "description": "SVG source path or URL.", + "type": "string" + }, + "content": { + "description": "Inline SVG content string.", + "type": "string" + }, + "fit": { + "description": "How the SVG fits within its bounds.", + "type": "string", + "enum": ["fill", "contain", "cover", "none"] + } + } + }, + + "tableElement": { + "description": "Renders a data table with columns, optional header styling, and data binding.", + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" } + ], + "properties": { + "type": { "const": "table" }, + "columns": { + "description": "Column definitions for the table.", + "type": "array", + "items": { + "type": "object" + } + }, + "array": { + "description": "Data binding path to the array of row data.", + "type": "string" + }, + "rows": { + "description": "Static row data (alternative to data-bound array).", + "type": "array" + }, + "as": { + "description": "Variable name for each row item in data binding.", + "type": "string" + }, + "font": { + "description": "Font name for table body text.", + "type": "string" + }, + "size": { + "description": "Font size for table body text.", + "type": "number" + }, + "color": { + "description": "Text color for table body.", + "type": "string" + }, + "rowGap": { + "description": "Gap between table rows.", + "type": ["number", "string"] + }, + "row-gap": { + "description": "Gap between table rows (kebab-case alias).", + "type": ["number", "string"] + }, + "columnGap": { + "description": "Gap between table columns.", + "type": ["number", "string"] + }, + "column-gap": { + "description": "Gap between table columns (kebab-case alias).", + "type": ["number", "string"] + }, + "headerFont": { + "description": "Font name for the header row.", + "type": "string" + }, + "header-font": { + "description": "Header font (kebab-case alias).", + "type": "string" + }, + "headerFontWeight": { + "description": "Font weight for the header row.", + "type": ["string", "number"] + }, + "header-fontWeight": { + "description": "Header font weight (kebab-case alias).", + "type": ["string", "number"] + }, + "headerFontStyle": { + "description": "Font style for the header row.", + "type": "string" + }, + "header-fontStyle": { + "description": "Header font style (kebab-case alias).", + "type": "string" + }, + "headerFontFamily": { + "description": "Font family for the header row.", + "type": "string" + }, + "header-fontFamily": { + "description": "Header font family (kebab-case alias).", + "type": "string" + }, + "headerColor": { + "description": "Text color for the header row.", + "type": "string" + }, + "header-color": { + "description": "Header color (kebab-case alias).", + "type": "string" + }, + "headerSize": { + "description": "Font size for the header row.", + "type": "number" + }, + "header-size": { + "description": "Header size (kebab-case alias).", + "type": "number" + }, + "headerBorderBottom": { + "description": "Border below the header row (e.g., '1 solid #000').", + "type": "string" + }, + "header-border-bottom": { + "description": "Header border bottom (kebab-case alias).", + "type": "string" + } + } + }, + + "eachElement": { + "description": "Iterates over a data array and renders children for each item.", + "required": ["array", "children"], + "properties": { + "type": { "const": "each" }, + "array": { + "description": "Data binding path to the array to iterate over.", + "type": "string" + }, + "as": { + "description": "Variable name for the current item (default: 'item').", + "type": "string" + }, + "children": { + "description": "Elements to render for each array item.", + "type": "array", + "items": { + "$ref": "#/definitions/element" + } + } + }, + "additionalProperties": false + }, + + "ifElement": { + "description": "Conditional rendering based on data values.", + "required": ["condition", "then"], + "properties": { + "type": { "const": "if" }, + "condition": { + "description": "Data binding path to the value to evaluate.", + "type": "string" + }, + "equals": { + "description": "Matches when the condition value equals this value." + }, + "notEquals": { + "description": "Matches when the condition value does not equal this value." + }, + "in": { + "description": "Matches when the condition value is one of these values.", + "type": "array" + }, + "notIn": { + "description": "Matches when the condition value is not one of these values.", + "type": "array" + }, + "contains": { + "description": "Matches when the condition value (string) contains this substring.", + "type": "string" + }, + "greaterThan": { + "description": "Matches when the condition value is greater than this number.", + "type": "number" + }, + "lessThan": { + "description": "Matches when the condition value is less than this number.", + "type": "number" + }, + "greaterThanOrEqual": { + "description": "Matches when the condition value is greater than or equal to this number.", + "type": "number" + }, + "lessThanOrEqual": { + "description": "Matches when the condition value is less than or equal to this number.", + "type": "number" + }, + "hasItems": { + "description": "Matches when the condition value (array) has items (true) or is empty (false).", + "type": "boolean" + }, + "countEquals": { + "description": "Matches when the condition value (array) has exactly this many items.", + "type": "number" + }, + "countGreaterThan": { + "description": "Matches when the condition value (array) has more than this many items.", + "type": "number" + }, + "then": { + "description": "Elements to render when the condition is true.", + "type": "array", + "items": { + "$ref": "#/definitions/element" + } + }, + "else": { + "description": "Elements to render when the condition is false.", + "type": "array", + "items": { + "$ref": "#/definitions/element" + } + }, + "elseIf": { + "description": "Chained conditional (another if-style object).", + "type": "object" + } + }, + "additionalProperties": false + }, + + "contentElement": { + "description": "Renders external content (NDC, Markdown, HTML) from a source file.", + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" } + ], + "required": ["source"], + "properties": { + "type": { "const": "content" }, + "source": { + "description": "Path to the content source file.", + "type": "string" + }, + "format": { + "description": "Content format.", + "type": "string", + "enum": ["ndc", "markdown", "html"] + }, + "options": { + "description": "Format-specific rendering options.", + "type": "object" + } + } + } + } +} diff --git a/src/FlexRender.Playground/wwwroot/style.css b/src/FlexRender.Playground/wwwroot/style.css index dadd771..26fd4f0 100644 --- a/src/FlexRender.Playground/wwwroot/style.css +++ b/src/FlexRender.Playground/wwwroot/style.css @@ -67,6 +67,30 @@ body { .toolbar .spacer { flex: 1; } +#project-select { + max-width: 220px; +} + +#btn-new-project, +#btn-delete-project, +#btn-reset-example { + padding: 4px 8px; + font-size: 12px; +} + +#btn-delete-project { + color: #d4d4d4; +} + +#btn-delete-project:hover { + background: #c72e2e; + border-color: #c72e2e; +} + +#btn-reset-example:hover { + background: #4c4c4c; +} + .main-content { display: flex; flex: 1; diff --git a/src/FlexRender.Playground/wwwroot/vfs.mjs b/src/FlexRender.Playground/wwwroot/vfs.mjs index 9bcfecb..8bcc6b6 100644 --- a/src/FlexRender.Playground/wwwroot/vfs.mjs +++ b/src/FlexRender.Playground/wwwroot/vfs.mjs @@ -1,9 +1,5 @@ // Virtual File System for FlexRender Playground. -// Manages an in-memory Map with IndexedDB persistence. - -const DB_NAME = 'flexrender-vfs'; -const DB_VERSION = 1; -const STORE_NAME = 'files'; +// Purely in-memory Map. Persistence is owned by projects.mjs. /** @type {Map} */ const files = new Map(); @@ -44,103 +40,32 @@ function notify(event, path) { } } -// --- IndexedDB helpers --- - -function openDb() { - return new Promise((resolve, reject) => { - const req = indexedDB.open(DB_NAME, DB_VERSION); - req.onupgradeneeded = () => { - const db = req.result; - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME); - } - }; - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); -} - -async function dbPut(path, entry) { - const db = await openDb(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, 'readwrite'); - tx.objectStore(STORE_NAME).put({ data: entry.data.buffer, type: entry.type }, path); - tx.oncomplete = () => { db.close(); resolve(); }; - tx.onerror = () => { db.close(); reject(tx.error); }; - }); -} - -async function dbDelete(path) { - const db = await openDb(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, 'readwrite'); - tx.objectStore(STORE_NAME).delete(path); - tx.oncomplete = () => { db.close(); resolve(); }; - tx.onerror = () => { db.close(); reject(tx.error); }; - }); -} - -async function dbClear() { - const db = await openDb(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, 'readwrite'); - tx.objectStore(STORE_NAME).clear(); - tx.oncomplete = () => { db.close(); resolve(); }; - tx.onerror = () => { db.close(); reject(tx.error); }; - }); -} - -async function dbGetAll() { - const db = await openDb(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, 'readonly'); - const store = tx.objectStore(STORE_NAME); - const req = store.openCursor(); - const entries = []; - req.onsuccess = () => { - const cursor = req.result; - if (cursor) { - entries.push({ path: cursor.key, data: new Uint8Array(cursor.value.data), type: cursor.value.type }); - cursor.continue(); - } else { - db.close(); - resolve(entries); - } - }; - req.onerror = () => { db.close(); reject(req.error); }; - }); -} - // --- Public API --- -/** Add or overwrite a file. */ -export async function addFile(path, data, type) { +/** Add or overwrite a file (in-memory only). */ +export function addFile(path, data, type) { path = normalizePath(path); if (!type) type = detectType(path); files.set(path, { data, type }); - await dbPut(path, { data, type }); notify('add', path); } -/** Remove a file. */ -export async function removeFile(path) { +/** Remove a file (in-memory only). */ +export function removeFile(path) { path = normalizePath(path); if (!files.has(path)) return; files.delete(path); - await dbDelete(path); notify('remove', path); } -/** Rename/move a file. */ -export async function renameFile(oldPath, newPath) { +/** Rename/move a file (in-memory only). */ +export function renameFile(oldPath, newPath) { oldPath = normalizePath(oldPath); newPath = normalizePath(newPath); const entry = files.get(oldPath); if (!entry) return; files.delete(oldPath); files.set(newPath, entry); - await dbDelete(oldPath); - await dbPut(newPath, entry); notify('rename', oldPath); notify('add', newPath); } @@ -165,25 +90,41 @@ export function exists(path) { return files.has(normalizePath(path)); } -/** Clear all files. */ -export async function clearAll() { +/** Clear all files (in-memory only). */ +export function clearAll() { files.clear(); - await dbClear(); notify('clear', ''); } -/** Load all files from IndexedDB into memory. Call once at startup. */ -export async function restore() { - try { - const entries = await dbGetAll(); - for (const e of entries) { - files.set(e.path, { data: e.data, type: e.type }); - } - return entries.length; - } catch (err) { - console.warn('VFS restore from IndexedDB failed:', err); - return 0; +/** + * Load files from a project into the in-memory VFS. + * Clears current files, loads given array, and notifies listeners. + * Does NOT write to IndexedDB — projects.mjs handles persistence. + * @param {{path: string, data: Uint8Array, type: string}[]} projectFiles + */ +export function loadFromProject(projectFiles) { + files.clear(); + for (const f of projectFiles) { + const path = normalizePath(f.path); + files.set(path, { data: f.data, type: f.type || detectType(path) }); } + notify('clear', ''); + // Notify for each file so WASM resource loader can pick them up + for (const [path] of files) { + notify('add', path); + } +} + +/** + * Export current VFS files as an array for saving into a project. + * @returns {{path: string, data: Uint8Array, type: string}[]} + */ +export function exportFiles() { + return [...files.entries()].map(([path, entry]) => ({ + path, + data: entry.data, + type: entry.type, + })); } /** diff --git a/src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs b/src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs new file mode 100644 index 0000000..802985e --- /dev/null +++ b/src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs @@ -0,0 +1,413 @@ +// Custom YAML autocomplete provider for FlexRender templates. +// Uses the JSON schema directly — no workers, no monaco-yaml dependency. + +/** + * @param {typeof import('monaco-editor')} monaco + * @param {object} schema - The FlexRender JSON schema + * @param {object} [options] - Optional configuration + * @param {() => Array<{path: string, type: string}>} [options.getVfsFiles] - Callback returning VFS files + */ +export function registerYamlAutocomplete(monaco, schema, options = {}) { + const defs = schema.definitions || {}; + const rootProps = schema.properties || {}; + + // Collect element types from the enum + const elementTypes = rootProps.layout?.items?.$ref + ? resolveRef(defs, schema, '#/definitions/element')?.properties?.type?.enum || [] + : []; + + // Build a map: elementType -> merged properties (element-specific + flexItemProperties) + const elementPropsMap = {}; + for (const elType of elementTypes) { + elementPropsMap[elType] = getElementProperties(schema, defs, elType); + } + + // --- Hover provider: show property docs on mouse hover --- + monaco.languages.registerHoverProvider('yaml', { + provideHover(model, position) { + const line = model.getLineContent(position.lineNumber); + const word = model.getWordAtPosition(position); + if (!word) return null; + + const textUntil = model.getValueInRange({ + startLineNumber: 1, startColumn: 1, + endLineNumber: position.lineNumber, endColumn: position.column, + }); + + const key = word.word; + + // Check if this word is a YAML key (followed by colon) + const afterWord = line.substring(word.endColumn - 1).trimStart(); + const isKey = afterWord.startsWith(':'); + + // Check if this word is a value after "type:" + const typeValMatch = line.match(/^\s*-?\s*type:\s*(\w+)/); + if (typeValMatch && typeValMatch[1] === key) { + const desc = getElementDescription(defs, key); + if (desc) { + return { + range: new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn), + contents: [ + { value: `**${key}** element` }, + { value: desc }, + ], + }; + } + } + + if (!isKey) return null; + + // Find property definition based on context + const propDef = findPropertyDef(key, textUntil, schema, defs, elementPropsMap); + if (!propDef) return null; + + const contents = [{ value: `**${key}**` }]; + if (propDef.description) contents.push({ value: propDef.description }); + + const typeInfo = []; + if (propDef.type) typeInfo.push(`Type: \`${Array.isArray(propDef.type) ? propDef.type.join(' | ') : propDef.type}\``); + if (propDef.enum) typeInfo.push(`Values: ${propDef.enum.map(v => '`' + v + '`').join(', ')}`); + if (propDef.minimum !== undefined) typeInfo.push(`Min: \`${propDef.minimum}\``); + if (propDef.maximum !== undefined) typeInfo.push(`Max: \`${propDef.maximum}\``); + if (propDef.default !== undefined) typeInfo.push(`Default: \`${propDef.default}\``); + if (typeInfo.length) contents.push({ value: typeInfo.join(' · ') }); + + return { + range: new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn), + contents, + }; + } + }); + + // --- Completion provider --- + monaco.languages.registerCompletionItemProvider('yaml', { + triggerCharacters: ['\n', ' ', ':'], + provideCompletionItems(model, position) { + const textUntilPosition = model.getValueInRange({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + + const currentLine = model.getLineContent(position.lineNumber); + const indent = currentLine.match(/^(\s*)/)[1].length; + const word = model.getWordUntilPosition(position); + + const range = { + startLineNumber: position.lineNumber, + startColumn: word.startColumn, + endLineNumber: position.lineNumber, + endColumn: word.endColumn, + }; + + // After a colon with a space — suggest values + // Handle both "key: " and "- key: " (YAML list item) + const colonMatch = currentLine.match(/^\s*(?:-\s+)?(\S+)\s*:\s*/); + if (colonMatch && position.column > currentLine.indexOf(':') + 2) { + return suggestValues(monaco, colonMatch[1], textUntilPosition, schema, defs, elementPropsMap, range, options); + } + + // Determine context from indentation + const context = detectContext(textUntilPosition, indent); + + switch (context.type) { + case 'root': + return makeSuggestions(monaco, rootProps, range, 'root'); + case 'canvas': + return makeSuggestions(monaco, rootProps.canvas?.properties || {}, range, 'canvas'); + case 'font-item': + return makeSuggestions(monaco, defs.flexItemProperties ? rootProps.fonts?.items?.properties || {} : {}, range, 'font'); + case 'element': { + const elType = context.elementType; + const props = elType && elementPropsMap[elType] + ? elementPropsMap[elType] + : { type: { description: 'The element type', type: 'string' } }; + return makeSuggestions(monaco, props, range, 'element'); + } + case 'template': + return makeSuggestions(monaco, rootProps.template?.properties || {}, range, 'template'); + default: + return { suggestions: [] }; + } + } + }); +} + +function detectContext(text, currentIndent) { + const lines = text.split('\n'); + + // Walk backwards to find context + for (let i = lines.length - 2; i >= 0; i--) { + const line = lines[i]; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const lineIndent = line.match(/^(\s*)/)[1].length; + + // If this line is less indented, it's a parent context + if (lineIndent < currentIndent) { + if (trimmed === 'canvas:') return { type: 'canvas' }; + if (trimmed === 'template:') return { type: 'template' }; + if (trimmed === 'fonts:' || trimmed === '- name:' || trimmed.startsWith('- name:')) { + return { type: 'font-item' }; + } + if (trimmed === 'layout:' || trimmed === 'children:' || trimmed === 'then:' || trimmed === 'else:') { + return { type: 'element', elementType: null }; + } + + // Check if we're inside an element (look for type: xxx at same or parent indent) + const typeMatch = trimmed.match(/^-?\s*type:\s*(\w+)/); + if (typeMatch) { + return { type: 'element', elementType: typeMatch[1] }; + } + + // Check if it's an array item marker + if (trimmed.startsWith('- ')) { + // Look further back for the array parent + continue; + } + + // Look for element type in sibling lines at same indent level + if (lineIndent === currentIndent || lineIndent === currentIndent - 2) { + // scan siblings + for (let j = i; j >= 0; j--) { + const sibLine = lines[j].trim(); + const sibIndent = lines[j].match(/^(\s*)/)[1].length; + if (sibIndent < lineIndent) break; + const sibType = sibLine.match(/^-?\s*type:\s*(\w+)/); + if (sibType) { + return { type: 'element', elementType: sibType[1] }; + } + } + } + } + } + + // Top-level (indent 0) + if (currentIndent === 0) return { type: 'root' }; + + return { type: 'unknown' }; +} + +function suggestValues(monaco, key, textUntilPosition, schema, defs, elementPropsMap, range, options) { + const suggestions = []; + + // "type" key — suggest element types + if (key === 'type' || key === '- type') { + const types = schema.definitions?.element?.properties?.type?.enum || []; + for (const t of types) { + suggestions.push({ + label: t, + kind: monaco.languages.CompletionItemKind.EnumMember, + insertText: t, + range, + detail: getElementDescription(defs, t), + }); + } + return { suggestions }; + } + + // Find the property definition to get enum values + const propDef = findPropertyDef(key, textUntilPosition, schema, defs, elementPropsMap); + if (propDef?.enum) { + for (const v of propDef.enum) { + suggestions.push({ + label: String(v), + kind: monaco.languages.CompletionItemKind.EnumMember, + insertText: String(v), + range, + detail: propDef.description || '', + }); + } + } + + // Boolean suggestions + if (propDef?.type === 'boolean') { + for (const v of ['true', 'false']) { + suggestions.push({ + label: v, + kind: monaco.languages.CompletionItemKind.Value, + insertText: v, + range, + }); + } + } + + // VFS file path suggestions for path/src/content keys + const cleanKey = key.replace(/^-\s*/, ''); + if (options?.getVfsFiles && isFilePathKey(cleanKey)) { + const expectedType = getExpectedFileType(cleanKey, textUntilPosition); + const vfsFiles = options.getVfsFiles(); + for (const file of vfsFiles) { + const isMatch = file.type === expectedType; + const insertText = file.path.includes(' ') ? `"${file.path}"` : file.path; + suggestions.push({ + label: file.path, + kind: monaco.languages.CompletionItemKind.File, + insertText, + range, + detail: file.type, + sortText: isMatch ? '0-' + file.path : '1-' + file.path, + }); + } + } + + return { suggestions }; +} + +/** Determines whether the given key should trigger VFS file suggestions. */ +function isFilePathKey(key) { + return key === 'path' || key === 'src' || key === 'content'; +} + +/** Returns the expected VFS file type based on key and YAML context. */ +function getExpectedFileType(key, textUntilPosition) { + if (key === 'path') { + // In fonts context, suggest font files + const lines = textUntilPosition.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + const trimmed = lines[i].trim(); + if (trimmed === 'fonts:' || trimmed.startsWith('- name:')) return 'font'; + } + return 'font'; + } + if (key === 'src') { + // In image element context, suggest images + return 'image'; + } + if (key === 'content') { + // Only suggest files if inside a content-type element + const lines = textUntilPosition.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + const typeMatch = lines[i].trim().match(/^-?\s*type:\s*(\w+)/); + if (typeMatch) { + if (typeMatch[1] === 'content') return 'content'; + // For non-content elements, content is typically a text string, not a file + return null; + } + } + return null; + } + return null; +} + +function findPropertyDef(key, textUntilPosition, schema, defs, elementPropsMap) { + const cleanKey = key.replace(/^-\s*/, ''); + + // Check root properties + if (schema.properties?.[cleanKey]) return schema.properties[cleanKey]; + + // Check canvas + if (schema.properties?.canvas?.properties?.[cleanKey]) return schema.properties.canvas.properties[cleanKey]; + + // Check element type from context + const lines = textUntilPosition.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + const typeMatch = lines[i].trim().match(/^-?\s*type:\s*(\w+)/); + if (typeMatch) { + const props = elementPropsMap[typeMatch[1]]; + if (props?.[cleanKey]) return props[cleanKey]; + break; + } + } + + // Check flex item properties as fallback + if (defs.flexItemProperties?.properties?.[cleanKey]) { + return defs.flexItemProperties.properties[cleanKey]; + } + + return null; +} + +function makeSuggestions(monaco, properties, range, context) { + const suggestions = []; + for (const [key, def] of Object.entries(properties)) { + // Skip kebab-case aliases if camelCase exists + if (key.includes('-') && properties[key.replace(/-([a-z])/g, (_, c) => c.toUpperCase())]) { + continue; + } + + const kind = def.enum + ? monaco.languages.CompletionItemKind.Enum + : def.type === 'object' || def.type === 'array' + ? monaco.languages.CompletionItemKind.Module + : monaco.languages.CompletionItemKind.Property; + + let insertText = key + ': '; + if (def.type === 'array' && key !== 'enum') { + insertText = key + ':\n - '; + } else if (def.type === 'object') { + insertText = key + ':\n '; + } + + suggestions.push({ + label: key, + kind, + insertText, + range, + detail: formatType(def), + documentation: def.description || '', + sortText: getSortOrder(key, context), + }); + } + return { suggestions }; +} + +function formatType(def) { + if (def.enum) return `enum: ${def.enum.join(' | ')}`; + if (Array.isArray(def.type)) return def.type.join(' | '); + return def.type || ''; +} + +function getSortOrder(key, context) { + // Prioritize commonly used properties + const priority = { + root: { canvas: '0', fonts: '1', layout: '2', template: '3' }, + canvas: { width: '0', height: '1', background: '2', fixed: '3' }, + element: { type: '0', content: '1', children: '1', src: '1', data: '1', direction: '2', size: '2', color: '2', font: '3', padding: '4' }, + font: { name: '0', path: '1', fallback: '2' }, + }; + return priority[context]?.[key] || '5'; +} + +function getElementDescription(defs, type) { + const defName = type + 'Element'; + return defs[defName]?.description || ''; +} + +function getElementProperties(schema, defs, elType) { + const defName = elType + 'Element'; + const elDef = defs[defName]; + if (!elDef) return {}; + + // Merge flex item properties + element-specific properties + const merged = {}; + + // Add flex item properties first (from allOf -> $ref) + if (elDef.allOf) { + for (const entry of elDef.allOf) { + if (entry.$ref) { + const resolved = resolveRef(defs, schema, entry.$ref); + if (resolved?.properties) { + Object.assign(merged, resolved.properties); + } + } + } + } + + // Add element-specific properties + if (elDef.properties) { + Object.assign(merged, elDef.properties); + } + + // Remove 'type' const — we already suggest it separately + delete merged.type; + + return merged; +} + +function resolveRef(defs, _schema, ref) { + const path = ref.replace('#/definitions/', ''); + return defs[path] || null; +} From 22af127e5721effb7f82e146295388b0a85aff7b Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 10 Mar 2026 13:27:45 +0300 Subject: [PATCH 16/25] fix(wasm): enable LinearMetrics for consistent text measurement across platforms FreeType in WASM quantizes glyph advances to integers, causing text width miscalculation and overlapping elements. Setting LinearMetrics=true on SKFont uses font design table metrics (fractional) instead of hinted metrics, matching macOS CoreText behavior. Also adds bounds overlay toggle and font diagnostics to playground UI. --- .../Layout/IntrinsicMeasurer.cs | 1 + src/FlexRender.Core/Layout/LayoutEngine.cs | 4 +- src/FlexRender.Playground/PlaygroundApi.cs | 69 ++++++++++ src/FlexRender.Playground/wwwroot/main.js | 128 +++++++++++++++++- src/FlexRender.Playground/wwwroot/style.css | 9 +- .../Rendering/SkiaTextShaper.cs | 1 + .../Rendering/TextRenderer.cs | 4 +- 7 files changed, 208 insertions(+), 8 deletions(-) diff --git a/src/FlexRender.Core/Layout/IntrinsicMeasurer.cs b/src/FlexRender.Core/Layout/IntrinsicMeasurer.cs index 3bc8c55..787feb4 100644 --- a/src/FlexRender.Core/Layout/IntrinsicMeasurer.cs +++ b/src/FlexRender.Core/Layout/IntrinsicMeasurer.cs @@ -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; diff --git a/src/FlexRender.Core/Layout/LayoutEngine.cs b/src/FlexRender.Core/Layout/LayoutEngine.cs index 60f4631..97172f3 100644 --- a/src/FlexRender.Core/Layout/LayoutEngine.cs +++ b/src/FlexRender.Core/Layout/LayoutEngine.cs @@ -867,7 +867,9 @@ private float ComputeFitContentFontSize(FlexElement flex, LayoutContext innerCon // Floor to 0.1px to avoid rounding errors where text barely exceeds container width // due to non-linear font scaling (hinting, glyph rounding) var computed = refSize * availableWidth / totalMeasured; - return MathF.Floor(computed * 10f) / 10f; + var floored = MathF.Floor(computed * 10f) / 10f; + + return floored; } private float MeasureContentWidth(TemplateElement element, LayoutContext context) diff --git a/src/FlexRender.Playground/PlaygroundApi.cs b/src/FlexRender.Playground/PlaygroundApi.cs index 567804c..a53522f 100644 --- a/src/FlexRender.Playground/PlaygroundApi.cs +++ b/src/FlexRender.Playground/PlaygroundApi.cs @@ -361,7 +361,10 @@ private static JsonObject SerializeLayoutNode(LayoutNode node) // Computed properties if (node.ComputedFontSize > 0) + { obj["fontSize"] = Math.Round(node.ComputedFontSize, 1); + obj["fontSizeExact"] = node.ComputedFontSize; + } if (node.Baseline > 0) obj["baseline"] = Math.Round(node.Baseline, 1); @@ -421,6 +424,7 @@ private static void SerializeElementProperties(JsonObject obj, TemplateElement e obj["fontStyle"] = t.FontStyle.Value.ToString().ToLowerInvariant(); if (!string.IsNullOrEmpty(t.Color.Value)) obj["color"] = t.Color.Value; + break; case ImageElement: @@ -584,6 +588,71 @@ private static void DrawGlyphBoundaries( } } + /// + /// Returns a JSON diagnostic report of font loading status, including registered font paths, + /// resolved typeface family names, and all resources in the in-memory VFS. + /// + /// JSON string with font diagnostics, or an error JSON on failure. + [JSExport] + public static string GetFontDiagnostics() + { + try + { + if (_render is not SkiaRender skiaRender) + { + return JsonSerializer.Serialize(new { error = "Not initialized or not using SkiaRender" }); + } + + var fontManager = skiaRender.FontManager; + var registeredPaths = fontManager.RegisteredFontPaths; + + var fonts = new JsonArray(); + foreach (var (name, path) in registeredPaths) + { + var typeface = fontManager.GetTypeface(name); + + // Also test variant lookup (how layout engine resolves fonts) + var boldVariant = fontManager.GetTypeface(name, Parsing.Ast.FontWeight.Bold, Parsing.Ast.FontStyle.Normal); + var normalVariant = fontManager.GetTypeface(name, Parsing.Ast.FontWeight.Normal, Parsing.Ast.FontStyle.Normal); + + // Test the 4-param overload (how NDC elements resolve: font + fontFamily + weight) + var ndcBoldResolve = fontManager.GetTypeface(name, "JetBrains Mono", Parsing.Ast.FontWeight.Bold, Parsing.Ast.FontStyle.Normal); + var ndcNormalResolve = fontManager.GetTypeface(name, "JetBrains Mono", Parsing.Ast.FontWeight.Normal, Parsing.Ast.FontStyle.Normal); + + fonts.Add(new JsonObject + { + ["name"] = name, + ["path"] = path, + ["familyName"] = typeface.FamilyName, + ["isFixedPitch"] = typeface.IsFixedPitch, + ["fontWeight"] = (int)typeface.FontStyle.Weight, + ["isDefault"] = typeface == SkiaSharp.SKTypeface.Default, + // Variant lookups + ["boldVariant"] = $"{boldVariant.FamilyName} w={boldVariant.FontStyle.Weight} fixed={boldVariant.IsFixedPitch} default={boldVariant == SkiaSharp.SKTypeface.Default}", + ["normalVariant"] = $"{normalVariant.FamilyName} w={normalVariant.FontStyle.Weight} fixed={normalVariant.IsFixedPitch} default={normalVariant == SkiaSharp.SKTypeface.Default}", + ["ndcBoldResolve"] = $"{ndcBoldResolve.FamilyName} w={ndcBoldResolve.FontStyle.Weight} fixed={ndcBoldResolve.IsFixedPitch} default={ndcBoldResolve == SkiaSharp.SKTypeface.Default}", + ["ndcNormalResolve"] = $"{ndcNormalResolve.FamilyName} w={ndcNormalResolve.FontStyle.Weight} fixed={ndcNormalResolve.IsFixedPitch} default={ndcNormalResolve == SkiaSharp.SKTypeface.Default}", + }); + } + + var resources = _memoryLoader?.ListResources() ?? []; + + var result = new JsonObject + { + ["registeredFontCount"] = registeredPaths.Count, + ["fonts"] = fonts, + ["memoryResourceCount"] = resources.Count, + ["memoryResources"] = JsonSerializer.SerializeToNode(resources) + }; + + return result.ToJsonString(); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new { error = ex.Message }); + } + } + private static void LoadEmbeddedFont(string resourceName) { var assembly = Assembly.GetExecutingAssembly(); diff --git a/src/FlexRender.Playground/wwwroot/main.js b/src/FlexRender.Playground/wwwroot/main.js index f5a9902..05d69c4 100644 --- a/src/FlexRender.Playground/wwwroot/main.js +++ b/src/FlexRender.Playground/wwwroot/main.js @@ -522,16 +522,39 @@ overlayCheckbox.addEventListener('change', () => { scheduleRender(); }); +// --- Bounds overlay toggle --- +const boundsToggle = document.createElement('label'); +boundsToggle.id = 'bounds-toggle'; +boundsToggle.innerHTML = ' Bounds'; +previewTabs.insertBefore(boundsToggle, zoomSelect); + +const boundsCheckbox = document.getElementById('bounds-checkbox'); +let boundsMode = false; + +boundsCheckbox.addEventListener('change', () => { + boundsMode = boundsCheckbox.checked; + if (boundsMode && lastLayoutData) { + showAllBounds(lastLayoutData); + } else { + hideHighlight(); + } +}); + // --- Layout tree builder (with data attributes for highlight) --- let canvasWidth = 0; let canvasHeight = 0; +let lastLayoutData = null; -function buildLayoutTree(node, depth) { +function buildLayoutTree(node, depth, parentAbsX, parentAbsY) { if (!node || !node.type) return ''; + parentAbsX = parentAbsX || 0; + parentAbsY = parentAbsY || 0; + const absX = parentAbsX + node.x; + const absY = parentAbsY + node.y; const dims = `${node.w}\u00d7${node.h} @ (${node.x}, ${node.y})`; const hasChildren = node.children && node.children.length > 0; const openAttr = depth < 2 ? ' open' : ''; - const dataAttrs = `data-x="${node.x}" data-y="${node.y}" data-w="${node.w}" data-h="${node.h}"`; + const dataAttrs = `data-x="${absX}" data-y="${absY}" data-w="${node.w}" data-h="${node.h}"`; const props = []; if (node.content) props.push(`"${escHtml(node.content)}"`); @@ -552,7 +575,7 @@ function buildLayoutTree(node, depth) { const safeDims = escHtml(dims); if (hasChildren) { - const childrenHtml = node.children.map(c => buildLayoutTree(c, depth + 1)).join(''); + const childrenHtml = node.children.map(c => buildLayoutTree(c, depth + 1, absX, absY)).join(''); return `${safeType} ${safeDims}${propsStr}${childrenHtml}`; } return `
${safeType} ${safeDims}${propsStr}
`; @@ -879,6 +902,64 @@ function hideHighlight() { const imgW = previewImg.offsetWidth; const imgH = previewImg.offsetHeight; highlightCtx.clearRect(0, 0, imgW, imgH); + // Redraw bounds overlay if active + if (boundsMode && lastLayoutData) { + showAllBounds(lastLayoutData); + } +} + +function showAllBounds(layoutData) { + if (!previewImg.naturalWidth || canvasWidth === 0) return; + syncCanvasSize(); + const imgW = previewImg.offsetWidth; + const imgH = previewImg.offsetHeight; + if (imgW === 0) return; + + const scaleX = imgW / canvasWidth; + const scaleY = imgH / canvasHeight; + + highlightCtx.clearRect(0, 0, imgW, imgH); + + function drawNode(node, parentX, parentY) { + if (!node || !node.type) return; + const absX = parentX + node.x; + const absY = parentY + node.y; + const px = absX * scaleX; + const py = absY * scaleY; + const pw = node.w * scaleX; + const ph = node.h * scaleY; + + if (node.type === 'text') { + highlightCtx.strokeStyle = 'rgba(33, 150, 243, 0.7)'; + highlightCtx.lineWidth = 1.5; + highlightCtx.strokeRect(px, py, pw, ph); + + // Draw label with font name and content preview + const fontLabel = node.fontFamily || node.font || ''; + const contentPreview = (node.content || '').substring(0, 10); + const label = fontLabel ? `${fontLabel}: ${contentPreview}` : contentPreview; + if (label) { + highlightCtx.font = '9px system-ui, sans-serif'; + const textMetrics = highlightCtx.measureText(label); + const labelX = px + 1; + const labelY = py > 12 ? py - 2 : py + ph + 10; + highlightCtx.fillStyle = 'rgba(33, 150, 243, 0.85)'; + highlightCtx.fillRect(labelX - 1, labelY - 9, textMetrics.width + 4, 11); + highlightCtx.fillStyle = '#fff'; + highlightCtx.fillText(label, labelX, labelY); + } + } else if (node.type === 'flex' && node.children && node.children.length > 0) { + highlightCtx.strokeStyle = 'rgba(76, 175, 80, 0.4)'; + highlightCtx.lineWidth = 0.5; + highlightCtx.strokeRect(px, py, pw, ph); + } + + if (node.children) { + node.children.forEach(c => drawNode(c, absX, absY)); + } + } + + drawNode(layoutData, 0, 0); } layoutPane.addEventListener('mouseover', (e) => { @@ -982,10 +1063,51 @@ function render() { const layoutData = JSON.parse(layoutJson); canvasWidth = layoutData.w || 0; canvasHeight = layoutData.h || 0; + lastLayoutData = layoutData; + + // Font diagnostics (logged to console for debugging) + try { + const diagJson = api.GetFontDiagnostics(); + const diag = JSON.parse(diagJson); + if (diag.fonts?.length > 0) { + console.group('Font diagnostics'); + for (const f of diag.fonts) { + const status = f.isDefault ? '⚠️ DEFAULT FALLBACK' : `✅ ${f.familyName}`; + console.log(`${f.name}: ${status} (fixed=${f.isFixedPitch}, weight=${f.fontWeight})`); + console.log(` boldVariant: ${f.boldVariant}`); + console.log(` normalVariant: ${f.normalVariant}`); + console.log(` ndcBoldResolve: ${f.ndcBoldResolve}`); + console.log(` ndcNormalResolve: ${f.ndcNormalResolve}`); + } + console.log(`Memory resources (${diag.memoryResourceCount}):`, diag.memoryResources); + console.groupEnd(); + } + } catch (diagErr) { console.warn('Font diagnostics failed:', diagErr); } + + // Layout debug: log first few text elements with metrics + console.group('Layout metrics (first text elements)'); + function logTexts(node, depth, parentX, parentY) { + if (!node) return; + const ax = (parentX || 0) + node.x, ay = (parentY || 0) + node.y; + if (node.type === 'text') { + console.log(`[${ax.toFixed(1)},${ay.toFixed(1)}] ${node.w.toFixed(1)}×${node.h.toFixed(1)} fontSize=${node.fontSize || '?'} fontSizeExact=${node.fontSizeExact || '?'} font=${node.font || '?'} resolved=${node.resolvedTypeface || '?'} "${(node.content || '').substring(0, 30)}"`); + } + (node.children || []).forEach(c => logTexts(c, depth + 1, ax, ay)); + } + logTexts(layoutData, 0, 0, 0); + console.groupEnd(); + layoutPane.innerHTML = '
' + buildLayoutTree(layoutData, 0) + '
'; + // Compare rendered PNG size vs layout size + previewImg.addEventListener('load', () => { + console.log(`PNG: ${previewImg.naturalWidth}×${previewImg.naturalHeight}, Layout: ${canvasWidth}×${canvasHeight}, Ratio: ${(previewImg.naturalWidth / canvasWidth).toFixed(4)}×${(previewImg.naturalHeight / canvasHeight).toFixed(4)}`); + if (boundsMode) showAllBounds(layoutData); + }, { once: true }); + } catch (layoutErr) { console.warn('Layout computation failed:', layoutErr); layoutPane.innerHTML = '
Layout unavailable
'; + lastLayoutData = null; } switchToTab('preview'); diff --git a/src/FlexRender.Playground/wwwroot/style.css b/src/FlexRender.Playground/wwwroot/style.css index 26fd4f0..41f4e77 100644 --- a/src/FlexRender.Playground/wwwroot/style.css +++ b/src/FlexRender.Playground/wwwroot/style.css @@ -384,7 +384,8 @@ body { } /* Debug overlay toggle */ -#overlay-toggle { +#overlay-toggle, +#bounds-toggle { font-size: 11px; color: #aaa; display: flex; @@ -395,11 +396,13 @@ body { user-select: none; } -#overlay-toggle:hover { +#overlay-toggle:hover, +#bounds-toggle:hover { color: #ddd; } -#overlay-toggle input { +#overlay-toggle input, +#bounds-toggle input { cursor: pointer; } diff --git a/src/FlexRender.Skia.Render/Rendering/SkiaTextShaper.cs b/src/FlexRender.Skia.Render/Rendering/SkiaTextShaper.cs index 0682d3a..e74a7bb 100644 --- a/src/FlexRender.Skia.Render/Rendering/SkiaTextShaper.cs +++ b/src/FlexRender.Skia.Render/Rendering/SkiaTextShaper.cs @@ -93,6 +93,7 @@ private SKFont CreateFont(TextElement element, float fontSize) var font = new SKFont(typeface, fontSize) { Subpixel = _defaultRenderOptions.SubpixelText, + LinearMetrics = true, Hinting = MapFontHinting(_defaultRenderOptions.FontHinting), Edging = MapTextRendering(_defaultRenderOptions.TextRendering) }; diff --git a/src/FlexRender.Skia.Render/Rendering/TextRenderer.cs b/src/FlexRender.Skia.Render/Rendering/TextRenderer.cs index 7a1a536..eeb8990 100644 --- a/src/FlexRender.Skia.Render/Rendering/TextRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/TextRenderer.cs @@ -143,7 +143,8 @@ public void DrawText( foreach (var line in lines) { - var lineWidth = font.MeasureText(line); + var lineWidthAdv = font.MeasureText(line, out var drawBounds); + var lineWidth = Math.Max(lineWidthAdv, drawBounds.Right); var x = CalculateX(element.Align.Value, bounds, lineWidth, direction); canvas.DrawText(line, x, y, SKTextAlign.Left, font, paint); @@ -178,6 +179,7 @@ private SKFont CreateFont(TextElement element, float baseFontSize, RenderOptions var font = new SKFont(typeface, fontSize) { Subpixel = renderOptions.SubpixelText, + LinearMetrics = true, Hinting = MapFontHinting(renderOptions.FontHinting), Edging = MapTextRendering(renderOptions.TextRendering) }; From ca989f6f7da82c820c762c3a0fbb98afd781478f Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 10 Mar 2026 13:39:11 +0300 Subject: [PATCH 17/25] feat(playground): add layout diagnostics and update text_styled snapshot Add contentW, intrinsicW, shapedW, resolvedTypeface diagnostic fields to LayoutNode and playground layout JSON for text rendering debugging. Update text_styled snapshot for LinearMetrics change. --- src/FlexRender.Core/Layout/LayoutEngine.cs | 8 +++++++ src/FlexRender.Core/Layout/LayoutNode.cs | 22 +++++++++++++++++++ src/FlexRender.Playground/PlaygroundApi.cs | 18 +++++++++++++++ src/FlexRender.Playground/wwwroot/main.js | 4 ++++ .../Snapshots/golden/text_styled.png | 2 +- 5 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/FlexRender.Core/Layout/LayoutEngine.cs b/src/FlexRender.Core/Layout/LayoutEngine.cs index 97172f3..2a4fe53 100644 --- a/src/FlexRender.Core/Layout/LayoutEngine.cs +++ b/src/FlexRender.Core/Layout/LayoutEngine.cs @@ -481,6 +481,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; @@ -490,6 +492,7 @@ private LayoutNode LayoutTextElement(TextElement text, LayoutContext context) && intrinsic.MaxWidth > 0) { contentWidth = intrinsic.MaxWidth; + diagIntrinsicW = intrinsic.MaxWidth; } else { @@ -518,6 +521,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; @@ -534,6 +538,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; @@ -582,6 +587,9 @@ private LayoutNode LayoutTextElement(TextElement text, LayoutContext context) node.ComputedLineHeight = computedLineHeight; node.Baseline = padding.Top + border.Top.Width + textBaseline; node.ComputedFontSize = resolvedFontSize; + node.DiagContentWidth = contentWidth; + node.DiagIntrinsicWidth = diagIntrinsicW; + node.DiagShapedWidth = diagShapedW; return node; } diff --git a/src/FlexRender.Core/Layout/LayoutNode.cs b/src/FlexRender.Core/Layout/LayoutNode.cs index 3b580a0..0355e32 100644 --- a/src/FlexRender.Core/Layout/LayoutNode.cs +++ b/src/FlexRender.Core/Layout/LayoutNode.cs @@ -61,6 +61,28 @@ public sealed class LayoutNode ///
public float ComputedFontSize { get; set; } + /// + /// Diagnostic: intrinsic width from IntrinsicMeasurer (before scaling). + /// Only populated for text elements during layout when diagnostics are enabled. + /// + public float DiagIntrinsicWidth { get; set; } + + /// + /// Diagnostic: shaped width from TextShaper at final font size. + /// Only populated for text elements during layout when diagnostics are enabled. + /// + public float DiagShapedWidth { get; set; } + + /// + /// Diagnostic: final content width used in layout calculation. + /// + public float DiagContentWidth { get; set; } + + /// + /// Diagnostic: resolved typeface family name from FontManager. + /// + public string? DiagResolvedTypeface { get; set; } + /// Right edge (X + Width). public float Right => X + Width; diff --git a/src/FlexRender.Playground/PlaygroundApi.cs b/src/FlexRender.Playground/PlaygroundApi.cs index a53522f..939c6a3 100644 --- a/src/FlexRender.Playground/PlaygroundApi.cs +++ b/src/FlexRender.Playground/PlaygroundApi.cs @@ -378,6 +378,16 @@ private static JsonObject SerializeLayoutNode(LayoutNode node) if (node.Direction != TextDirection.Ltr) obj["direction"] = node.Direction.ToString().ToLowerInvariant(); + // Diagnostic fields for text debugging + if (node.DiagContentWidth > 0) + obj["contentW"] = Math.Round(node.DiagContentWidth, 2); + if (node.DiagIntrinsicWidth > 0) + obj["intrinsicW"] = Math.Round(node.DiagIntrinsicWidth, 2); + if (node.DiagShapedWidth > 0) + obj["shapedW"] = Math.Round(node.DiagShapedWidth, 2); + if (!string.IsNullOrEmpty(node.DiagResolvedTypeface)) + obj["resolvedTypeface"] = node.DiagResolvedTypeface; + // Element-specific properties SerializeElementProperties(obj, node.Element); @@ -425,6 +435,14 @@ private static void SerializeElementProperties(JsonObject obj, TemplateElement e if (!string.IsNullOrEmpty(t.Color.Value)) obj["color"] = t.Color.Value; + // Resolve typeface name for diagnostics + if (_render is SkiaRender skiaRender) + { + var typeface = skiaRender.FontManager.GetTypeface( + t.Font.Value, t.FontFamily.Value, t.FontWeight.Value, t.FontStyle.Value); + obj["resolvedTypeface"] = typeface.FamilyName; + } + break; case ImageElement: diff --git a/src/FlexRender.Playground/wwwroot/main.js b/src/FlexRender.Playground/wwwroot/main.js index 05d69c4..2ea08ad 100644 --- a/src/FlexRender.Playground/wwwroot/main.js +++ b/src/FlexRender.Playground/wwwroot/main.js @@ -569,6 +569,10 @@ function buildLayoutTree(node, depth, parentAbsX, parentAbsY) { if (node.justify) props.push(`justify=${escHtml(node.justify)}`); if (node.fontSize) props.push(`fontSize=${escHtml(String(node.fontSize))}px`); if (node.textLines) props.push(`lines=${escHtml(String(node.textLines))}`); + if (node.contentW) props.push(`contentW=${node.contentW}`); + if (node.intrinsicW) props.push(`intrinsicW=${node.intrinsicW}`); + if (node.shapedW) props.push(`shapedW=${node.shapedW}`); + if (node.resolvedTypeface) props.push(`tf=${escHtml(node.resolvedTypeface)}`); const propsStr = props.length > 0 ? ` [${props.join(', ')}]` : ''; const safeType = escHtml(node.type); diff --git a/tests/FlexRender.Tests/Snapshots/golden/text_styled.png b/tests/FlexRender.Tests/Snapshots/golden/text_styled.png index 6a8c5fc..ac0d239 100644 --- a/tests/FlexRender.Tests/Snapshots/golden/text_styled.png +++ b/tests/FlexRender.Tests/Snapshots/golden/text_styled.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:db836f8c2d59f5a4c2ad9999e82a4058252311c9a54d92be7df4c04e1a603fd9 +oid sha256:4902d7b58dafe4d03babf76c00629cf348643dc2559d30d068375b38df57ee92 size 2900 From 97e4e17f027f94fd73974a5e87fb2e181053d2b7 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 10 Mar 2026 13:41:36 +0300 Subject: [PATCH 18/25] chore: remove design/plan docs from branch --- .../2026-03-09-wasm-playground-design.md | 227 --- docs/plans/2026-03-09-wasm-playground-plan.md | 1586 ----------------- 2 files changed, 1813 deletions(-) delete mode 100644 docs/plans/2026-03-09-wasm-playground-design.md delete mode 100644 docs/plans/2026-03-09-wasm-playground-plan.md diff --git a/docs/plans/2026-03-09-wasm-playground-design.md b/docs/plans/2026-03-09-wasm-playground-design.md deleted file mode 100644 index 85affba..0000000 --- a/docs/plans/2026-03-09-wasm-playground-design.md +++ /dev/null @@ -1,227 +0,0 @@ -# FlexRender WASM Playground — Design Document - -## Goal - -A fully client-side, IDE-like browser application for authoring and previewing FlexRender YAML templates. Runs entirely in the browser via .NET WebAssembly — no backend required. - -## Technology Stack - -| Layer | Technology | -|-------|-----------| -| .NET WASM | `wasmbrowser` template, `[JSExport]`/`[JSImport]` interop | -| Rendering | FlexRender.Core + FlexRender.Yaml + FlexRender.Skia.Render | -| Native WASM | SkiaSharp.NativeAssets.WebAssembly | -| Code editor | Monaco Editor + monaco-yaml (JSON Schema autocomplete) | -| Hosting | GitHub Pages (static) | -| CI/CD | GitHub Actions | - -## Architecture - -``` -Browser (Static HTML/JS/CSS) -┌─────────────────────────────────────────────────────────┐ -│ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │ -│ │ Monaco YAML │ │ Monaco JSON │ │ Preview Panel │ │ -│ │ (template) │ │ (data) │ │ PNG/SVG/canvas│ │ -│ └──────┬───────┘ └──────┬───────┘ └───────▲───────┘ │ -│ └────────┬────────┘ │ │ -│ debounce 300ms │ │ -│ ┌────────▼────────────────────────────┘ │ -│ │ Web Worker (.NET WASM) │ -│ │ FlexRender.Core + Yaml + Skia.Render │ -│ │ SkiaSharp.NativeAssets.WebAssembly │ -│ │ │ -│ │ [JSExport] RenderToPng(yaml, json) → byte[] │ -│ │ [JSExport] RenderToSvg(yaml, json) → string │ -│ │ [JSExport] Validate(yaml) → string │ -│ │ [JSExport] DebugLayout(yaml, json) → string │ -│ │ [JSExport] LoadFont(name, data) │ -│ │ [JSExport] LoadImage(path, data) │ -│ │ [JSExport] LoadContent(path, data) │ -│ └────────────────────────────────────────────────┘ -└─────────────────────────────────────────────────────────┘ -``` - -## JSExport API (C# → JS) - -```csharp -partial class PlaygroundApi -{ - // Rendering - [JSExport] - public static byte[] RenderToPng(string yaml, string? dataJson); - - [JSExport] - public static string RenderToSvg(string yaml, string? dataJson); - - // Validation — returns JSON array of error objects - [JSExport] - public static string Validate(string yaml); - - // Debug — returns JSON layout tree - [JSExport] - public static string DebugLayout(string yaml, string? dataJson); - - // Resource loading (drag & drop) - [JSExport] - public static void LoadFont(string name, byte[] data); // .ttf, .otf, .woff2 - - [JSExport] - public static void LoadImage(string path, byte[] data); // .png, .jpg, .svg - - [JSExport] - public static void LoadContent(string path, byte[] data); // .ndc, .txt -} -``` - -All resources are loaded into an in-memory `IResourceLoader` on the WASM side. Templates reference them by filename (e.g., `src: "logo.png"`, `src: "receipt.ndc"`). - -## UI Layout - -``` -┌─────────────────────────────────────────────────────┐ -│ Logo [Examples ▾] [Export PNG] [Export SVG] [☀/🌙] │ -├────────────────────────┬────────────────────────────┤ -│ Monaco YAML Editor │ Preview │ -│ (template.yaml) │ ┌──────────────────────┐ │ -│ │ │ │ │ -│ │ │ Rendered Image │ │ -│ │ │ (zoom/pan) │ │ -│ │ │ │ │ -│ │ └──────────────────────┘ │ -├────────────────────────┤ [Preview] [Layout] [Errors]│ -│ Monaco JSON Editor ├────────────────────────────┤ -│ (data.json) │ Layout tree / Error list │ -│ │ │ -└────────────────────────┴────────────────────────────┘ -│ Ready · 400×600 · Rendered in 45ms · 0 errors │ -└─────────────────────────────────────────────────────┘ -``` - -### Panels - -- **Top bar**: Logo, example gallery dropdown, export buttons (PNG/SVG), light/dark theme toggle -- **Left top**: Monaco YAML editor with monaco-yaml plugin (JSON Schema autocomplete for FlexRender properties) -- **Left bottom**: Monaco JSON editor for template data -- **Right top**: Rendered image preview with zoom (mouse wheel) and pan (drag) -- **Right bottom tabs**: - - Preview (default) — rendered image - - Layout — debug layout tree visualization (from `DebugLayout`) - - Errors — validation errors list (from `Validate`) -- **Status bar**: Render status, canvas dimensions, render time, error count - -### Key interactions - -- **Debounce 300ms**: After the user stops typing, call `Validate()` + `RenderToPng()` via Web Worker -- **Drag & drop**: Drop font files (.ttf/.otf/.woff2), images (.png/.jpg/.svg), or NDC content (.ndc/.txt) anywhere → calls `LoadFont`/`LoadImage`/`LoadContent` → re-renders -- **Example gallery**: Built-in examples from `examples/` directory, loads YAML + JSON into editors on click -- **Zoom/Pan**: CSS transform on ``, mouse wheel for zoom, drag to pan -- **Export**: Download rendered result as PNG or SVG file - -## Monaco YAML Integration - -- **monaco-yaml** plugin provides YAML validation, autocomplete, and hover -- **JSON Schema** generated from `KnownProperties.cs` — covers all element types, properties, enums (FlexDirection, JustifyContent, AlignItems, etc.) -- Schema file: `wwwroot/schemas/flexrender-template.json` -- Autocomplete for: element `type` values, all properties per element type, enum values, CSS-like values - -## Project Structure - -``` -src/FlexRender.Playground/ - FlexRender.Playground.csproj # wasmbrowser SDK project - Program.cs # Entry point + [JSExport] API - PlaygroundApi.cs # Render/Validate/Debug/Load methods - MemoryResourceLoader.cs # In-memory IResourceLoader for drag&drop - wwwroot/ - index.html # Main page - main.js # .NET WASM init + Monaco + UI wiring - style.css # Layout and theming - schemas/ - flexrender-template.json # JSON Schema for autocomplete - examples/ # Built-in example templates - receipt.yaml / receipt.json - card.yaml / card.json - ... -``` - -## .csproj Configuration - -```xml - - - net10.0 - true - true - - - - - - - - - -``` - -## Build & Deploy - -### Local development -```bash -dotnet run --project src/FlexRender.Playground -``` -Launches dev server with hot-reload at `https://localhost:5xxx`. - -### Production build -```bash -dotnet publish src/FlexRender.Playground -c Release -``` -Output: `bin/Release/net10.0/publish/wwwroot/` — static files ready for deployment. - -### Bundle size (estimated) -| Component | Gzip size | -|-----------|-----------| -| .NET WASM runtime | ~3 MB | -| SkiaSharp native WASM | ~2 MB | -| FlexRender assemblies | ~500 KB | -| Monaco Editor (CDN) | 0 (loaded externally) | -| **Total first load** | **~5.5 MB** | - -All assets are cached by the browser after first load. - -### Optimization -- `InvariantGlobalization=true` — removes ICU data (~30% savings) -- `PublishTrimmed=true` — tree-shakes unused code -- Brotli pre-compression in CI for all static files -- Monaco Editor loaded from CDN (not bundled) - -### GitHub Actions CI/CD -- **Trigger**: push to `main` or tag `v*` -- **Steps**: `dotnet publish` → copy `wwwroot/` → deploy to GitHub Pages -- **URL**: `https://robonet.github.io/FlexRender/` (or custom domain) - -## Embedded Fonts - -Two fonts bundled as embedded resources: -- **Inter** (sans-serif) — default for text elements -- **Roboto Mono** (monospace) — for code/receipt templates - -Additional fonts loaded via drag & drop at runtime. - -## Risks & Mitigations - -| Risk | Impact | Mitigation | -|------|--------|------------| -| SkiaSharp 3.119.2 vs NativeAssets.WebAssembly 3.119.1 version mismatch | Build/runtime failure | Pin both to 3.119.1 or wait for 3.119.2 WASM release | -| Large bundle size (~5.5 MB) | Slow first load | Brotli compression, loading spinner, cache headers | -| HarfBuzz WASM not available | Complex text shaping broken | Start without HarfBuzz, add later if HarfBuzzSharp.NativeAssets.WebAssembly exists | -| Web Worker + .NET WASM interop complexity | Technical risk | Prototype Web Worker integration first as spike | -| YamlDotNet AOT compatibility in WASM | Possible trimming issues | Test early, configure trimmer roots if needed | - -## Out of Scope (Future) - -- URL sharing (gzip+base64 or server-side storage) -- Collaborative editing -- Template marketplace / community gallery -- PWA / offline support -- Mobile-optimized layout diff --git a/docs/plans/2026-03-09-wasm-playground-plan.md b/docs/plans/2026-03-09-wasm-playground-plan.md deleted file mode 100644 index df9bac8..0000000 --- a/docs/plans/2026-03-09-wasm-playground-plan.md +++ /dev/null @@ -1,1586 +0,0 @@ -# FlexRender WASM Playground Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Build a fully client-side browser IDE for authoring and previewing FlexRender YAML templates using .NET WebAssembly. - -**Architecture:** Static site (GitHub Pages) with .NET WASM runtime running FlexRender.Core + Yaml + Skia.Render in a Web Worker. Monaco Editor with monaco-yaml provides YAML editing with autocomplete. No backend. - -**Tech Stack:** .NET 10 wasmbrowser, SkiaSharp.NativeAssets.WebAssembly, Monaco Editor, monaco-yaml, vanilla HTML/JS/CSS - -**Design doc:** `docs/plans/2026-03-09-wasm-playground-design.md` - ---- - -## Task 1: Scaffold wasmbrowser project - -**Files:** -- Create: `src/FlexRender.Playground/FlexRender.Playground.csproj` -- Create: `src/FlexRender.Playground/Program.cs` -- Modify: `FlexRender.slnx` (add project reference) - -**Step 1: Install wasmbrowser template** - -```bash -dotnet new install Microsoft.NET.Runtime.WebAssembly.Templates.net10 -``` - -Expected: Template `wasmbrowser` available. - -**Step 2: Create project** - -```bash -cd src -mkdir FlexRender.Playground -cd FlexRender.Playground -dotnet new wasmbrowser -``` - -**Step 3: Replace generated csproj with FlexRender-specific configuration** - -Replace `FlexRender.Playground.csproj`: - -```xml - - - net10.0 - true - true - - true - - - - - - - - - - - - - - -``` - -**Step 4: Write minimal Program.cs** - -```csharp -using System.Runtime.InteropServices.JavaScript; - -Console.WriteLine("FlexRender Playground loaded"); -``` - -**Step 5: Add project to solution** - -```bash -cd /path/to/SkiaLayout -dotnet sln FlexRender.slnx add src/FlexRender.Playground/FlexRender.Playground.csproj --solution-folder playground -``` - -**Step 6: Verify it builds** - -```bash -dotnet build src/FlexRender.Playground -``` - -Expected: Build succeeds. If SkiaSharp version mismatch, pin SkiaSharp to 3.119.1 in `Directory.Packages.props`. - -**Step 7: Verify it runs** - -```bash -dotnet run --project src/FlexRender.Playground -``` - -Expected: Dev server starts, browser opens, console shows "FlexRender Playground loaded". - -**Step 8: Commit** - -```bash -git add src/FlexRender.Playground/ FlexRender.slnx -git commit -m "feat(playground): scaffold wasmbrowser project with FlexRender dependencies" -``` - ---- - -## Task 2: MemoryResourceLoader - -In-memory resource loader for drag & drop files (fonts, images, NDC content). Follows the `Base64ResourceLoader` pattern. - -**Files:** -- Create: `src/FlexRender.Playground/MemoryResourceLoader.cs` - -**Step 1: Write MemoryResourceLoader** - -```csharp -using FlexRender.Abstractions; - -namespace FlexRender.Playground; - -/// -/// In-memory resource loader for browser-uploaded files (fonts, images, content). -/// Resources are stored by name and served from memory. -/// -internal sealed class MemoryResourceLoader : IResourceLoader -{ - private readonly Dictionary _resources = new(StringComparer.OrdinalIgnoreCase); - - /// - public int Priority => 10; // Highest priority — uploaded files override everything - - /// - public bool CanHandle(string uri) - { - if (string.IsNullOrWhiteSpace(uri)) - return false; - - // Handle any URI that matches a stored resource name - // Strip leading "./" or "/" for matching - var normalized = NormalizePath(uri); - return _resources.ContainsKey(normalized); - } - - /// - public Task Load(string uri, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(uri); - - var normalized = NormalizePath(uri); - - if (_resources.TryGetValue(normalized, out var data)) - { - Stream stream = new MemoryStream(data, writable: false); - return Task.FromResult(stream); - } - - return Task.FromResult(null); - } - - /// - /// Stores a resource in memory, available by name. - /// - public void AddResource(string name, byte[] data) - { - ArgumentNullException.ThrowIfNull(name); - ArgumentNullException.ThrowIfNull(data); - - var normalized = NormalizePath(name); - _resources[normalized] = data; - } - - /// - /// Removes a resource from memory. - /// - public bool RemoveResource(string name) - { - var normalized = NormalizePath(name); - return _resources.Remove(normalized); - } - - /// - /// Removes all stored resources. - /// - public void Clear() => _resources.Clear(); - - private static string NormalizePath(string path) - { - if (path.StartsWith("./", StringComparison.Ordinal)) - path = path[2..]; - else if (path.StartsWith("/", StringComparison.Ordinal)) - path = path[1..]; - - return path; - } -} -``` - -**Step 2: Verify build** - -```bash -dotnet build src/FlexRender.Playground -``` - -Expected: Build succeeds. - -**Step 3: Commit** - -```bash -git add src/FlexRender.Playground/MemoryResourceLoader.cs -git commit -m "feat(playground): add MemoryResourceLoader for browser file uploads" -``` - ---- - -## Task 3: PlaygroundApi — JSExport interop - -The C# API surface exposed to JavaScript via `[JSExport]`. - -**Files:** -- Create: `src/FlexRender.Playground/PlaygroundApi.cs` -- Modify: `src/FlexRender.Playground/Program.cs` - -**Step 1: Write PlaygroundApi.cs** - -```csharp -using System.Runtime.InteropServices.JavaScript; -using System.Text.Json; -using FlexRender.Abstractions; -using FlexRender.Configuration; -using FlexRender.Content.Ndc; -using FlexRender.Skia; -using FlexRender.Values; -using FlexRender.Yaml; - -namespace FlexRender.Playground; - -/// -/// Browser-facing API exposed via [JSExport] for the WASM playground. -/// -internal static partial class PlaygroundApi -{ - private static MemoryResourceLoader s_memoryLoader = new(); - private static IFlexRender? s_render; - private static TemplateParser s_parser = new(); - - /// - /// Initializes the FlexRender engine. Must be called once before rendering. - /// - [JSExport] - public static void Initialize() - { - s_memoryLoader = new MemoryResourceLoader(); - - s_render?.Dispose(); - s_render = new FlexRenderBuilder() - .WithResourceLoader(s_memoryLoader) - .WithoutDefaultLoaders() - .WithSkia() - .WithContentParser(new NdcContentParser()) - .Build(); - } - - /// - /// Renders a YAML template to PNG bytes. - /// - /// PNG image as byte array, or empty array on error. - [JSExport] - public static byte[] RenderToPng(string yaml, string? dataJson) - { - try - { - if (s_render is null) - return []; - - var template = s_parser.Parse(yaml); - var data = ParseData(dataJson); - - // JSExport doesn't support async — use sync-over-async - return s_render.RenderToPng(template, data).GetAwaiter().GetResult(); - } - catch (Exception ex) - { - Console.Error.WriteLine($"Render error: {ex.Message}"); - return []; - } - } - - /// - /// Validates a YAML template and returns errors as JSON array. - /// - /// JSON string: [] on success, [{"message":"...","line":N},...] on errors. - [JSExport] - public static string Validate(string yaml) - { - var errors = new List(); - - try - { - s_parser.Parse(yaml); - } - catch (TemplateParseException ex) - { - errors.Add(new { message = ex.Message, line = ex.Line }); - } - catch (Exception ex) - { - errors.Add(new { message = ex.Message, line = 0 }); - } - - return JsonSerializer.Serialize(errors); - } - - /// - /// Loads a font file into memory for template rendering. - /// - [JSExport] - public static void LoadFont(string name, byte[] data) - { - s_memoryLoader.AddResource(name, data); - } - - /// - /// Loads an image file into memory for template rendering. - /// - [JSExport] - public static void LoadImage(string path, byte[] data) - { - s_memoryLoader.AddResource(path, data); - } - - /// - /// Loads a content file (NDC, etc.) into memory for template rendering. - /// - [JSExport] - public static void LoadContent(string path, byte[] data) - { - s_memoryLoader.AddResource(path, data); - } - - private static ObjectValue? ParseData(string? json) - { - if (string.IsNullOrWhiteSpace(json)) - return null; - - return TemplateData.FromJson(json); - } -} -``` - -> **Note:** `TemplateData.FromJson` — check if this utility exists. If not, use `JsonSerializer.Deserialize` + conversion to `ObjectValue`. The exact method name may need adjustment based on the actual codebase API. - -**Step 2: Update Program.cs** - -```csharp -using System.Runtime.InteropServices.JavaScript; -using FlexRender.Playground; - -// Initialize the FlexRender engine -PlaygroundApi.Initialize(); - -Console.WriteLine("FlexRender Playground ready"); -``` - -**Step 3: Verify build** - -```bash -dotnet build src/FlexRender.Playground -``` - -Expected: Build succeeds. Fix any missing `using` directives or API mismatches. - -> **Important:** If `FlexRenderBuilder.WithResourceLoader()` doesn't exist as a direct method, check for `AddResourceLoader()` or add the `MemoryResourceLoader` to the builder's loader list. The builder API may need a small extension. Also check if `WithoutDefaultLoaders()` exists — if not, the default `FileResourceLoader` is harmless in WASM (it just won't find local files). - -> **Important:** If `WithContentParser()` doesn't exist on `FlexRenderBuilder`, check how NDC parser is registered (it may need `FlexRenderBuilder` extension from the Content.Ndc package). - -**Step 4: Commit** - -```bash -git add src/FlexRender.Playground/PlaygroundApi.cs src/FlexRender.Playground/Program.cs -git commit -m "feat(playground): add PlaygroundApi with JSExport render/validate/load methods" -``` - ---- - -## Task 4: Minimal HTML + JS shell - -Basic page that loads .NET WASM, renders a hardcoded template, and displays the result. This validates the full WASM pipeline before adding Monaco. - -**Files:** -- Create: `src/FlexRender.Playground/wwwroot/index.html` -- Create: `src/FlexRender.Playground/wwwroot/main.js` - -**Step 1: Write index.html** - -```html - - - - - - FlexRender Playground - - - - - - -
Loading FlexRender WASM runtime...
-
-
-

FlexRender Playground

-

WASM runtime loaded. Minimal test:

- -

-        
-
Ready
-
- - -``` - -**Step 2: Write main.js** - -```javascript -import { dotnet } from './_framework/dotnet.js'; - -const { getAssemblyExports, getConfig, runMain } = await dotnet - .withApplicationArguments("start") - .create(); - -const config = getConfig(); -const exports = await getAssemblyExports(config.mainAssemblyName); -const api = exports.FlexRender.Playground.PlaygroundApi; - -await runMain(); - -// Hide loading, show app -document.getElementById('loading').style.display = 'none'; -document.getElementById('app').style.display = 'flex'; - -// Test render with a minimal template -const testYaml = ` -canvas: - width: 300 - height: 100 - background: "#ffffff" -elements: - - type: text - content: "Hello from WASM!" - size: 24 - color: "#333333" - padding: "20" -`; - -const statusEl = document.getElementById('status'); -const errorEl = document.getElementById('error'); -const imgEl = document.getElementById('preview-img'); - -try { - statusEl.textContent = 'Rendering...'; - const start = performance.now(); - - const pngBytes = api.RenderToPng(testYaml, null); - const elapsed = (performance.now() - start).toFixed(0); - - if (pngBytes && pngBytes.length > 0) { - const blob = new Blob([pngBytes], { type: 'image/png' }); - imgEl.src = URL.createObjectURL(blob); - statusEl.textContent = `Rendered in ${elapsed}ms · ${pngBytes.length} bytes`; - } else { - errorEl.textContent = 'Render returned empty result'; - statusEl.textContent = 'Error'; - } -} catch (e) { - errorEl.textContent = e.message || String(e); - statusEl.textContent = 'Error'; -} -``` - -**Step 3: Run and test in browser** - -```bash -dotnet run --project src/FlexRender.Playground -``` - -Expected: Browser opens, shows "Hello from WASM!" rendered as a PNG image. If SkiaSharp WASM fails, this is where we'll discover it and fix. - -> **Troubleshooting:** If SkiaSharp native binding fails, check: -> 1. `SkiaSharp.NativeAssets.WebAssembly` version compatibility -> 2. May need `false` in csproj -> 3. May need `-s ALLOW_MEMORY_GROWTH=1` - -**Step 4: Commit** - -```bash -git add src/FlexRender.Playground/wwwroot/ -git commit -m "feat(playground): add minimal HTML/JS shell with WASM render test" -``` - ---- - -## Task 5: Monaco Editor integration - -Add Monaco Editor with two panes: YAML template editor and JSON data editor. - -**Files:** -- Modify: `src/FlexRender.Playground/wwwroot/index.html` -- Create: `src/FlexRender.Playground/wwwroot/style.css` -- Modify: `src/FlexRender.Playground/wwwroot/main.js` - -**Step 1: Write style.css** - -```css -* { margin: 0; padding: 0; box-sizing: border-box; } - -body { - font-family: system-ui, -apple-system, sans-serif; - background: #1e1e1e; - color: #d4d4d4; - height: 100vh; - overflow: hidden; -} - -/* Loading screen */ -#loading { - display: flex; - align-items: center; - justify-content: center; - height: 100vh; - font-size: 1.2em; - flex-direction: column; - gap: 12px; -} - -#loading .spinner { - width: 32px; - height: 32px; - border: 3px solid #333; - border-top-color: #007acc; - border-radius: 50%; - animation: spin 1s linear infinite; -} - -@keyframes spin { to { transform: rotate(360deg); } } - -/* Main app layout */ -#app { - display: none; - flex-direction: column; - height: 100vh; -} - -/* Top bar */ -.toolbar { - display: flex; - align-items: center; - padding: 6px 12px; - background: #2d2d2d; - border-bottom: 1px solid #404040; - gap: 12px; - flex-shrink: 0; -} - -.toolbar h1 { - font-size: 14px; - font-weight: 600; - white-space: nowrap; -} - -.toolbar select, .toolbar button { - background: #3c3c3c; - color: #d4d4d4; - border: 1px solid #555; - padding: 4px 10px; - border-radius: 4px; - font-size: 12px; - cursor: pointer; -} - -.toolbar select:hover, .toolbar button:hover { - background: #4c4c4c; -} - -.toolbar .spacer { flex: 1; } - -/* Main content area */ -.main-content { - display: flex; - flex: 1; - overflow: hidden; -} - -/* Left panel — editors */ -.editor-panel { - display: flex; - flex-direction: column; - width: 50%; - min-width: 300px; - border-right: 1px solid #404040; -} - -.editor-section { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.editor-section + .editor-section { - border-top: 1px solid #404040; -} - -.editor-label { - padding: 4px 12px; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.5px; - color: #888; - background: #252526; - flex-shrink: 0; -} - -.editor-container { - flex: 1; - overflow: hidden; -} - -/* Right panel — preview */ -.preview-panel { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.preview-tabs { - display: flex; - background: #252526; - border-bottom: 1px solid #404040; - flex-shrink: 0; -} - -.preview-tabs button { - background: none; - color: #888; - border: none; - padding: 6px 16px; - font-size: 12px; - cursor: pointer; - border-bottom: 2px solid transparent; -} - -.preview-tabs button.active { - color: #d4d4d4; - border-bottom-color: #007acc; -} - -.preview-content { - flex: 1; - overflow: auto; - display: flex; - align-items: center; - justify-content: center; - background: #1a1a1a; -} - -.preview-content img { - max-width: 100%; - max-height: 100%; - object-fit: contain; - image-rendering: auto; -} - -.tab-pane { display: none; width: 100%; height: 100%; } -.tab-pane.active { - display: flex; - align-items: center; - justify-content: center; -} - -#errors-pane { - align-items: flex-start; - justify-content: flex-start; - padding: 12px; - font-family: monospace; - font-size: 13px; - color: #f48771; - white-space: pre-wrap; -} - -#layout-pane { - align-items: flex-start; - justify-content: flex-start; - padding: 12px; - font-family: monospace; - font-size: 12px; - overflow: auto; -} - -/* Status bar */ -.status-bar { - display: flex; - align-items: center; - padding: 2px 12px; - background: #007acc; - color: #fff; - font-size: 12px; - flex-shrink: 0; - gap: 16px; -} - -.status-bar.error { background: #c72e2e; } - -/* Drop zone overlay */ -.drop-overlay { - display: none; - position: fixed; - inset: 0; - background: rgba(0, 122, 204, 0.2); - border: 3px dashed #007acc; - z-index: 1000; - align-items: center; - justify-content: center; - font-size: 1.5em; - pointer-events: none; -} - -.drop-overlay.visible { display: flex; } -``` - -**Step 2: Rewrite index.html** - -```html - - - - - - FlexRender Playground - - - - - - -
-
-
Loading FlexRender WASM runtime...
-
- -
- -
-

FlexRender Playground

- -
- - -
- - -
- -
-
-
Template (YAML)
-
-
-
-
Data (JSON)
-
-
-
- - -
-
- - - -
-
-
- -
-
-
-
-
-
- - -
- Ready -
-
- - -
- Drop fonts, images, or content files here -
- - -``` - -**Step 3: Rewrite main.js with Monaco + debounce rendering** - -```javascript -import { dotnet } from './_framework/dotnet.js'; - -// --- Monaco Editor setup (CDN) --- -const MONACO_CDN = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min'; - -function loadScript(src) { - return new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = src; - script.onload = resolve; - script.onerror = reject; - document.head.appendChild(script); - }); -} - -// --- .NET WASM initialization --- -const { getAssemblyExports, getConfig, runMain } = await dotnet - .withApplicationArguments('start') - .create(); - -const config = getConfig(); -const exports = await getAssemblyExports(config.mainAssemblyName); -const api = exports.FlexRender.Playground.PlaygroundApi; - -await runMain(); - -// --- Load Monaco from CDN --- -window.require = { paths: { vs: `${MONACO_CDN}/vs` } }; -await loadScript(`${MONACO_CDN}/vs/loader.js`); - -await new Promise((resolve) => { - window.require(['vs/editor/editor.main'], resolve); -}); - -const monaco = window.monaco; - -// --- Create editors --- -const defaultYaml = `canvas: - width: 400 - height: 200 - background: "#ffffff" -elements: - - type: text - content: "Hello, FlexRender!" - size: 28 - color: "#333333" - padding: "30" -`; - -const defaultJson = `{}`; - -const yamlEditor = monaco.editor.create(document.getElementById('yaml-editor'), { - value: defaultYaml, - language: 'yaml', - theme: 'vs-dark', - minimap: { enabled: false }, - fontSize: 13, - tabSize: 2, - automaticLayout: true, - scrollBeyondLastLine: false, -}); - -const jsonEditor = monaco.editor.create(document.getElementById('json-editor'), { - value: defaultJson, - language: 'json', - theme: 'vs-dark', - minimap: { enabled: false }, - fontSize: 13, - tabSize: 2, - automaticLayout: true, - scrollBeyondLastLine: false, -}); - -// --- UI elements --- -const statusBar = document.getElementById('status-bar'); -const statusText = document.getElementById('status-text'); -const previewImg = document.getElementById('preview-img'); -const errorsPane = document.getElementById('errors-pane'); -const layoutPane = document.getElementById('layout-pane'); - -// --- Tabs --- -document.querySelectorAll('.preview-tabs button').forEach(btn => { - btn.addEventListener('click', () => { - document.querySelectorAll('.preview-tabs button').forEach(b => b.classList.remove('active')); - document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); - btn.classList.add('active'); - document.getElementById(`${btn.dataset.tab}-pane`).classList.add('active'); - }); -}); - -// --- Render function --- -let renderTimeout = null; -let lastObjectUrl = null; - -function render() { - const yaml = yamlEditor.getValue(); - const json = jsonEditor.getValue(); - - statusBar.classList.remove('error'); - statusText.textContent = 'Rendering...'; - - try { - // Validate first - const errorsJson = api.Validate(yaml); - const errors = JSON.parse(errorsJson); - - if (errors.length > 0) { - errorsPane.textContent = errors.map(e => `Line ${e.line}: ${e.message}`).join('\n'); - statusBar.classList.add('error'); - statusText.textContent = `${errors.length} error(s)`; - return; - } - - errorsPane.textContent = ''; - - // Render - const start = performance.now(); - const dataArg = json.trim() === '{}' || json.trim() === '' ? null : json; - const pngBytes = api.RenderToPng(yaml, dataArg); - const elapsed = (performance.now() - start).toFixed(0); - - if (pngBytes && pngBytes.length > 0) { - if (lastObjectUrl) URL.revokeObjectURL(lastObjectUrl); - const blob = new Blob([pngBytes], { type: 'image/png' }); - lastObjectUrl = URL.createObjectURL(blob); - previewImg.src = lastObjectUrl; - statusText.textContent = `Rendered in ${elapsed}ms · ${(pngBytes.length / 1024).toFixed(1)} KB`; - } else { - statusText.textContent = 'Render returned empty result'; - } - } catch (e) { - errorsPane.textContent = e.message || String(e); - statusBar.classList.add('error'); - statusText.textContent = 'Error'; - } -} - -// --- Debounced render on editor changes --- -function scheduleRender() { - clearTimeout(renderTimeout); - renderTimeout = setTimeout(render, 300); -} - -yamlEditor.onDidChangeModelContent(scheduleRender); -jsonEditor.onDidChangeModelContent(scheduleRender); - -// --- Export buttons --- -document.getElementById('btn-export-png').addEventListener('click', () => { - const yaml = yamlEditor.getValue(); - const json = jsonEditor.getValue(); - const dataArg = json.trim() === '{}' || json.trim() === '' ? null : json; - - try { - const pngBytes = api.RenderToPng(yaml, dataArg); - if (pngBytes && pngBytes.length > 0) { - const blob = new Blob([pngBytes], { type: 'image/png' }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = 'flexrender-output.png'; - a.click(); - } - } catch (e) { - alert('Export failed: ' + (e.message || e)); - } -}); - -// --- Drag & drop --- -const dropOverlay = document.getElementById('drop-overlay'); -let dragCounter = 0; - -const FONT_EXTENSIONS = ['.ttf', '.otf', '.woff2']; -const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.svg', '.gif', '.webp']; -const CONTENT_EXTENSIONS = ['.ndc', '.txt']; - -document.addEventListener('dragenter', (e) => { - e.preventDefault(); - dragCounter++; - dropOverlay.classList.add('visible'); -}); - -document.addEventListener('dragleave', (e) => { - e.preventDefault(); - dragCounter--; - if (dragCounter === 0) dropOverlay.classList.remove('visible'); -}); - -document.addEventListener('dragover', (e) => e.preventDefault()); - -document.addEventListener('drop', async (e) => { - e.preventDefault(); - dragCounter = 0; - dropOverlay.classList.remove('visible'); - - for (const file of e.dataTransfer.files) { - const name = file.name.toLowerCase(); - const ext = '.' + name.split('.').pop(); - const buffer = new Uint8Array(await file.arrayBuffer()); - - if (FONT_EXTENSIONS.includes(ext)) { - api.LoadFont(file.name, buffer); - statusText.textContent = `Loaded font: ${file.name}`; - } else if (IMAGE_EXTENSIONS.includes(ext)) { - api.LoadImage(file.name, buffer); - statusText.textContent = `Loaded image: ${file.name}`; - } else if (CONTENT_EXTENSIONS.includes(ext)) { - api.LoadContent(file.name, buffer); - statusText.textContent = `Loaded content: ${file.name}`; - } else { - statusText.textContent = `Unsupported file type: ${ext}`; - continue; - } - - // Re-render with new resources - scheduleRender(); - } -}); - -// --- Show app, trigger initial render --- -document.getElementById('loading').style.display = 'none'; -document.getElementById('app').style.display = 'flex'; -render(); -``` - -**Step 4: Verify in browser** - -```bash -dotnet run --project src/FlexRender.Playground -``` - -Expected: Full IDE layout with YAML editor (left top), JSON editor (left bottom), preview (right). Editing YAML triggers re-render after 300ms debounce. - -**Step 5: Commit** - -```bash -git add src/FlexRender.Playground/wwwroot/ -git commit -m "feat(playground): add Monaco Editor UI with debounced live preview" -``` - ---- - -## Task 6: Example gallery - -Built-in example templates selectable from the dropdown. - -**Files:** -- Modify: `src/FlexRender.Playground/wwwroot/main.js` (add example loading logic) - -**Step 1: Add examples object to main.js** - -Add examples as inline JS objects (avoids file loading complexity). Copy 3-4 representative examples from `examples/` directory: - -```javascript -const EXAMPLES = { - 'Simple Text': { - yaml: `canvas: - width: 400 - height: 150 - background: "#ffffff" -elements: - - type: text - content: "Hello, FlexRender!" - size: 28 - color: "#333333" - padding: "30"`, - json: '{}' - }, - 'Flex Layout': { - yaml: `canvas: - width: 400 - height: 200 - background: "#f5f5f5" -elements: - - type: flex - direction: row - gap: "10" - padding: "20" - children: - - type: flex - background: "#4CAF50" - padding: "20" - grow: 1 - children: - - type: text - content: "Left" - color: "#ffffff" - size: 16 - - type: flex - background: "#2196F3" - padding: "20" - grow: 2 - children: - - type: text - content: "Right (grow: 2)" - color: "#ffffff" - size: 16`, - json: '{}' - }, - 'Data Binding': { - yaml: `canvas: - width: 400 - height: 250 - background: "#ffffff" -elements: - - type: flex - padding: "20" - gap: "8" - children: - - type: text - content: "{{title}}" - size: 24 - color: "#333" - - type: text - content: "By {{author}}" - size: 14 - color: "#888" - - type: each - array: items - children: - - type: text - content: "- {{item}}" - size: 14 - color: "#555"`, - json: `{ - "title": "Shopping List", - "author": "FlexRender", - "items": ["Apples", "Bread", "Milk", "Cheese"] -}` - } -}; -``` - -**Step 2: Populate dropdown and wire up selection** - -Add after editor creation in main.js: - -```javascript -// Populate examples dropdown -const examplesSelect = document.getElementById('examples'); -for (const name of Object.keys(EXAMPLES)) { - const option = document.createElement('option'); - option.value = name; - option.textContent = name; - examplesSelect.appendChild(option); -} - -examplesSelect.addEventListener('change', () => { - const example = EXAMPLES[examplesSelect.value]; - if (example) { - yamlEditor.setValue(example.yaml); - jsonEditor.setValue(example.json); - } -}); -``` - -**Step 3: Test examples in browser** - -```bash -dotnet run --project src/FlexRender.Playground -``` - -Expected: Dropdown shows examples, selecting one loads YAML + JSON and triggers render. - -**Step 4: Commit** - -```bash -git add src/FlexRender.Playground/wwwroot/main.js -git commit -m "feat(playground): add example gallery with built-in templates" -``` - ---- - -## Task 7: monaco-yaml integration with JSON Schema - -Add YAML autocomplete and validation using monaco-yaml + a FlexRender JSON Schema. - -**Files:** -- Create: `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json` -- Modify: `src/FlexRender.Playground/wwwroot/main.js` - -**Step 1: Create JSON Schema for FlexRender templates** - -Based on `KnownProperties.cs`, create a JSON Schema covering element types and their properties. - -Create `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`: - -```json -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "FlexRender Template", - "type": "object", - "properties": { - "canvas": { - "type": "object", - "properties": { - "width": { "type": "integer", "description": "Canvas width in pixels" }, - "height": { "type": "integer", "description": "Canvas height in pixels" }, - "background": { "type": "string", "description": "Background color (hex, e.g. #ffffff)" }, - "fixed": { "type": "boolean", "description": "Whether canvas size is fixed" } - }, - "required": ["width", "height"] - }, - "fonts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "family": { "type": "string" }, - "src": { "type": "string" }, - "weight": { "type": ["string", "integer"] }, - "style": { "type": "string", "enum": ["normal", "italic"] } - }, - "required": ["family", "src"] - } - }, - "elements": { - "type": "array", - "items": { "$ref": "#/definitions/element" } - } - }, - "required": ["canvas", "elements"], - "definitions": { - "flexItemProperties": { - "type": "object", - "properties": { - "grow": { "type": "number", "description": "Flex grow factor" }, - "shrink": { "type": "number", "description": "Flex shrink factor" }, - "basis": { "type": "string", "description": "Flex basis (px, %, auto)" }, - "order": { "type": "integer", "description": "Display order" }, - "display": { "type": "string", "enum": ["flex", "none"] }, - "alignSelf": { "type": "string", "enum": ["auto", "start", "center", "end", "stretch", "baseline"] }, - "width": { "type": ["string", "integer"], "description": "Width (px, %, em, auto)" }, - "height": { "type": ["string", "integer"], "description": "Height (px, %, em, auto)" }, - "minWidth": { "type": ["string", "integer"] }, - "maxWidth": { "type": ["string", "integer"] }, - "minHeight": { "type": ["string", "integer"] }, - "maxHeight": { "type": ["string", "integer"] }, - "padding": { "type": ["string", "integer"], "description": "CSS-like padding shorthand" }, - "margin": { "type": ["string", "integer"], "description": "CSS-like margin shorthand (supports auto)" }, - "background": { "type": "string", "description": "Background color or CSS gradient" }, - "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, - "rotate": { "type": "string" }, - "boxShadow": { "type": "string", "description": "offsetX offsetY blurRadius color" }, - "borderRadius": { "type": ["string", "integer"] }, - "position": { "type": "string", "enum": ["static", "relative", "absolute"] }, - "top": { "type": ["string", "integer"] }, - "right": { "type": ["string", "integer"] }, - "bottom": { "type": ["string", "integer"] }, - "left": { "type": ["string", "integer"] }, - "aspectRatio": { "type": "number" } - } - }, - "element": { - "allOf": [ - { "$ref": "#/definitions/flexItemProperties" }, - { - "type": "object", - "required": ["type"], - "properties": { - "type": { - "type": "string", - "enum": ["text", "flex", "image", "qr", "barcode", "separator", "svg", "table", "each", "if", "content"] - } - }, - "allOf": [ - { - "if": { "properties": { "type": { "const": "text" } } }, - "then": { - "properties": { - "content": { "type": "string", "description": "Text content (supports {{expressions}})" }, - "font": { "type": "string" }, - "fontFamily": { "type": "string" }, - "size": { "type": "number", "description": "Font size in pixels" }, - "color": { "type": "string" }, - "align": { "type": "string", "enum": ["left", "center", "right"] }, - "wrap": { "type": "boolean" }, - "overflow": { "type": "string", "enum": ["visible", "hidden", "ellipsis"] }, - "maxLines": { "type": "integer" }, - "lineHeight": { "type": "number" } - } - } - }, - { - "if": { "properties": { "type": { "const": "flex" } } }, - "then": { - "properties": { - "direction": { "type": "string", "enum": ["row", "column", "row-reverse", "column-reverse"] }, - "wrap": { "type": "string", "enum": ["nowrap", "wrap", "wrap-reverse"] }, - "gap": { "type": ["string", "integer"] }, - "columnGap": { "type": ["string", "integer"] }, - "rowGap": { "type": ["string", "integer"] }, - "justify": { "type": "string", "enum": ["start", "center", "end", "space-between", "space-around", "space-evenly"] }, - "align": { "type": "string", "enum": ["start", "center", "end", "stretch", "baseline"] }, - "alignContent": { "type": "string", "enum": ["start", "center", "end", "stretch", "space-between", "space-around", "space-evenly"] }, - "overflow": { "type": "string", "enum": ["visible", "hidden"] }, - "children": { "type": "array", "items": { "$ref": "#/definitions/element" } } - } - } - }, - { - "if": { "properties": { "type": { "const": "image" } } }, - "then": { - "properties": { - "src": { "type": "string", "description": "Image source path or URL" }, - "objectFit": { "type": "string", "enum": ["fill", "contain", "cover", "none"] } - }, - "required": ["src"] - } - }, - { - "if": { "properties": { "type": { "const": "qr" } } }, - "then": { - "properties": { - "data": { "type": "string", "description": "QR code data" }, - "size": { "type": "integer" }, - "foreground": { "type": "string" }, - "errorCorrection": { "type": "string", "enum": ["L", "M", "Q", "H"] } - }, - "required": ["data"] - } - }, - { - "if": { "properties": { "type": { "const": "each" } } }, - "then": { - "properties": { - "array": { "type": "string", "description": "Path to array in data" }, - "as": { "type": "string", "description": "Iterator variable name" }, - "children": { "type": "array", "items": { "$ref": "#/definitions/element" } } - }, - "required": ["array", "children"] - } - }, - { - "if": { "properties": { "type": { "const": "if" } } }, - "then": { - "properties": { - "condition": { "type": "string" }, - "equals": {}, - "notEquals": {}, - "in": { "type": "array" }, - "notIn": { "type": "array" }, - "contains": { "type": "string" }, - "greaterThan": { "type": "number" }, - "lessThan": { "type": "number" }, - "hasItems": { "type": "boolean" }, - "then": { "type": "array", "items": { "$ref": "#/definitions/element" } }, - "else": { "type": "array", "items": { "$ref": "#/definitions/element" } } - }, - "required": ["condition", "then"] - } - }, - { - "if": { "properties": { "type": { "const": "separator" } } }, - "then": { - "properties": { - "color": { "type": "string" }, - "thickness": { "type": "number" }, - "style": { "type": "string", "enum": ["solid", "dashed", "dotted"] } - } - } - }, - { - "if": { "properties": { "type": { "const": "content" } } }, - "then": { - "properties": { - "source": { "type": "string", "description": "Content source path" }, - "format": { "type": "string", "enum": ["ndc", "markdown", "html"], "description": "Content format" }, - "options": { "type": "object" } - }, - "required": ["source"] - } - } - ] - } - ] - } - } -} -``` - -> **Note:** This is a starting schema. It can be refined later to match `KnownProperties.cs` exactly. The key is getting autocomplete for `type` values and per-type properties. - -**Step 2: Integrate monaco-yaml into main.js** - -Replace the Monaco loading section with: - -```javascript -// Load Monaco + monaco-yaml -// monaco-yaml requires ES module import; use dynamic import after Monaco loader -window.require = { paths: { vs: `${MONACO_CDN}/vs` } }; -await loadScript(`${MONACO_CDN}/vs/loader.js`); - -await new Promise((resolve) => { - window.require(['vs/editor/editor.main'], resolve); -}); - -const monaco = window.monaco; - -// Configure YAML schema for autocomplete -// monaco-yaml needs to be loaded as ESM separately -// For now, Monaco's built-in YAML mode provides syntax highlighting -// Full monaco-yaml integration can be added as a follow-up - -// Load schema for reference -const schemaResponse = await fetch('schemas/flexrender-template.json'); -const flexrenderSchema = await schemaResponse.json(); -``` - -> **Note:** Full `monaco-yaml` integration (with npm-based ESM bundling) is complex to wire into a CDN-only setup. For MVP, use Monaco's built-in YAML syntax highlighting. `monaco-yaml` integration can be added in a follow-up task with a bundler (esbuild/vite). - -**Step 3: Commit** - -```bash -git add src/FlexRender.Playground/wwwroot/schemas/ src/FlexRender.Playground/wwwroot/main.js -git commit -m "feat(playground): add FlexRender JSON Schema for YAML autocomplete" -``` - ---- - -## Task 8: GitHub Actions deployment - -CI/CD pipeline to build and deploy to GitHub Pages. - -**Files:** -- Create: `.github/workflows/playground.yml` - -**Step 1: Write GitHub Actions workflow** - -```yaml -name: Deploy Playground - -on: - push: - branches: [main] - paths: - - 'src/FlexRender.Playground/**' - - 'src/FlexRender.Core/**' - - 'src/FlexRender.Yaml/**' - - 'src/FlexRender.Skia.Render/**' - - '.github/workflows/playground.yml' - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - 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: Compress with Brotli - run: | - find publish/wwwroot -type f \( -name "*.js" -o -name "*.wasm" -o -name "*.dll" -o -name "*.json" -o -name "*.css" -o -name "*.html" \) -exec brotli -f -q 11 {} \; - - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: publish/wwwroot - - deploy: - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 -``` - -**Step 2: Commit** - -```bash -git add .github/workflows/playground.yml -git commit -m "build(playground): add GitHub Actions workflow for Pages deployment" -``` - ---- - -## Task 9: Polish and integration testing - -Final polish: verify full pipeline, fix any issues, add README section. - -**Files:** -- Modify: various files as needed for bug fixes - -**Step 1: Full integration test** - -```bash -dotnet run --project src/FlexRender.Playground -``` - -Test checklist: -- [ ] Page loads, spinner shows, then IDE appears -- [ ] Default template renders in preview -- [ ] Editing YAML triggers re-render after 300ms -- [ ] Editing JSON data triggers re-render -- [ ] Validation errors show in Errors tab -- [ ] Example dropdown loads examples -- [ ] Export PNG downloads a file -- [ ] Drag & drop font file → re-render with custom font -- [ ] Drag & drop image file → use in `type: image` with `src: "filename.png"` -- [ ] Drag & drop .ndc file → use in `type: content` with `source: "file.ndc"` -- [ ] Status bar shows render time and file size - -**Step 2: Fix any issues found** - -Address build errors, runtime errors, or UI glitches found during testing. - -**Step 3: Final commit** - -```bash -git add -A -git commit -m "feat(playground): complete WASM playground MVP with Monaco Editor" -``` - ---- - -## Execution Order Summary - -| Task | Description | Depends On | Risk | -|------|-------------|-----------|------| -| 1 | Scaffold wasmbrowser project | — | HIGH (WASM + SkiaSharp compatibility) | -| 2 | MemoryResourceLoader | 1 | LOW | -| 3 | PlaygroundApi (JSExport) | 2 | MEDIUM (JSExport marshalling) | -| 4 | Minimal HTML/JS shell | 3 | HIGH (validates full WASM pipeline) | -| 5 | Monaco Editor UI | 4 | LOW | -| 6 | Example gallery | 5 | LOW | -| 7 | JSON Schema + autocomplete | 5 | LOW | -| 8 | GitHub Actions deployment | 5 | LOW | -| 9 | Polish and testing | 5-8 | LOW | - -**Critical path:** Tasks 1 → 4. If SkiaSharp WASM doesn't work, we'll know at Task 4 and can pivot to SVG renderer. From 77d630fabaf132d944aa0451b9b2be3107313c04 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 10 Mar 2026 13:53:13 +0300 Subject: [PATCH 19/25] fix(playground): add Inter font to VFS and fix Data Binding each template Add Inter-Regular.ttf to example-assets so it appears in VFS file tree. All examples now explicitly declare the font in assets list. Fix Data Binding example: add as:item to each loop for string arrays. --- src/FlexRender.Playground/PlaygroundApi.cs | 2 +- .../wwwroot/example-assets/Inter-Regular.ttf | 3 +++ src/FlexRender.Playground/wwwroot/main.js | 22 +++++++++++++------ 3 files changed, 19 insertions(+), 8 deletions(-) create mode 100755 src/FlexRender.Playground/wwwroot/example-assets/Inter-Regular.ttf diff --git a/src/FlexRender.Playground/PlaygroundApi.cs b/src/FlexRender.Playground/PlaygroundApi.cs index 939c6a3..3fe7a02 100644 --- a/src/FlexRender.Playground/PlaygroundApi.cs +++ b/src/FlexRender.Playground/PlaygroundApi.cs @@ -36,7 +36,7 @@ public static void Initialize() _memoryLoader = new MemoryResourceLoader(); _parser = new TemplateParser(); - // Load embedded default font (WASM has no system fonts) + // Load embedded default font into VFS (WASM has no system fonts) LoadEmbeddedFont("Inter-Regular.ttf"); var builder = new FlexRenderBuilder() diff --git a/src/FlexRender.Playground/wwwroot/example-assets/Inter-Regular.ttf b/src/FlexRender.Playground/wwwroot/example-assets/Inter-Regular.ttf new file mode 100755 index 0000000..e675cc0 --- /dev/null +++ b/src/FlexRender.Playground/wwwroot/example-assets/Inter-Regular.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e5f90a0138b38de4cf4d779ad78391974ea1df776b9164842bdcbb60ce383c5 +size 342680 diff --git a/src/FlexRender.Playground/wwwroot/main.js b/src/FlexRender.Playground/wwwroot/main.js index 2ea08ad..c810930 100644 --- a/src/FlexRender.Playground/wwwroot/main.js +++ b/src/FlexRender.Playground/wwwroot/main.js @@ -53,7 +53,8 @@ layout: size: 28 color: "#333333" padding: "30"`, - json: '{}' + json: '{}', + assets: ['Inter-Regular.ttf'], }, 'Flex Layout': { yaml: `canvas: @@ -88,7 +89,8 @@ layout: content: "Right (grow: 2)" color: "#ffffff" size: 16`, - json: '{}' + json: '{}', + assets: ['Inter-Regular.ttf'], }, 'Data Binding': { yaml: `canvas: @@ -116,6 +118,7 @@ layout: color: "#eee" - type: each array: items + as: item children: - type: text content: "\u2022 {{item}}" @@ -125,14 +128,17 @@ layout: "title": "Shopping List", "author": "FlexRender", "items": ["Apples", "Bread", "Milk", "Cheese"] -}` +}`, + assets: ['Inter-Regular.ttf'], }, 'Image Scaling': { yaml: `canvas: fixed: width width: 440 background: "#ffffff" - +fonts: + - name: main + path: Inter-Regular.ttf layout: - type: flex direction: column @@ -215,14 +221,16 @@ layout: height: "120" fit: fill`, json: '{}', - assets: ['test-pattern.png'], + assets: ['test-pattern.png', 'Inter-Regular.ttf'], }, 'Dynamic Receipt': { yaml: `canvas: fixed: width width: 320 background: "#ffffff" - +fonts: + - name: main + path: Inter-Regular.ttf layout: - type: flex padding: "24 20" @@ -423,7 +431,7 @@ layout: "paymentUrl": "https://pay.example.com/inv/12345", "date": "2026-03-10 14:30" }`, - assets: ['star-badge.png'], + assets: ['star-badge.png', 'Inter-Regular.ttf'], }, 'NDC Receipt': { yaml: `# NDC (ATM receipt) format — binary terminal data rendered as a receipt From 38daeccd6bbbef8fbea6e56c31d533c78fb056dd Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 10 Mar 2026 14:05:01 +0300 Subject: [PATCH 20/25] fix: address all code review issues from PR #8 - Remove reflection (Assembly.GetExecutingAssembly) for AOT safety - Gate diagnostics behind LayoutDiagnostics record + EnableDiagnostics flag - Remove InternalsVisibleTo for Playground, make ResourceLoaders public - Use ConcurrentDictionary in MemoryResourceLoader for thread safety - Add 10MB resource size limit in MemoryResourceLoader - Fix silent catch in FontManager.PreloadFontFromResourcesAsync - Fix race condition with atomic AddOrUpdate in FontManager - Add min 1px bitmap guard to all Render overloads - Escape innerHTML in context menu for XSS safety - Remove leftover debug variable in LayoutEngine - Add playground link to README, wiki Home and Getting Started --- README.md | 2 + docs/wiki/Getting-Started.md | 2 + docs/wiki/Home.md | 2 + .../Configuration/FlexRenderBuilder.cs | 8 ++-- src/FlexRender.Core/FlexRender.Core.csproj | 1 - .../Layout/LayoutDiagnostics.cs | 14 ++++++ src/FlexRender.Core/Layout/LayoutEngine.cs | 17 ++++--- src/FlexRender.Core/Layout/LayoutNode.cs | 22 ++-------- .../FlexRender.Playground.csproj | 4 -- .../MemoryResourceLoader.cs | 12 +++-- src/FlexRender.Playground/PlaygroundApi.cs | 44 +++++++------------ src/FlexRender.Playground/wwwroot/main.js | 2 +- .../Rendering/FontManager.cs | 12 ++--- .../Rendering/SkiaRenderer.cs | 17 +++++-- src/FlexRender.Skia.Render/SkiaRender.cs | 10 +++++ 15 files changed, 95 insertions(+), 74 deletions(-) create mode 100644 src/FlexRender.Core/Layout/LayoutDiagnostics.cs diff --git a/README.md b/README.md index f463a7f..7850e41 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index f889b82..73a2831 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -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) diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index a992e4f..ec011e0 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -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 diff --git a/src/FlexRender.Core/Configuration/FlexRenderBuilder.cs b/src/FlexRender.Core/Configuration/FlexRenderBuilder.cs index e8d9710..66211b7 100644 --- a/src/FlexRender.Core/Configuration/FlexRenderBuilder.cs +++ b/src/FlexRender.Core/Configuration/FlexRenderBuilder.cs @@ -70,12 +70,14 @@ public sealed class FlexRenderBuilder /// /// Gets the list of configured resource loaders. + /// Custom loaders can be inserted before is called to control + /// resolution priority (lower index = higher priority). /// /// - /// Loaders are added lazily when is called to ensure - /// they receive the fully configured instance. + /// Built-in loaders (file, base64, embedded) are added lazily when + /// is called to ensure they receive the fully configured instance. /// - internal List ResourceLoaders { get; } = []; + public List ResourceLoaders { get; } = []; /// /// Gets the configured filter registry, or null if no filters have been registered. diff --git a/src/FlexRender.Core/FlexRender.Core.csproj b/src/FlexRender.Core/FlexRender.Core.csproj index 7039027..32a2f97 100644 --- a/src/FlexRender.Core/FlexRender.Core.csproj +++ b/src/FlexRender.Core/FlexRender.Core.csproj @@ -12,7 +12,6 @@ - diff --git a/src/FlexRender.Core/Layout/LayoutDiagnostics.cs b/src/FlexRender.Core/Layout/LayoutDiagnostics.cs new file mode 100644 index 0000000..35e7835 --- /dev/null +++ b/src/FlexRender.Core/Layout/LayoutDiagnostics.cs @@ -0,0 +1,14 @@ +namespace FlexRender.Layout; + +/// +/// Diagnostic data attached to a for debugging text layout. +/// +/// Intrinsic width from IntrinsicMeasurer (before scaling). +/// Shaped width from TextShaper at final font size. +/// Final content width used in layout calculation. +/// Resolved typeface family name. +public sealed record LayoutDiagnostics( + float IntrinsicWidth, + float ShapedWidth, + float ContentWidth, + string? ResolvedTypeface = null); diff --git a/src/FlexRender.Core/Layout/LayoutEngine.cs b/src/FlexRender.Core/Layout/LayoutEngine.cs index 2a4fe53..dc49425 100644 --- a/src/FlexRender.Core/Layout/LayoutEngine.cs +++ b/src/FlexRender.Core/Layout/LayoutEngine.cs @@ -53,6 +53,12 @@ public LayoutEngine(ResourceLimits limits) /// public ITextShaper? TextShaper { get; set; } + /// + /// When true, populates with text measurement + /// details (intrinsic width, shaped width, content width). Defaults to false. + /// + public bool EnableDiagnostics { get; set; } + /// /// 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 @@ -587,9 +593,10 @@ private LayoutNode LayoutTextElement(TextElement text, LayoutContext context) node.ComputedLineHeight = computedLineHeight; node.Baseline = padding.Top + border.Top.Width + textBaseline; node.ComputedFontSize = resolvedFontSize; - node.DiagContentWidth = contentWidth; - node.DiagIntrinsicWidth = diagIntrinsicW; - node.DiagShapedWidth = diagShapedW; + if (EnableDiagnostics) + { + node.Diagnostics = new LayoutDiagnostics(diagIntrinsicW, diagShapedW, contentWidth); + } return node; } @@ -875,9 +882,7 @@ private float ComputeFitContentFontSize(FlexElement flex, LayoutContext innerCon // Floor to 0.1px to avoid rounding errors where text barely exceeds container width // due to non-linear font scaling (hinting, glyph rounding) var computed = refSize * availableWidth / totalMeasured; - var floored = MathF.Floor(computed * 10f) / 10f; - - return floored; + return MathF.Floor(computed * 10f) / 10f; } private float MeasureContentWidth(TemplateElement element, LayoutContext context) diff --git a/src/FlexRender.Core/Layout/LayoutNode.cs b/src/FlexRender.Core/Layout/LayoutNode.cs index 0355e32..705eab2 100644 --- a/src/FlexRender.Core/Layout/LayoutNode.cs +++ b/src/FlexRender.Core/Layout/LayoutNode.cs @@ -62,26 +62,10 @@ public sealed class LayoutNode public float ComputedFontSize { get; set; } /// - /// Diagnostic: intrinsic width from IntrinsicMeasurer (before scaling). - /// Only populated for text elements during layout when diagnostics are enabled. + /// Optional diagnostic data populated during layout for debugging purposes. + /// Only populated when is true. /// - public float DiagIntrinsicWidth { get; set; } - - /// - /// Diagnostic: shaped width from TextShaper at final font size. - /// Only populated for text elements during layout when diagnostics are enabled. - /// - public float DiagShapedWidth { get; set; } - - /// - /// Diagnostic: final content width used in layout calculation. - /// - public float DiagContentWidth { get; set; } - - /// - /// Diagnostic: resolved typeface family name from FontManager. - /// - public string? DiagResolvedTypeface { get; set; } + public LayoutDiagnostics? Diagnostics { get; set; } /// Right edge (X + Width). public float Right => X + Width; diff --git a/src/FlexRender.Playground/FlexRender.Playground.csproj b/src/FlexRender.Playground/FlexRender.Playground.csproj index 3267aad..8f409b6 100644 --- a/src/FlexRender.Playground/FlexRender.Playground.csproj +++ b/src/FlexRender.Playground/FlexRender.Playground.csproj @@ -26,8 +26,4 @@ - - - - diff --git a/src/FlexRender.Playground/MemoryResourceLoader.cs b/src/FlexRender.Playground/MemoryResourceLoader.cs index 6473afc..b73df37 100644 --- a/src/FlexRender.Playground/MemoryResourceLoader.cs +++ b/src/FlexRender.Playground/MemoryResourceLoader.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using FlexRender.Abstractions; namespace FlexRender.Playground; @@ -8,7 +9,9 @@ namespace FlexRender.Playground; /// internal sealed class MemoryResourceLoader : IResourceLoader { - private readonly Dictionary _resources = new(StringComparer.OrdinalIgnoreCase); + private const int MaxResourceSize = 10 * 1024 * 1024; // 10 MB per resource + + private readonly ConcurrentDictionary _resources = new(StringComparer.OrdinalIgnoreCase); /// /// Priority 10 ensures uploaded files override all other loaders. @@ -59,6 +62,9 @@ public void AddResource(string name, byte[] data) ArgumentNullException.ThrowIfNull(name); ArgumentNullException.ThrowIfNull(data); + if (data.Length > MaxResourceSize) + throw new ArgumentException($"Resource exceeds maximum size of {MaxResourceSize / 1024 / 1024} MB.", nameof(data)); + _resources[NormalizePath(name)] = data; } @@ -70,7 +76,7 @@ public void RemoveResource(string name) { ArgumentNullException.ThrowIfNull(name); - _resources.Remove(NormalizePath(name)); + _resources.TryRemove(NormalizePath(name), out _); } /// @@ -78,7 +84,7 @@ public void RemoveResource(string name) /// public void Clear() { - _resources.Clear(); + _resources.Clear(); // ConcurrentDictionary.Clear is thread-safe } /// diff --git a/src/FlexRender.Playground/PlaygroundApi.cs b/src/FlexRender.Playground/PlaygroundApi.cs index 3fe7a02..93910ed 100644 --- a/src/FlexRender.Playground/PlaygroundApi.cs +++ b/src/FlexRender.Playground/PlaygroundApi.cs @@ -1,4 +1,3 @@ -using System.Reflection; using System.Runtime.InteropServices.JavaScript; using System.Text.Json; using System.Text.Json.Nodes; @@ -36,9 +35,6 @@ public static void Initialize() _memoryLoader = new MemoryResourceLoader(); _parser = new TemplateParser(); - // Load embedded default font into VFS (WASM has no system fonts) - LoadEmbeddedFont("Inter-Regular.ttf"); - var builder = new FlexRenderBuilder() .WithNdc() .WithSkia(); @@ -47,6 +43,11 @@ public static void Initialize() builder.ResourceLoaders.Insert(0, _memoryLoader); _render = builder.Build(); + + // Enable layout diagnostics for the playground inspector + if (_render is SkiaRender skiaRender) + skiaRender.EnableDiagnostics = true; + Console.WriteLine("FlexRender engine initialized successfully"); } catch (Exception ex) @@ -379,14 +380,17 @@ private static JsonObject SerializeLayoutNode(LayoutNode node) obj["direction"] = node.Direction.ToString().ToLowerInvariant(); // Diagnostic fields for text debugging - if (node.DiagContentWidth > 0) - obj["contentW"] = Math.Round(node.DiagContentWidth, 2); - if (node.DiagIntrinsicWidth > 0) - obj["intrinsicW"] = Math.Round(node.DiagIntrinsicWidth, 2); - if (node.DiagShapedWidth > 0) - obj["shapedW"] = Math.Round(node.DiagShapedWidth, 2); - if (!string.IsNullOrEmpty(node.DiagResolvedTypeface)) - obj["resolvedTypeface"] = node.DiagResolvedTypeface; + if (node.Diagnostics is { } diag) + { + if (diag.ContentWidth > 0) + obj["contentW"] = Math.Round(diag.ContentWidth, 2); + if (diag.IntrinsicWidth > 0) + obj["intrinsicW"] = Math.Round(diag.IntrinsicWidth, 2); + if (diag.ShapedWidth > 0) + obj["shapedW"] = Math.Round(diag.ShapedWidth, 2); + if (!string.IsNullOrEmpty(diag.ResolvedTypeface)) + obj["resolvedTypeface"] = diag.ResolvedTypeface; + } // Element-specific properties SerializeElementProperties(obj, node.Element); @@ -671,22 +675,6 @@ public static string GetFontDiagnostics() } } - private static void LoadEmbeddedFont(string resourceName) - { - var assembly = Assembly.GetExecutingAssembly(); - using var stream = assembly.GetManifestResourceStream(resourceName); - if (stream is null) - { - Console.Error.WriteLine($"Embedded font not found: {resourceName}"); - return; - } - - using var ms = new MemoryStream(); - stream.CopyTo(ms); - _memoryLoader!.AddResource(resourceName, ms.ToArray()); - Console.WriteLine($"Loaded embedded font: {resourceName}"); - } - /// /// Parses a JSON string into an for template data binding. /// Mirrors the logic from FlexRender.Cli.DataLoader. diff --git a/src/FlexRender.Playground/wwwroot/main.js b/src/FlexRender.Playground/wwwroot/main.js index c810930..afa2c11 100644 --- a/src/FlexRender.Playground/wwwroot/main.js +++ b/src/FlexRender.Playground/wwwroot/main.js @@ -682,7 +682,7 @@ function showContextMenu(x, y, items) { } const el = document.createElement('div'); el.className = 'ctx-menu-item'; - el.innerHTML = `${item.label}${item.shortcut ? `${item.shortcut}` : ''}`; + el.innerHTML = `${escHtml(item.label)}${item.shortcut ? `${escHtml(item.shortcut)}` : ''}`; el.addEventListener('click', () => { hideContextMenu(); item.action(); }); ctxMenu.appendChild(el); } diff --git a/src/FlexRender.Skia.Render/Rendering/FontManager.cs b/src/FlexRender.Skia.Render/Rendering/FontManager.cs index fd2d344..ce93845 100644 --- a/src/FlexRender.Skia.Render/Rendering/FontManager.cs +++ b/src/FlexRender.Skia.Render/Rendering/FontManager.cs @@ -181,14 +181,16 @@ public async Task PreloadFontFromResourcesAsync(string name, string resour continue; // Cache directly so GetTypeface returns it synchronously - _typefaces.TryRemove(name, out var old); - old?.Dispose(); - _typefaces[name] = typeface; + _typefaces.AddOrUpdate(name, typeface, (_, old) => + { + old.Dispose(); + return typeface; + }); return true; } - catch + catch (Exception ex) when (ex is not OutOfMemoryException) { - // Resource loader failed, try next + // Resource loader failed (e.g. file not found, invalid format), try next } } diff --git a/src/FlexRender.Skia.Render/Rendering/SkiaRenderer.cs b/src/FlexRender.Skia.Render/Rendering/SkiaRenderer.cs index 7975553..b1425e1 100644 --- a/src/FlexRender.Skia.Render/Rendering/SkiaRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/SkiaRenderer.cs @@ -143,6 +143,15 @@ public SkiaRenderer( /// public FontManager FontManager => _fontManager; + /// + /// Enables diagnostic data collection on layout nodes. + /// + public bool EnableDiagnostics + { + get => _layoutEngine.EnableDiagnostics; + set => _layoutEngine.EnableDiagnostics = value; + } + /// /// Computes the layout tree for a template with data asynchronously. /// Uses the same layout engine configuration as rendering (including text measurement). @@ -236,7 +245,7 @@ public async Task Render( ? new SKSize(rootNode.Height, rootNode.Width) : new SKSize(rootNode.Width, rootNode.Height); - var bitmap = new SKBitmap((int)size.Width, (int)size.Height); + var bitmap = new SKBitmap(Math.Max(1, (int)size.Width), Math.Max(1, (int)size.Height)); try { _renderingEngine.RenderToBitmapCore(bitmap, processedTemplate, rootNode, default, imageCache, _defaultRenderOptions); @@ -411,7 +420,7 @@ public async Task RenderToJpeg( ? new SKSize(rootNode.Height, rootNode.Width) : new SKSize(rootNode.Width, rootNode.Height); - using var bitmap = new SKBitmap((int)size.Width, (int)size.Height); + using var bitmap = new SKBitmap(Math.Max(1, (int)size.Width), Math.Max(1, (int)size.Height)); _renderingEngine.RenderToBitmapCore(bitmap, processedTemplate, rootNode, default, imageCache, renderOptions); using var image = SKImage.FromBitmap(bitmap); @@ -469,7 +478,7 @@ public async Task RenderToBmp( ? new SKSize(rootNode.Height, rootNode.Width) : new SKSize(rootNode.Width, rootNode.Height); - using var bitmap = new SKBitmap((int)size.Width, (int)size.Height); + using var bitmap = new SKBitmap(Math.Max(1, (int)size.Width), Math.Max(1, (int)size.Height)); _renderingEngine.RenderToBitmapCore(bitmap, processedTemplate, rootNode, default, imageCache, renderOptions); BmpEncoder.Encode(bitmap, output, colorMode); @@ -523,7 +532,7 @@ public async Task RenderToRaw( ? new SKSize(rootNode.Height, rootNode.Width) : new SKSize(rootNode.Width, rootNode.Height); - using var bitmap = new SKBitmap((int)size.Width, (int)size.Height); + using var bitmap = new SKBitmap(Math.Max(1, (int)size.Width), Math.Max(1, (int)size.Height)); _renderingEngine.RenderToBitmapCore(bitmap, processedTemplate, rootNode, default, imageCache, renderOptions); // Copy raw pixel bytes directly from the bitmap diff --git a/src/FlexRender.Skia.Render/SkiaRender.cs b/src/FlexRender.Skia.Render/SkiaRender.cs index 95de212..eaffb45 100644 --- a/src/FlexRender.Skia.Render/SkiaRender.cs +++ b/src/FlexRender.Skia.Render/SkiaRender.cs @@ -62,6 +62,16 @@ public sealed class SkiaRender : IFlexRender /// public FontManager FontManager => _renderer.FontManager; + /// + /// Enables diagnostic data collection on layout nodes. + /// When true, is populated with text measurement details. + /// + public bool EnableDiagnostics + { + get => _renderer.EnableDiagnostics; + set => _renderer.EnableDiagnostics = value; + } + /// /// Asynchronously computes layout for a template without rendering. /// Intended for diagnostic and debugging tools. From 99a0050ef82885d229a4442512d69ec26e2c1b47 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 10 Mar 2026 15:56:20 +0300 Subject: [PATCH 21/25] fix(wasm): WASM-safe font handling, cleanup PlaygroundApi, strip alpha from color picker - FontManager: deferred disposal pattern for shared typefaces, WASM guards on system font APIs, resource loader fallback for font loading - PlaygroundApi: remove all console logs, add GetLastError() for error observability, guard native property access with IsFileLoaded() - Monaco editor: custom DocumentColorProvider that strips alpha channel from hex colors (FlexRender doesn't support alpha) - AGENTS.md: document WASM playground testing with agent-browser --- AGENTS.md | 9 + src/FlexRender.Playground/PlaygroundApi.cs | 125 +++++----- src/FlexRender.Playground/wwwroot/main.js | 45 ++++ .../Rendering/FontManager.cs | 231 +++++++++++++----- 4 files changed, 280 insertions(+), 130 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0927287..17016b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/src/FlexRender.Playground/PlaygroundApi.cs b/src/FlexRender.Playground/PlaygroundApi.cs index 93910ed..5f70f26 100644 --- a/src/FlexRender.Playground/PlaygroundApi.cs +++ b/src/FlexRender.Playground/PlaygroundApi.cs @@ -22,6 +22,13 @@ internal static partial class PlaygroundApi private static IFlexRender? _render; private static MemoryResourceLoader? _memoryLoader; private static TemplateParser? _parser; + private static string? _lastError; + + /// + /// Returns the last error message from any API call, or null if no error occurred. + /// + [JSExport] + public static string? GetLastError() => _lastError; /// /// Creates the FlexRender pipeline with an in-memory resource loader, Skia backend, and NDC support. @@ -30,31 +37,21 @@ internal static partial class PlaygroundApi [JSExport] public static void Initialize() { - try - { - _memoryLoader = new MemoryResourceLoader(); - _parser = new TemplateParser(); + _memoryLoader = new MemoryResourceLoader(); + _parser = new TemplateParser(); - var builder = new FlexRenderBuilder() - .WithNdc() - .WithSkia(); + var builder = new FlexRenderBuilder() + .WithNdc() + .WithSkia(); - // Insert memory loader at highest priority so uploaded files win - builder.ResourceLoaders.Insert(0, _memoryLoader); + // Insert memory loader at highest priority so uploaded files win + builder.ResourceLoaders.Insert(0, _memoryLoader); - _render = builder.Build(); + _render = builder.Build(); - // Enable layout diagnostics for the playground inspector - if (_render is SkiaRender skiaRender) - skiaRender.EnableDiagnostics = true; - - Console.WriteLine("FlexRender engine initialized successfully"); - } - catch (Exception ex) - { - Console.Error.WriteLine($"FlexRender initialization failed: {ex}"); - throw; - } + // Enable layout diagnostics for the playground inspector + if (_render is SkiaRender skiaRender) + skiaRender.EnableDiagnostics = true; } /// @@ -69,10 +66,7 @@ public static byte[] RenderToPng(string yaml, string? dataJson) try { if (_render is null) - { - Console.Error.WriteLine("PlaygroundApi not initialized. Call Initialize() first."); return []; - } ObjectValue? data = null; if (!string.IsNullOrWhiteSpace(dataJson)) @@ -87,7 +81,7 @@ public static byte[] RenderToPng(string yaml, string? dataJson) } catch (Exception ex) { - Console.Error.WriteLine($"RenderToPng error: {ex}"); + _lastError = ex.Message; return []; } } @@ -105,10 +99,7 @@ public static byte[] RenderDebugPng(string yaml, string? dataJson) try { if (_render is not SkiaRender skiaRender) - { - Console.Error.WriteLine("PlaygroundApi not initialized or not using SkiaRender."); return []; - } _parser ??= new TemplateParser(); var template = _parser.Parse(yaml); @@ -153,10 +144,7 @@ public static byte[] RenderDebugPng(string yaml, string? dataJson) using var image = SKImage.FromBitmap(bitmap); using var encoded = image.Encode(SKEncodedImageFormat.Png, 100); if (encoded is null) - { - Console.Error.WriteLine("RenderDebugPng: failed to encode debug image"); return []; - } using var ms = new MemoryStream(); encoded.SaveTo(ms); @@ -164,7 +152,7 @@ public static byte[] RenderDebugPng(string yaml, string? dataJson) } catch (Exception ex) { - Console.Error.WriteLine($"RenderDebugPng error: {ex}"); + _lastError = ex.Message; return []; } } @@ -215,7 +203,7 @@ public static void LoadFont(string name, byte[] data) } catch (Exception ex) { - Console.Error.WriteLine($"LoadFont error: {ex.Message}"); + _lastError = ex.Message; } } @@ -233,7 +221,7 @@ public static void LoadImage(string path, byte[] data) } catch (Exception ex) { - Console.Error.WriteLine($"LoadImage error: {ex.Message}"); + _lastError = ex.Message; } } @@ -251,7 +239,7 @@ public static void LoadContent(string path, byte[] data) } catch (Exception ex) { - Console.Error.WriteLine($"LoadContent error: {ex.Message}"); + _lastError = ex.Message; } } @@ -269,7 +257,7 @@ public static void LoadResource(string path, byte[] data) } catch (Exception ex) { - Console.Error.WriteLine($"LoadResource error: {ex.Message}"); + _lastError = ex.Message; } } @@ -286,7 +274,7 @@ public static void RemoveResource(string path) } catch (Exception ex) { - Console.Error.WriteLine($"RemoveResource error: {ex.Message}"); + _lastError = ex.Message; } } @@ -304,7 +292,7 @@ public static string ListResources() } catch (Exception ex) { - Console.Error.WriteLine($"ListResources error: {ex.Message}"); + _lastError = ex.Message; return "[]"; } } @@ -321,10 +309,7 @@ public static string GetLayout(string yaml, string? dataJson) try { if (_render is not SkiaRender skiaRender) - { - Console.Error.WriteLine("PlaygroundApi not initialized or not using SkiaRender."); return "{}"; - } _parser ??= new TemplateParser(); var template = _parser.Parse(yaml); @@ -344,7 +329,7 @@ public static string GetLayout(string yaml, string? dataJson) } catch (Exception ex) { - Console.Error.WriteLine($"GetLayout error: {ex}"); + _lastError = ex.Message; return "{}"; } } @@ -439,12 +424,18 @@ private static void SerializeElementProperties(JsonObject obj, TemplateElement e if (!string.IsNullOrEmpty(t.Color.Value)) obj["color"] = t.Color.Value; - // Resolve typeface name for diagnostics + // Safely resolve typeface name for diagnostics. + // Only access native SKTypeface properties when the base font was loaded from + // a real file/resource — system fallback typefaces crash in WASM (no system fonts). if (_render is SkiaRender skiaRender) { - var typeface = skiaRender.FontManager.GetTypeface( - t.Font.Value, t.FontFamily.Value, t.FontWeight.Value, t.FontStyle.Value); - obj["resolvedTypeface"] = typeface.FamilyName; + var baseFontName = !string.IsNullOrEmpty(t.Font.Value) ? t.Font.Value : "main"; + if (skiaRender.FontManager.IsFileLoaded(baseFontName)) + { + var typeface = skiaRender.FontManager.GetTypeface( + t.Font.Value, t.FontFamily.Value, t.FontWeight.Value, t.FontStyle.Value); + obj["resolvedTypeface"] = typeface.FamilyName; + } } break; @@ -564,6 +555,11 @@ private static void DrawGlyphBoundaries( LayoutNode node, FontManager fontManager) { + // Skip glyph boundaries if font is not file-loaded (WASM: system fallback has invalid native handle) + var baseFontName = !string.IsNullOrEmpty(text.Font.Value) ? text.Font.Value : "main"; + if (!fontManager.IsFileLoaded(baseFontName)) + return; + var fontSize = node.ComputedFontSize > 0 ? node.ComputedFontSize : 16f; var typeface = fontManager.GetTypeface(text.Font.Value, text.FontFamily.Value, text.FontWeight.Value, text.FontStyle.Value); using var font = new SKFont(typeface, FontSizeResolver.Resolve(text.Size.Value, fontSize)); @@ -631,30 +627,25 @@ public static string GetFontDiagnostics() var fonts = new JsonArray(); foreach (var (name, path) in registeredPaths) { - var typeface = fontManager.GetTypeface(name); - - // Also test variant lookup (how layout engine resolves fonts) - var boldVariant = fontManager.GetTypeface(name, Parsing.Ast.FontWeight.Bold, Parsing.Ast.FontStyle.Normal); - var normalVariant = fontManager.GetTypeface(name, Parsing.Ast.FontWeight.Normal, Parsing.Ast.FontStyle.Normal); - - // Test the 4-param overload (how NDC elements resolve: font + fontFamily + weight) - var ndcBoldResolve = fontManager.GetTypeface(name, "JetBrains Mono", Parsing.Ast.FontWeight.Bold, Parsing.Ast.FontStyle.Normal); - var ndcNormalResolve = fontManager.GetTypeface(name, "JetBrains Mono", Parsing.Ast.FontWeight.Normal, Parsing.Ast.FontStyle.Normal); - - fonts.Add(new JsonObject + var info = fontManager.GetTypefaceInfo(name); + var fontObj = new JsonObject { ["name"] = name, ["path"] = path, - ["familyName"] = typeface.FamilyName, - ["isFixedPitch"] = typeface.IsFixedPitch, - ["fontWeight"] = (int)typeface.FontStyle.Weight, - ["isDefault"] = typeface == SkiaSharp.SKTypeface.Default, - // Variant lookups - ["boldVariant"] = $"{boldVariant.FamilyName} w={boldVariant.FontStyle.Weight} fixed={boldVariant.IsFixedPitch} default={boldVariant == SkiaSharp.SKTypeface.Default}", - ["normalVariant"] = $"{normalVariant.FamilyName} w={normalVariant.FontStyle.Weight} fixed={normalVariant.IsFixedPitch} default={normalVariant == SkiaSharp.SKTypeface.Default}", - ["ndcBoldResolve"] = $"{ndcBoldResolve.FamilyName} w={ndcBoldResolve.FontStyle.Weight} fixed={ndcBoldResolve.IsFixedPitch} default={ndcBoldResolve == SkiaSharp.SKTypeface.Default}", - ["ndcNormalResolve"] = $"{ndcNormalResolve.FamilyName} w={ndcNormalResolve.FontStyle.Weight} fixed={ndcNormalResolve.IsFixedPitch} default={ndcNormalResolve == SkiaSharp.SKTypeface.Default}", - }); + ["fileLoaded"] = fontManager.IsFileLoaded(name) + }; + + if (info is var (familyName, isFixedPitch)) + { + fontObj["familyName"] = familyName; + fontObj["isFixedPitch"] = isFixedPitch; + } + else + { + fontObj["familyName"] = "(system fallback - not inspectable in WASM)"; + } + + fonts.Add(fontObj); } var resources = _memoryLoader?.ListResources() ?? []; diff --git a/src/FlexRender.Playground/wwwroot/main.js b/src/FlexRender.Playground/wwwroot/main.js index afa2c11..3df6eba 100644 --- a/src/FlexRender.Playground/wwwroot/main.js +++ b/src/FlexRender.Playground/wwwroot/main.js @@ -481,6 +481,8 @@ const yamlEditor = monaco.editor.create(document.getElementById('yaml-editor'), automaticLayout: true, scrollBeyondLastLine: true, fixedOverflowWidgets: true, + colorDecorators: true, + colorDecoratorsActivatedOn: 'click', quickSuggestions: { other: true, comments: false, @@ -500,6 +502,49 @@ const jsonEditor = monaco.editor.create(document.getElementById('json-editor'), fixedOverflowWidgets: true, }); +// --- Color picker without alpha channel (FlexRender doesn't support alpha) --- +monaco.languages.registerColorProvider('yaml', { + provideDocumentColors(model) { + const colors = []; + const hexRe = /#([0-9a-fA-F]{3,8})\b/g; + for (let i = 1; i <= model.getLineCount(); i++) { + const line = model.getLineContent(i); + let m; + while ((m = hexRe.exec(line)) !== null) { + const hex = m[1]; + let r, g, b; + if (hex.length === 3) { + r = parseInt(hex[0] + hex[0], 16) / 255; + g = parseInt(hex[1] + hex[1], 16) / 255; + b = parseInt(hex[2] + hex[2], 16) / 255; + } else if (hex.length === 6 || hex.length === 8) { + r = parseInt(hex.slice(0, 2), 16) / 255; + g = parseInt(hex.slice(2, 4), 16) / 255; + b = parseInt(hex.slice(4, 6), 16) / 255; + } else { + continue; + } + colors.push({ + color: { red: r, green: g, blue: b, alpha: 1 }, + range: { + startLineNumber: i, + startColumn: m.index + 1, + endLineNumber: i, + endColumn: m.index + 1 + m[0].length, + }, + }); + } + } + return colors; + }, + provideColorPresentations(model, colorInfo) { + const { red, green, blue } = colorInfo.color; + const toHex = (v) => Math.round(v * 255).toString(16).padStart(2, '0'); + const hex = `#${toHex(red)}${toHex(green)}${toHex(blue)}`; + return [{ label: hex }]; + }, +}); + // --- UI elements --- const statusBar = document.getElementById('status-bar'); const statusText = document.getElementById('status-text'); diff --git a/src/FlexRender.Skia.Render/Rendering/FontManager.cs b/src/FlexRender.Skia.Render/Rendering/FontManager.cs index ce93845..28e10e8 100644 --- a/src/FlexRender.Skia.Render/Rendering/FontManager.cs +++ b/src/FlexRender.Skia.Render/Rendering/FontManager.cs @@ -17,6 +17,10 @@ public sealed class FontManager : IFontManager, IDisposable private readonly ConcurrentDictionary _variantTypefaces = new(); private readonly ConcurrentDictionary _fontPaths = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _fontFallbacks = new(StringComparer.OrdinalIgnoreCase); + /// Names of typefaces loaded from real files/resources (safe to inspect native properties). + private readonly ConcurrentDictionary _fileLoadedTypefaces = new(StringComparer.OrdinalIgnoreCase); + /// Typefaces removed from caches that still need disposal at shutdown. + private readonly ConcurrentBag _orphanedTypefaces = new(); private readonly IReadOnlyList _resourceLoaders; private string _defaultFallback = "Arial"; private bool _disposed; @@ -137,22 +141,48 @@ private SKTypeface LoadTypeface(string fontName) var typeface = SKTypeface.FromFile(path); if (typeface != null) { + _fileLoadedTypefaces[fontName] = 1; return typeface; } } - // Try fallback font - if (_fontFallbacks.TryGetValue(fontName, out var fallbackName)) + // Try fallback font via system font lookup. + // In WASM (browser) there are no system fonts — SKTypeface.FromFamilyName() returns + // objects with invalid native handles whose properties cause unrecoverable RuntimeErrors. + // Skip system font fallback entirely and return the built-in blank typeface instead. + if (!OperatingSystem.IsBrowser()) { - var fallback = SKTypeface.FromFamilyName(fallbackName); - if (fallback != null) + if (_fontFallbacks.TryGetValue(fontName, out var fallbackName)) { - return fallback; + var fallback = SKTypeface.FromFamilyName(fallbackName); + if (fallback != null) + { + return fallback; + } } + + // Use default fallback + return SKTypeface.FromFamilyName(_defaultFallback) ?? SKTypeface.Default; + } + + // In WASM, SKTypeface.Default may also have an invalid native handle. + // Try to return any file-loaded typeface as a last resort. + return GetAnyFileLoadedTypeface() ?? SKTypeface.Default; + } + + /// + /// Returns any typeface that was loaded from a real file/resource, or null if none exist. + /// Used as a WASM-safe fallback when system fonts and SKTypeface.Default are unavailable. + /// + private SKTypeface? GetAnyFileLoadedTypeface() + { + foreach (var (name, _) in _fileLoadedTypefaces) + { + if (_typefaces.TryGetValue(name, out var typeface)) + return typeface; } - // Use default fallback - return SKTypeface.FromFamilyName(_defaultFallback) ?? SKTypeface.Default; + return null; } /// @@ -180,12 +210,18 @@ public async Task PreloadFontFromResourcesAsync(string name, string resour if (typeface is null) continue; - // Cache directly so GetTypeface returns it synchronously - _typefaces.AddOrUpdate(name, typeface, (_, old) => + // Cache directly so GetTypeface returns it synchronously. + // Only mark as file-loaded when TryAdd succeeds — if the key already exists + // (e.g. a system fallback was cached by a concurrent GetTypeface call), + // marking it as file-loaded would be incorrect and unsafe in WASM. + if (_typefaces.TryAdd(name, typeface)) { - old.Dispose(); - return typeface; - }); + _fileLoadedTypefaces[name] = 1; + } + else + { + typeface.Dispose(); + } return true; } catch (Exception ex) when (ex is not OutOfMemoryException) @@ -209,12 +245,17 @@ private SKTypeface LoadTypefaceByFamily(string familyName, FontWeight weight, Fo var skFontStyle = ToSkFontStyle(weight, style); var targetWeight = (int)weight; - // 1. Search registered fonts by loading each and checking FamilyName + // 1. Search registered fonts by loading each and checking FamilyName. + // Only check file/resource-loaded typefaces — system fallbacks may have invalid + // native handles in WASM where no system fonts are available. SKTypeface? bestRegisteredMatch = null; var bestRegisteredWeightDiff = int.MaxValue; foreach (var fontPath in _fontPaths) { + if (!_fileLoadedTypefaces.ContainsKey(fontPath.Key)) + continue; + var typeface = GetTypeface(fontPath.Key); if (!string.Equals(typeface.FamilyName, familyName, StringComparison.OrdinalIgnoreCase)) { @@ -244,19 +285,24 @@ private SKTypeface LoadTypefaceByFamily(string familyName, FontWeight weight, Fo return bestRegisteredMatch; } - // 2. Try system fonts via SKFontManager - var systemMatch = SKFontManager.Default.MatchFamily(familyName, skFontStyle); - if (systemMatch is not null) + // 2. Try system fonts via SKFontManager. + // In WASM (browser) there are no system fonts — MatchFamily() returns objects with + // invalid native handles whose properties cause unrecoverable RuntimeErrors. + if (!OperatingSystem.IsBrowser()) { - var weightDiff = Math.Abs((int)systemMatch.FontStyle.Weight - targetWeight); - if (string.Equals(systemMatch.FamilyName, familyName, StringComparison.OrdinalIgnoreCase) - && weightDiff <= 100) + var systemMatch = SKFontManager.Default.MatchFamily(familyName, skFontStyle); + if (systemMatch is not null) { - return systemMatch; - } + var weightDiff = Math.Abs((int)systemMatch.FontStyle.Weight - targetWeight); + if (string.Equals(systemMatch.FamilyName, familyName, StringComparison.OrdinalIgnoreCase) + && weightDiff <= 100) + { + return systemMatch; + } - // System returned an unrelated font; dispose it - systemMatch.Dispose(); + // System returned an unrelated font; dispose it + systemMatch.Dispose(); + } } // 3. Return registered match even if weight is off, or fall back to default @@ -265,10 +311,11 @@ private SKTypeface LoadTypefaceByFamily(string familyName, FontWeight weight, Fo /// /// Factory method to load a typeface variant with specific weight and style. - /// Resolves the base font family name from the registered font, then attempts to find - /// a matching variant through the system font manager first. If the system match returns - /// an unrelated font (different family or distant weight), scans sibling font files in - /// the same directory as the base font for a better match. + /// Resolves the base font family name from the registered font, then searches for a + /// matching variant in the following order: (1) already-registered typefaces in the + /// in-memory cache, (2) the system font manager, (3) sibling font files on disk. + /// Registered typefaces are checked first so that environments without system fonts + /// (e.g., WASM) can resolve variants from pre-loaded fonts. /// /// The variant key containing font name, weight, and style. /// The loaded typeface variant or a fallback to the base typeface. @@ -279,32 +326,74 @@ private SKTypeface LoadTypefaceVariant(TypefaceVariantKey key) // Resolve the family name from the base typeface so that // named fonts (e.g. "main" mapped to a file) resolve correctly. + // If the font was not loaded from a real file/resource, skip variant search + // entirely — system fallback typefaces may have invalid native handles in WASM. var baseTypeface = GetTypeface(key.FontName); + if (!_fileLoadedTypefaces.ContainsKey(key.FontName)) + return baseTypeface; + var familyName = baseTypeface.FamilyName; - // 1. Try system font manager, but verify the result actually matches - var systemMatch = SKFontManager.Default.MatchFamily(familyName, skFontStyle); - if (systemMatch is not null) + // 1. Search among file/resource-loaded typefaces for matching family + weight/style. + // Only inspect typefaces loaded from real font files — system fallbacks may have + // invalid native handles in WASM (no system fonts), causing unrecoverable crashes. + SKTypeface? bestRegistered = null; + var bestRegisteredDiff = int.MaxValue; + foreach (var (name, _) in _fileLoadedTypefaces) { - var weightDiff = Math.Abs((int)systemMatch.FontStyle.Weight - targetWeight); - if (string.Equals(systemMatch.FamilyName, familyName, StringComparison.OrdinalIgnoreCase) - && weightDiff <= 100) + if (!_typefaces.TryGetValue(name, out var registered)) + continue; + + if (!string.Equals(registered.FamilyName, familyName, StringComparison.OrdinalIgnoreCase)) + continue; + + var wDiff = Math.Abs((int)registered.FontStyle.Weight - targetWeight); + var slantMatch = key.Style == FontStyle.Italic + ? registered.FontStyle.Slant != SKFontStyleSlant.Upright + : registered.FontStyle.Slant == SKFontStyleSlant.Upright; + + if (wDiff <= 100 && slantMatch && wDiff < bestRegisteredDiff) { - return systemMatch; + bestRegistered = registered; + bestRegisteredDiff = wDiff; } + } + + if (bestRegistered is not null) + return bestRegistered; + + // 2. Try system font manager, but verify the result actually matches. + // In WASM (browser) there are no system fonts — MatchFamily() returns objects with + // invalid native handles whose properties cause unrecoverable RuntimeErrors. + if (!OperatingSystem.IsBrowser()) + { + var systemMatch = SKFontManager.Default.MatchFamily(familyName, skFontStyle); + if (systemMatch is not null) + { + var weightDiff = Math.Abs((int)systemMatch.FontStyle.Weight - targetWeight); + if (string.Equals(systemMatch.FamilyName, familyName, StringComparison.OrdinalIgnoreCase) + && weightDiff <= 100) + { + return systemMatch; + } - // System returned an unrelated font; dispose and try sibling scan - systemMatch.Dispose(); + // System returned an unrelated font; dispose and try sibling scan + systemMatch.Dispose(); + } } - // 2. Scan sibling font files in the same directory as the base font - var siblingMatch = FindSiblingTypeface(key.FontName, familyName, targetWeight, skFontStyle.Slant); - if (siblingMatch is not null) + // 3. Scan sibling font files in the same directory as the base font. + // In WASM (browser) there is no local file system, so skip sibling discovery. + if (!OperatingSystem.IsBrowser()) { - return siblingMatch; + var siblingMatch = FindSiblingTypeface(key.FontName, familyName, targetWeight, skFontStyle.Slant); + if (siblingMatch is not null) + { + return siblingMatch; + } } - // 3. Fall back to base typeface + // 4. Fall back to base typeface return baseTypeface; } @@ -423,19 +512,19 @@ public bool RegisterFont(string name, string path, string? fallback = null) if (!string.IsNullOrEmpty(fallback)) _fontFallbacks[name] = fallback; - // Clear cached typeface so it gets reloaded - // TryRemove is the thread-safe equivalent of Remove for ConcurrentDictionary - _typefaces.TryRemove(name, out var removedTypeface); - removedTypeface?.Dispose(); + // Remove cached typeface and collect for deferred disposal. + // Cannot dispose immediately — the same object may be shared via _variantTypefaces. + if (_typefaces.TryRemove(name, out var removedTypeface)) + _orphanedTypefaces.Add(removedTypeface); - // Clear matching variant typefaces for re-registered font - foreach (var variantKey in _variantTypefaces.Keys) + // Clear ALL variant typefaces and collect for deferred disposal. + // Variant entries (including __family__ prefixed keys from GetTypefaceByFamily) + // may hold references to the typeface being re-registered. Without a full clear, + // stale entries would return the old typeface. + foreach (var key in _variantTypefaces.Keys) { - if (string.Equals(variantKey.FontName, name, StringComparison.OrdinalIgnoreCase) - && _variantTypefaces.TryRemove(variantKey, out var variantTypeface)) - { - variantTypeface.Dispose(); - } + if (_variantTypefaces.TryRemove(key, out var variant)) + _orphanedTypefaces.Add(variant); } return File.Exists(path); @@ -449,16 +538,28 @@ public bool RegisterFont(string name, string path, string? fallback = null) /// /// Gets the resolved typeface info (family name, fixed-pitch) for a registered font. - /// Returns null if the font is not registered or cannot be loaded. + /// Returns null if the font was not loaded from a real file or resource + /// (system fallback typefaces may have invalid native handles in WASM). /// /// The registered font name. - /// Tuple of (FamilyName, IsFixedPitch) or null. + /// Tuple of (FamilyName, IsFixedPitch) or null if not file-loaded. public (string FamilyName, bool IsFixedPitch)? GetTypefaceInfo(string fontName) { + if (!_fileLoadedTypefaces.ContainsKey(fontName)) + return null; + var typeface = GetTypeface(fontName); return (typeface.FamilyName, typeface.IsFixedPitch); } + /// + /// Returns whether the named font was loaded from a real file or resource (safe to inspect native properties). + /// System fallback typefaces in WASM may have invalid native handles; this check prevents crashes. + /// + /// The registered font name. + /// True if the font was loaded from a file or resource loader. + public bool IsFileLoaded(string fontName) => _fileLoadedTypefaces.ContainsKey(fontName); + /// /// Sets the default fallback font family name. /// @@ -499,7 +600,8 @@ public float ParseFontSize(string? sizeStr, float baseFontSize, float parentSize } /// - /// Disposes all loaded typefaces (both base and variant caches). + /// Disposes all loaded typefaces (base caches, variant caches, and orphaned typefaces + /// collected during font re-registration). /// This method is thread-safe but should only be called once. /// public void Dispose() @@ -509,23 +611,26 @@ public void Dispose() _disposed = true; - // Dispose and remove each base typeface atomically + // Collect all unique typefaces to avoid double-dispose (variants may alias base entries) + var toDispose = new HashSet(ReferenceEqualityComparer.Instance); + foreach (var key in _typefaces.Keys) { if (_typefaces.TryRemove(key, out var typeface)) - { - typeface.Dispose(); - } + toDispose.Add(typeface); } - // Dispose and remove each variant typeface atomically foreach (var key in _variantTypefaces.Keys) { if (_variantTypefaces.TryRemove(key, out var typeface)) - { - typeface.Dispose(); - } + toDispose.Add(typeface); } + + while (_orphanedTypefaces.TryTake(out var orphan)) + toDispose.Add(orphan); + + foreach (var typeface in toDispose) + typeface.Dispose(); } /// From cd2c7ca12b83cd0759370623fd2918f6b889dcd0 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 10 Mar 2026 16:02:56 +0300 Subject: [PATCH 22/25] chore(playground): remove debug console logs from render loop --- src/FlexRender.Playground/wwwroot/main.js | 36 +---------------------- 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/src/FlexRender.Playground/wwwroot/main.js b/src/FlexRender.Playground/wwwroot/main.js index 3df6eba..cebb85e 100644 --- a/src/FlexRender.Playground/wwwroot/main.js +++ b/src/FlexRender.Playground/wwwroot/main.js @@ -31,7 +31,7 @@ try { registerYamlAutocomplete(monaco, flexrenderSchema, { getVfsFiles: () => vfs.listFiles().map(p => ({ path: p, type: vfs.detectType(p) })), }); - console.log('YAML autocomplete registered with FlexRender schema'); + // YAML autocomplete ready } catch (e) { console.warn('YAML autocomplete setup failed:', e.message); } @@ -1122,42 +1122,8 @@ function render() { canvasHeight = layoutData.h || 0; lastLayoutData = layoutData; - // Font diagnostics (logged to console for debugging) - try { - const diagJson = api.GetFontDiagnostics(); - const diag = JSON.parse(diagJson); - if (diag.fonts?.length > 0) { - console.group('Font diagnostics'); - for (const f of diag.fonts) { - const status = f.isDefault ? '⚠️ DEFAULT FALLBACK' : `✅ ${f.familyName}`; - console.log(`${f.name}: ${status} (fixed=${f.isFixedPitch}, weight=${f.fontWeight})`); - console.log(` boldVariant: ${f.boldVariant}`); - console.log(` normalVariant: ${f.normalVariant}`); - console.log(` ndcBoldResolve: ${f.ndcBoldResolve}`); - console.log(` ndcNormalResolve: ${f.ndcNormalResolve}`); - } - console.log(`Memory resources (${diag.memoryResourceCount}):`, diag.memoryResources); - console.groupEnd(); - } - } catch (diagErr) { console.warn('Font diagnostics failed:', diagErr); } - - // Layout debug: log first few text elements with metrics - console.group('Layout metrics (first text elements)'); - function logTexts(node, depth, parentX, parentY) { - if (!node) return; - const ax = (parentX || 0) + node.x, ay = (parentY || 0) + node.y; - if (node.type === 'text') { - console.log(`[${ax.toFixed(1)},${ay.toFixed(1)}] ${node.w.toFixed(1)}×${node.h.toFixed(1)} fontSize=${node.fontSize || '?'} fontSizeExact=${node.fontSizeExact || '?'} font=${node.font || '?'} resolved=${node.resolvedTypeface || '?'} "${(node.content || '').substring(0, 30)}"`); - } - (node.children || []).forEach(c => logTexts(c, depth + 1, ax, ay)); - } - logTexts(layoutData, 0, 0, 0); - console.groupEnd(); - layoutPane.innerHTML = '
' + buildLayoutTree(layoutData, 0) + '
'; - // Compare rendered PNG size vs layout size previewImg.addEventListener('load', () => { - console.log(`PNG: ${previewImg.naturalWidth}×${previewImg.naturalHeight}, Layout: ${canvasWidth}×${canvasHeight}, Ratio: ${(previewImg.naturalWidth / canvasWidth).toFixed(4)}×${(previewImg.naturalHeight / canvasHeight).toFixed(4)}`); if (boundsMode) showAllBounds(layoutData); }, { once: true }); From fbf1fa427c2638ad6d1763e0f1d7d9a47e88cbc4 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 10 Mar 2026 16:08:36 +0300 Subject: [PATCH 23/25] fix: update NDC snapshot golden images for LinearMetrics change --- .../Snapshots/golden/ndc_receipt_bank_b_balance.png | 2 +- .../Snapshots/golden/ndc_receipt_bank_c_balance.png | 2 +- .../FlexRender.Tests/Snapshots/golden/ndc_receipt_formfeed.png | 2 +- tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_spacing.png | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_bank_b_balance.png b/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_bank_b_balance.png index c25f616..52052d7 100644 --- a/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_bank_b_balance.png +++ b/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_bank_b_balance.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7068b58a890bd9d19b66cea5dd6e0da9cbb192164df91db9f89f4a8c92873a85 +oid sha256:bffd88337e04dedac64851ef5f07feba0e988cc7239d671697340e3ad50d3b30 size 101861 diff --git a/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_bank_c_balance.png b/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_bank_c_balance.png index c1c7f14..ab1e027 100644 --- a/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_bank_c_balance.png +++ b/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_bank_c_balance.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:437c9ee6dbe1aeef1416a75a48c9ee516fef31bd489e489eac09e9154452f302 +oid sha256:ee0ce3713a2c5a6d2fea45e3affb742a16d2df30e31a08cd82676aba94532025 size 75881 diff --git a/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_formfeed.png b/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_formfeed.png index 0feead7..3b8deba 100644 --- a/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_formfeed.png +++ b/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_formfeed.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5ddbdd441865003d2111af4eb708c00512ae5fa40bf6288926daf286edfa4aa7 +oid sha256:16c8d56e4a720963165bdc02257366ee22bc5f4a8a18f6187c19bafc44ff609f size 30676 diff --git a/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_spacing.png b/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_spacing.png index da9d349..3bbb51c 100644 --- a/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_spacing.png +++ b/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_spacing.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:79acad61cf4adacd73451c4e6f90e80a8cce86161d976a0135f5cf12def6ee06 +oid sha256:b7f645ef9c26120b014a80bd8494582fb933eb8a1467f88b14404caa3af957b0 size 19338 From 181d92a1c9d4811d11e7804ff1b0ad56c310ae5b Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 10 Mar 2026 16:10:34 +0300 Subject: [PATCH 24/25] Revert "fix: update NDC snapshot golden images for LinearMetrics change" This reverts commit fbf1fa427c2638ad6d1763e0f1d7d9a47e88cbc4. --- .../Snapshots/golden/ndc_receipt_bank_b_balance.png | 2 +- .../Snapshots/golden/ndc_receipt_bank_c_balance.png | 2 +- .../FlexRender.Tests/Snapshots/golden/ndc_receipt_formfeed.png | 2 +- tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_spacing.png | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_bank_b_balance.png b/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_bank_b_balance.png index 52052d7..c25f616 100644 --- a/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_bank_b_balance.png +++ b/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_bank_b_balance.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bffd88337e04dedac64851ef5f07feba0e988cc7239d671697340e3ad50d3b30 +oid sha256:7068b58a890bd9d19b66cea5dd6e0da9cbb192164df91db9f89f4a8c92873a85 size 101861 diff --git a/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_bank_c_balance.png b/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_bank_c_balance.png index ab1e027..c1c7f14 100644 --- a/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_bank_c_balance.png +++ b/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_bank_c_balance.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ee0ce3713a2c5a6d2fea45e3affb742a16d2df30e31a08cd82676aba94532025 +oid sha256:437c9ee6dbe1aeef1416a75a48c9ee516fef31bd489e489eac09e9154452f302 size 75881 diff --git a/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_formfeed.png b/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_formfeed.png index 3b8deba..0feead7 100644 --- a/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_formfeed.png +++ b/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_formfeed.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:16c8d56e4a720963165bdc02257366ee22bc5f4a8a18f6187c19bafc44ff609f +oid sha256:5ddbdd441865003d2111af4eb708c00512ae5fa40bf6288926daf286edfa4aa7 size 30676 diff --git a/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_spacing.png b/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_spacing.png index 3bbb51c..da9d349 100644 --- a/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_spacing.png +++ b/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_spacing.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b7f645ef9c26120b014a80bd8494582fb933eb8a1467f88b14404caa3af957b0 +oid sha256:79acad61cf4adacd73451c4e6f90e80a8cce86161d976a0135f5cf12def6ee06 size 19338 From dd42ffc436efc812aa28a397206750e60b93cfa6 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 10 Mar 2026 16:28:51 +0300 Subject: [PATCH 25/25] fix(fonts): trigger lazy loading before _fileLoadedTypefaces check in family resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoadTypefaceByFamily and LoadTypefaceVariant filtered by _fileLoadedTypefaces before calling GetTypeface, but _fileLoadedTypefaces is only populated during lazy loading via GetTypeface → LoadTypeface. Fonts registered via RegisterFont (which only populates _fontPaths) were skipped, falling back to system fonts. Fix: call GetTypeface first to trigger lazy file loading, then check _fileLoadedTypefaces to determine if the font is safe to inspect. Also adds 27 new FontManager unit/integration tests and Font-Loading.md wiki. --- docs/wiki/Font-Loading.md | 169 ++++++ .../Rendering/FontManager.cs | 15 +- .../Rendering/FontManagerTests.cs | 556 ++++++++++++++++++ 3 files changed, 736 insertions(+), 4 deletions(-) create mode 100644 docs/wiki/Font-Loading.md diff --git a/docs/wiki/Font-Loading.md b/docs/wiki/Font-Loading.md new file mode 100644 index 0000000..ef08c84 --- /dev/null +++ b/docs/wiki/Font-Loading.md @@ -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` 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` | Snapshot of all registered name-to-path mappings | diff --git a/src/FlexRender.Skia.Render/Rendering/FontManager.cs b/src/FlexRender.Skia.Render/Rendering/FontManager.cs index 28e10e8..ca64553 100644 --- a/src/FlexRender.Skia.Render/Rendering/FontManager.cs +++ b/src/FlexRender.Skia.Render/Rendering/FontManager.cs @@ -246,17 +246,20 @@ private SKTypeface LoadTypefaceByFamily(string familyName, FontWeight weight, Fo var targetWeight = (int)weight; // 1. Search registered fonts by loading each and checking FamilyName. - // Only check file/resource-loaded typefaces — system fallbacks may have invalid - // native handles in WASM where no system fonts are available. + // GetTypeface triggers lazy loading from file, which populates _fileLoadedTypefaces. + // After loading, only inspect typefaces that were loaded from real files — system + // fallbacks may have invalid native handles in WASM. SKTypeface? bestRegisteredMatch = null; var bestRegisteredWeightDiff = int.MaxValue; foreach (var fontPath in _fontPaths) { + var typeface = GetTypeface(fontPath.Key); + + // Skip system fallbacks (unsafe to inspect native properties in WASM) if (!_fileLoadedTypefaces.ContainsKey(fontPath.Key)) continue; - var typeface = GetTypeface(fontPath.Key); if (!string.Equals(typeface.FamilyName, familyName, StringComparison.OrdinalIgnoreCase)) { continue; @@ -335,8 +338,12 @@ private SKTypeface LoadTypefaceVariant(TypefaceVariantKey key) var familyName = baseTypeface.FamilyName; // 1. Search among file/resource-loaded typefaces for matching family + weight/style. - // Only inspect typefaces loaded from real font files — system fallbacks may have + // Trigger lazy loading for all registered font paths first, so _fileLoadedTypefaces + // is populated. Then only inspect file-loaded typefaces — system fallbacks may have // invalid native handles in WASM (no system fonts), causing unrecoverable crashes. + foreach (var fontPath in _fontPaths) + GetTypeface(fontPath.Key); + SKTypeface? bestRegistered = null; var bestRegisteredDiff = int.MaxValue; foreach (var (name, _) in _fileLoadedTypefaces) diff --git a/tests/FlexRender.Tests/Rendering/FontManagerTests.cs b/tests/FlexRender.Tests/Rendering/FontManagerTests.cs index 15b022e..0467446 100644 --- a/tests/FlexRender.Tests/Rendering/FontManagerTests.cs +++ b/tests/FlexRender.Tests/Rendering/FontManagerTests.cs @@ -1,3 +1,5 @@ +using FlexRender.Abstractions; +using FlexRender.Configuration; using FlexRender.Parsing.Ast; using FlexRender.Rendering; using SkiaSharp; @@ -11,6 +13,18 @@ public class FontManagerTests : IDisposable private readonly FontManager _fontManager = new(); private readonly string _tempDir; + /// + /// Absolute path to the test font directory under Snapshots/Fonts. + /// + private static readonly string TestFontsDir = Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Snapshots", "Fonts")); + + /// + /// Absolute path to the example fonts directory with full Inter family variants. + /// + private static readonly string ExampleFontsDir = Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "examples", "assets", "fonts")); + public FontManagerTests() { _tempDir = Path.Combine(Path.GetTempPath(), $"FlexRenderFontTests_{Guid.NewGuid():N}"); @@ -24,6 +38,40 @@ public void Dispose() Directory.Delete(_tempDir, recursive: true); } + /// + /// Simple in-memory resource loader for testing font preload from resources. + /// + private sealed class TestResourceLoader : IResourceLoader + { + private readonly Dictionary _resources = new(StringComparer.OrdinalIgnoreCase); + + /// + public int Priority => 0; + + /// + /// Adds a resource that can be loaded by key. + /// + /// The resource key (URI or file name). + /// The raw font bytes. + public void AddResource(string key, byte[] data) => _resources[key] = data; + + /// + public bool CanHandle(string uri) => + _resources.ContainsKey(uri) || _resources.ContainsKey(Path.GetFileName(uri)); + + /// + public Task Load(string uri, CancellationToken cancellationToken = default) + { + if (_resources.TryGetValue(uri, out var data) + || _resources.TryGetValue(Path.GetFileName(uri), out data)) + { + return Task.FromResult(new MemoryStream(data, writable: false)); + } + + return Task.FromResult(null); + } + } + [Fact] public void GetTypeface_DefaultFont_ReturnsTypeface() { @@ -291,4 +339,512 @@ public void ToSkFontStyle_Black_ReturnsWeight900() Assert.Equal(900, skStyle.Weight); } + + // ─── IsFileLoaded tests ────────────────────────────────────────────── + + [Fact] + public void IsFileLoaded_UnregisteredFont_ReturnsFalse() + { + Assert.False(_fontManager.IsFileLoaded("never-registered")); + } + + [Fact] + public void IsFileLoaded_SystemFallback_ReturnsFalse() + { + // Trigger system fallback by requesting an unregistered font name + _fontManager.GetTypeface("some-system-font"); + + Assert.False(_fontManager.IsFileLoaded("some-system-font")); + } + + [Fact] + public void IsFileLoaded_AfterFileRegistration_ReturnsTrue() + { + var fontPath = Path.Combine(TestFontsDir, "Inter-Regular.ttf"); + _fontManager.RegisterFont("inter", fontPath); + + // Force loading so the typeface is cached from file + _fontManager.GetTypeface("inter"); + + Assert.True(_fontManager.IsFileLoaded("inter")); + } + + // ─── GetTypefaceInfo tests ─────────────────────────────────────────── + + [Fact] + public void GetTypefaceInfo_NonFileLoaded_ReturnsNull() + { + // Trigger system fallback + _fontManager.GetTypeface("fallback-only"); + + Assert.Null(_fontManager.GetTypefaceInfo("fallback-only")); + } + + [Fact] + public void GetTypefaceInfo_FileLoadedFont_ReturnsFamilyNameAndFixedPitch() + { + var fontPath = Path.Combine(TestFontsDir, "Inter-Regular.ttf"); + _fontManager.RegisterFont("inter-info", fontPath); + _fontManager.GetTypeface("inter-info"); + + var info = _fontManager.GetTypefaceInfo("inter-info"); + + Assert.NotNull(info); + Assert.False(string.IsNullOrEmpty(info.Value.FamilyName)); + // Inter is a proportional (variable-width) font + Assert.False(info.Value.IsFixedPitch); + } + + [Fact] + public void GetTypefaceInfo_MonospaceFont_ReportsFixedPitch() + { + var fontPath = Path.Combine(TestFontsDir, "JetBrainsMono-Regular.ttf"); + _fontManager.RegisterFont("jetbrains", fontPath); + _fontManager.GetTypeface("jetbrains"); + + var info = _fontManager.GetTypefaceInfo("jetbrains"); + + Assert.NotNull(info); + Assert.True(info.Value.IsFixedPitch); + } + + // ─── PreloadFontFromResourcesAsync tests ───────────────────────────── + + [Fact] + public async Task PreloadFontFromResources_LoadsFontFromResourceLoader() + { + var fontBytes = await File.ReadAllBytesAsync(Path.Combine(TestFontsDir, "Inter-Regular.ttf")); + var loader = new TestResourceLoader(); + loader.AddResource("fonts/Inter-Regular.ttf", fontBytes); + + using var manager = new FontManager([loader]); + + var result = await manager.PreloadFontFromResourcesAsync("preloaded", "fonts/Inter-Regular.ttf"); + + Assert.True(result); + Assert.True(manager.IsFileLoaded("preloaded")); + + var typeface = manager.GetTypeface("preloaded"); + Assert.NotNull(typeface); + } + + [Fact] + public async Task PreloadFontFromResources_NoLoaderHandlesKey_ReturnsFalse() + { + var loader = new TestResourceLoader(); + // Don't add any resources + using var manager = new FontManager([loader]); + + var result = await manager.PreloadFontFromResourcesAsync("missing", "nonexistent.ttf"); + + Assert.False(result); + Assert.False(manager.IsFileLoaded("missing")); + } + + [Fact] + public async Task PreloadFontFromResources_DuplicateKey_DisposesNewTypeface() + { + var fontBytes = await File.ReadAllBytesAsync(Path.Combine(TestFontsDir, "Inter-Regular.ttf")); + var loader = new TestResourceLoader(); + loader.AddResource("fonts/Inter.ttf", fontBytes); + + using var manager = new FontManager([loader]); + + // First preload succeeds + var first = await manager.PreloadFontFromResourcesAsync("dup-font", "fonts/Inter.ttf"); + Assert.True(first); + + var originalTypeface = manager.GetTypeface("dup-font"); + + // Second preload: key already in cache, new typeface should be disposed internally. + // The method returns true because the loader DID handle the resource. + var second = await manager.PreloadFontFromResourcesAsync("dup-font", "fonts/Inter.ttf"); + Assert.True(second); + + // The original cached typeface should still be the one returned + var afterSecond = manager.GetTypeface("dup-font"); + Assert.Same(originalTypeface, afterSecond); + } + + // ─── RegisterFont re-registration tests ────────────────────────────── + + [Fact] + public void RegisterFont_Twice_SecondFileTakesEffect() + { + var interPath = Path.Combine(TestFontsDir, "Inter-Regular.ttf"); + var jetbrainsPath = Path.Combine(TestFontsDir, "JetBrainsMono-Regular.ttf"); + + _fontManager.RegisterFont("swap-font", interPath); + var first = _fontManager.GetTypeface("swap-font"); + Assert.NotNull(first); + + // Re-register with a different file + _fontManager.RegisterFont("swap-font", jetbrainsPath); + var second = _fontManager.GetTypeface("swap-font"); + + // The second typeface should come from the new file (JetBrains Mono is monospaced) + Assert.NotNull(second); + Assert.NotSame(first, second); + + var info = _fontManager.GetTypefaceInfo("swap-font"); + Assert.NotNull(info); + Assert.True(info.Value.IsFixedPitch, "After re-registration, should be JetBrains Mono (fixed-pitch)"); + } + + [Fact] + public void RegisterFont_Twice_ClearsVariantCache() + { + var interPath = Path.Combine(ExampleFontsDir, "Inter-Regular.ttf"); + + _fontManager.RegisterFont("variant-test", interPath); + + // Load a variant to populate variant cache + var boldBefore = _fontManager.GetTypeface("variant-test", FontWeight.Bold, AstFontStyle.Normal); + Assert.NotNull(boldBefore); + + // Re-register + var jetbrainsPath = Path.Combine(TestFontsDir, "JetBrainsMono-Regular.ttf"); + _fontManager.RegisterFont("variant-test", jetbrainsPath); + + // Variant cache should be cleared; new request loads from new font + var boldAfter = _fontManager.GetTypeface("variant-test", FontWeight.Bold, AstFontStyle.Normal); + Assert.NotNull(boldAfter); + Assert.NotSame(boldBefore, boldAfter); + } + + // ─── Dispose deduplication tests ───────────────────────────────────── + + [Fact] + public void Dispose_SharedTypefaces_DisposedOnlyOnce() + { + // Register and load a file font, then request a variant that falls back to base + var fontPath = Path.Combine(TestFontsDir, "Inter-Regular.ttf"); + + using var manager = new FontManager(); + manager.RegisterFont("dedup", fontPath); + + // Force base typeface into _typefaces cache + var baseTypeface = manager.GetTypeface("dedup"); + + // Request variant with Normal weight+style (fast path returns same base instance) + var variant = manager.GetTypeface("dedup", FontWeight.Normal, AstFontStyle.Normal); + Assert.Same(baseTypeface, variant); + + // Dispose should not throw even though the same SKTypeface instance + // may appear in both _typefaces and _variantTypefaces + manager.Dispose(); + + // If we got here without ObjectDisposedException or AccessViolation, dedup works + } + + [Fact] + public void Dispose_OrphanedTypefaces_AreDisposed() + { + var interPath = Path.Combine(TestFontsDir, "Inter-Regular.ttf"); + var jetbrainsPath = Path.Combine(TestFontsDir, "JetBrainsMono-Regular.ttf"); + + using var manager = new FontManager(); + manager.RegisterFont("orphan-test", interPath); + var orphaned = manager.GetTypeface("orphan-test"); + + // Re-register to orphan the first typeface + manager.RegisterFont("orphan-test", jetbrainsPath); + var replacement = manager.GetTypeface("orphan-test"); + + Assert.NotSame(orphaned, replacement); + + // Dispose should handle the orphaned typeface without errors + manager.Dispose(); + } + + // ─── Integration: LoadTypefaceByFamily tests ───────────────────────── + + [Fact] + public void GetTypefaceByFamily_RegisteredFileFont_ResolvesToIt() + { + var fontPath = Path.Combine(TestFontsDir, "Inter-Regular.ttf"); + _fontManager.RegisterFont("my-inter", fontPath); + + // Force file-load so FamilyName is inspectable + var loaded = _fontManager.GetTypeface("my-inter"); + var familyName = loaded.FamilyName; + + // Now query by family name + var byFamily = _fontManager.GetTypefaceByFamily(familyName, FontWeight.Normal, AstFontStyle.Normal); + + Assert.NotNull(byFamily); + Assert.Equal(familyName, byFamily.FamilyName, ignoreCase: true); + } + + [Fact] + public void GetTypefaceByFamily_SystemFont_FindsArial() + { + // System font lookup; skip if running in WASM (this test runs on desktop) + var typeface = _fontManager.GetTypefaceByFamily("Arial", FontWeight.Normal, AstFontStyle.Normal); + + Assert.NotNull(typeface); + // On most desktop systems, Arial is available. If not, we get the "main" fallback. + } + + [Fact] + public void GetTypefaceByFamily_UnknownFamily_FallsBackToMain() + { + var typeface = _fontManager.GetTypefaceByFamily( + "NonExistentFontFamily12345", FontWeight.Normal, AstFontStyle.Normal); + + Assert.NotNull(typeface); + // Should be the "main" fallback typeface + var mainTypeface = _fontManager.GetTypeface("main"); + Assert.Same(mainTypeface, typeface); + } + + [Fact] + public void GetTypefaceByFamily_LazyLoadedFont_ResolvesWithoutPriorGetTypeface() + { + // Regression test: RegisterFont only populates _fontPaths. + // _fileLoadedTypefaces is populated lazily by GetTypeface → LoadTypeface. + // LoadTypefaceByFamily must trigger lazy loading before checking _fileLoadedTypefaces, + // otherwise registered file fonts are skipped and a system fallback is returned. + var fontPath = Path.Combine(TestFontsDir, "JetBrainsMono-Regular.ttf"); + _fontManager.RegisterFont("mono-lazy", fontPath); + + // Do NOT call GetTypeface("mono-lazy") — that would mask the bug. + // Go directly to family-based lookup. + var byFamily = _fontManager.GetTypefaceByFamily("JetBrains Mono", FontWeight.Normal, AstFontStyle.Normal); + + Assert.NotNull(byFamily); + Assert.Equal("JetBrains Mono", byFamily.FamilyName, ignoreCase: true); + Assert.True(byFamily.IsFixedPitch, "JetBrains Mono should be monospaced"); + } + + // ─── Integration: Font variant resolution from file ────────────────── + + [Fact] + public void GetTypeface_BoldWeight_FindsSiblingBoldFile() + { + // Register Inter-Regular from examples dir (which has Inter-Bold sibling) + var regularPath = Path.Combine(ExampleFontsDir, "Inter-Regular.ttf"); + _fontManager.RegisterFont("inter-variants", regularPath); + + // Request Bold variant + var bold = _fontManager.GetTypeface("inter-variants", FontWeight.Bold, AstFontStyle.Normal); + + Assert.NotNull(bold); + // The bold variant should have weight closer to 700 than the regular (400) + Assert.True(bold.FontStyle.Weight >= 600, + $"Expected bold weight >= 600, got {bold.FontStyle.Weight}"); + } + + [Fact] + public void GetTypeface_ItalicStyle_FindsSiblingItalicFile() + { + var regularPath = Path.Combine(ExampleFontsDir, "Inter-Regular.ttf"); + _fontManager.RegisterFont("inter-italic-test", regularPath); + + var italic = _fontManager.GetTypeface("inter-italic-test", FontWeight.Normal, AstFontStyle.Italic); + + Assert.NotNull(italic); + Assert.True(italic.FontStyle.Slant != SKFontStyleSlant.Upright, + $"Expected italic/oblique slant, got {italic.FontStyle.Slant}"); + } + + // ─── Integration: Font variant resolution from registered family ───── + + [Fact] + public void GetTypefaceByFamily_RegisteredBothWeights_ResolvesBold() + { + var regularPath = Path.Combine(ExampleFontsDir, "Inter-Regular.ttf"); + var boldPath = Path.Combine(ExampleFontsDir, "Inter-Bold.ttf"); + + _fontManager.RegisterFont("inter-reg", regularPath); + _fontManager.RegisterFont("inter-bold", boldPath); + + // Force load both so they're file-loaded + var regular = _fontManager.GetTypeface("inter-reg"); + var bold = _fontManager.GetTypeface("inter-bold"); + var familyName = regular.FamilyName; + + // Query by family name with Bold weight + var resolvedBold = _fontManager.GetTypefaceByFamily(familyName, FontWeight.Bold, AstFontStyle.Normal); + + Assert.NotNull(resolvedBold); + Assert.True(resolvedBold.FontStyle.Weight >= 600, + $"Expected bold weight >= 600, got {resolvedBold.FontStyle.Weight}"); + } + + // ─── Integration: TemplatePreprocessor font registration ───────────── + + [Fact] + public async Task RegisterFontsAsync_FileFonts_RegistersAndFileLoads() + { + var fontPath = Path.Combine(TestFontsDir, "Inter-Regular.ttf"); + + var template = new Template + { + Fonts = + { + ["test-font"] = new FontDefinition(fontPath) + } + }; + + var preprocessor = new TemplatePreprocessor(_fontManager, options: null); + await preprocessor.RegisterFontsAsync(template); + + // Font should be registered and loadable + var typeface = _fontManager.GetTypeface("test-font"); + Assert.NotNull(typeface); + Assert.True(_fontManager.IsFileLoaded("test-font")); + } + + [Fact] + public async Task RegisterFontsAsync_DefaultFont_AlsoRegistersAsMain() + { + var fontPath = Path.Combine(TestFontsDir, "Inter-Regular.ttf"); + + var template = new Template + { + Fonts = + { + ["default"] = new FontDefinition(fontPath) + } + }; + + var preprocessor = new TemplatePreprocessor(_fontManager, options: null); + await preprocessor.RegisterFontsAsync(template); + + // Force-load both to trigger file-loaded tracking (lazy loading) + var defaultTypeface = _fontManager.GetTypeface("default"); + var mainTypeface = _fontManager.GetTypeface("main"); + + Assert.True(_fontManager.IsFileLoaded("default")); + Assert.True(_fontManager.IsFileLoaded("main")); + + // Both should resolve to the same font family + Assert.Equal(defaultTypeface.FamilyName, mainTypeface.FamilyName, ignoreCase: true); + } + + [Fact] + public async Task RegisterFontsAsync_ResourceLoaderFallback_LoadsFromLoader() + { + var fontBytes = await File.ReadAllBytesAsync(Path.Combine(TestFontsDir, "Inter-Regular.ttf")); + var loader = new TestResourceLoader(); + loader.AddResource("assets/fonts/Inter-Regular.ttf", fontBytes); + + using var manager = new FontManager([loader]); + + var template = new Template + { + Fonts = + { + ["resource-font"] = new FontDefinition("assets/fonts/Inter-Regular.ttf") + } + }; + + var preprocessor = new TemplatePreprocessor(manager, options: null); + await preprocessor.RegisterFontsAsync(template); + + // Font file doesn't exist on disk at "assets/fonts/Inter-Regular.ttf" (relative), + // so it falls back to resource loader + var typeface = manager.GetTypeface("resource-font"); + Assert.NotNull(typeface); + Assert.True(manager.IsFileLoaded("resource-font")); + } + + [Fact] + public async Task RegisterFontsAsync_WithBasePath_ResolvesRelativeFontPath() + { + var template = new Template + { + Fonts = + { + ["base-path-font"] = new FontDefinition("Snapshots/Fonts/Inter-Regular.ttf") + } + }; + + // Use the test project root as base path + var testProjectRoot = Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, "..", "..", "..")); + var options = new FlexRenderOptions { BasePath = testProjectRoot }; + + var preprocessor = new TemplatePreprocessor(_fontManager, options); + await preprocessor.RegisterFontsAsync(template); + + var typeface = _fontManager.GetTypeface("base-path-font"); + Assert.NotNull(typeface); + Assert.True(_fontManager.IsFileLoaded("base-path-font")); + } + + // ─── GetTypeface four-parameter overload tests ─────────────────────── + + [Fact] + public void GetTypeface_WithFontNameAndFamily_PrefersRegisteredName() + { + var interPath = Path.Combine(TestFontsDir, "Inter-Regular.ttf"); + var jetbrainsPath = Path.Combine(TestFontsDir, "JetBrainsMono-Regular.ttf"); + + _fontManager.RegisterFont("explicit-name", jetbrainsPath); + _fontManager.RegisterFont("other-font", interPath); + + // When fontName is explicitly set (not "main"), it should take priority over fontFamily + var typeface = _fontManager.GetTypeface( + "explicit-name", "Arial", FontWeight.Normal, AstFontStyle.Normal); + + var info = _fontManager.GetTypefaceInfo("explicit-name"); + Assert.NotNull(info); + Assert.True(info.Value.IsFixedPitch, "Should resolve to JetBrains Mono by name, not Arial by family"); + } + + [Fact] + public void GetTypeface_WithMainNameAndFamily_UsesFamilyLookup() + { + var interPath = Path.Combine(TestFontsDir, "Inter-Regular.ttf"); + _fontManager.RegisterFont("inter-family", interPath); + + // Force load to populate file-loaded metadata + var loaded = _fontManager.GetTypeface("inter-family"); + var familyName = loaded.FamilyName; + + // When fontName is "main", should fall through to fontFamily lookup + var typeface = _fontManager.GetTypeface( + "main", familyName, FontWeight.Normal, AstFontStyle.Normal); + + Assert.NotNull(typeface); + Assert.Equal(familyName, typeface.FamilyName, ignoreCase: true); + } + + // ─── RegisteredFontPaths property test ─────────────────────────────── + + [Fact] + public void RegisteredFontPaths_ReflectsRegisteredFonts() + { + _fontManager.RegisterFont("path-a", "/some/path/a.ttf"); + _fontManager.RegisterFont("path-b", "/some/path/b.ttf"); + + var paths = _fontManager.RegisteredFontPaths; + + Assert.Equal(2, paths.Count); + Assert.Equal("/some/path/a.ttf", paths["path-a"]); + Assert.Equal("/some/path/b.ttf", paths["path-b"]); + } + + // ─── ObjectDisposedException tests ─────────────────────────────────── + + [Fact] + public void GetTypeface_AfterDispose_Throws() + { + var manager = new FontManager(); + manager.Dispose(); + + Assert.Throws(() => manager.GetTypeface("main")); + } + + [Fact] + public void GetTypefaceByFamily_AfterDispose_Throws() + { + var manager = new FontManager(); + manager.Dispose(); + + Assert.Throws(() => + manager.GetTypefaceByFamily("Arial", FontWeight.Normal, AstFontStyle.Normal)); + } }