();
+ 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;
+ }
+
+ ///
+ /// 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)] + "...";
+}
diff --git a/src/FlexRender.Playground/Program.cs b/src/FlexRender.Playground/Program.cs
new file mode 100644
index 0000000..808eb52
--- /dev/null
+++ b/src/FlexRender.Playground/Program.cs
@@ -0,0 +1,4 @@
+using System.Runtime.InteropServices.JavaScript;
+
+FlexRender.Playground.PlaygroundApi.Initialize();
+Console.WriteLine("FlexRender Playground ready");
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..c9f508b
--- /dev/null
+++ b/src/FlexRender.Playground/Properties/launchSettings.json
@@ -0,0 +1,11 @@
+{
+ "profiles": {
+ "FlexRender.Playground": {
+ "commandName": "Project",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "http://localhost:5249"
+ }
+ }
+}
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/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/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
new file mode 100644
index 0000000..da5ba7c
--- /dev/null
+++ b/src/FlexRender.Playground/wwwroot/index.html
@@ -0,0 +1,103 @@
+
+
+
+
+
+ FlexRender Playground
+
+
+
+
+
+
+
+
+
Loading FlexRender WASM runtime...
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Files
+ ▼
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+ Ready
+
+
+
+
+ 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
new file mode 100644
index 0000000..cebb85e
--- /dev/null
+++ b/src/FlexRender.Playground/wwwroot/main.js
@@ -0,0 +1,1665 @@
+import { dotnet } from './_framework/dotnet.js';
+
+// --- .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();
+
+// --- 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 ---
+const { init } = await import('https://esm.sh/modern-monaco');
+const monaco = await init({
+ langs: ['yaml', 'json'],
+ themes: ['one-dark-pro'],
+});
+
+// --- 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, {
+ getVfsFiles: () => vfs.listFiles().map(p => ({ path: p, type: vfs.detectType(p) })),
+ });
+ // YAML autocomplete ready
+} catch (e) {
+ console.warn('YAML autocomplete setup failed:', e.message);
+}
+
+// --- Built-in examples (used to seed example projects on first run) ---
+const EXAMPLES = {
+ 'Simple Text': {
+ yaml: `canvas:
+ width: 400
+ height: 150
+ fixed: both
+ background: "#ffffff"
+fonts:
+ - name: main
+ path: Inter-Regular.ttf
+layout:
+ - type: text
+ content: "Hello, FlexRender!"
+ size: 28
+ color: "#333333"
+ padding: "30"`,
+ json: '{}',
+ assets: ['Inter-Regular.ttf'],
+ },
+ 'Flex Layout': {
+ yaml: `canvas:
+ width: 400
+ height: 200
+ fixed: both
+ background: "#f5f5f5"
+fonts:
+ - name: main
+ path: Inter-Regular.ttf
+layout:
+ - 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: '{}',
+ assets: ['Inter-Regular.ttf'],
+ },
+ 'Data Binding': {
+ yaml: `canvas:
+ width: 400
+ height: 300
+ fixed: both
+ background: "#ffffff"
+fonts:
+ - name: main
+ path: Inter-Regular.ttf
+layout:
+ - 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
+ as: item
+ children:
+ - type: text
+ content: "\u2022 {{item}}"
+ size: 14
+ color: "#555"`,
+ json: `{
+ "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
+ 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', '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"
+ 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', 'Inter-Regular.ttf'],
+ },
+ '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 yamlModelUri = monaco.Uri.parse('file:///template.yaml');
+const yamlModel = monaco.editor.createModel('', 'yaml', yamlModelUri);
+
+const yamlEditor = monaco.editor.create(document.getElementById('yaml-editor'), {
+ model: yamlModel,
+ theme: 'one-dark-pro',
+ minimap: { enabled: false },
+ fontSize: 13,
+ tabSize: 2,
+ automaticLayout: true,
+ scrollBeyondLastLine: true,
+ fixedOverflowWidgets: true,
+ colorDecorators: true,
+ colorDecoratorsActivatedOn: 'click',
+ quickSuggestions: {
+ other: true,
+ comments: false,
+ strings: true,
+ },
+});
+
+const jsonEditor = monaco.editor.create(document.getElementById('json-editor'), {
+ value: '{}',
+ language: 'json',
+ theme: 'one-dark-pro',
+ minimap: { enabled: false },
+ fontSize: 13,
+ tabSize: 2,
+ automaticLayout: true,
+ scrollBeyondLastLine: true,
+ 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');
+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');
+
+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();
+});
+
+// --- 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, 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="${absX}" data-y="${absY}" data-w="${node.w}" data-h="${node.h}"`;
+
+ const props = [];
+ 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))}`);
+ 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);
+ const safeDims = escHtml(dims);
+
+ if (hasChildren) {
+ const childrenHtml = node.children.map(c => buildLayoutTree(c, depth + 1, absX, absY)).join('');
+ return `${safeType} ${safeDims}${propsStr}
${childrenHtml} `;
+ }
+ return `${safeType} ${safeDims}${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 = `${escHtml(item.label)}${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() {
+ 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);
+
+ highlightCtx.fillStyle = 'rgba(255, 90, 50, 0.12)';
+ highlightCtx.fillRect(px, py, pw, ph);
+
+ highlightCtx.strokeStyle = 'rgba(255, 90, 50, 0.8)';
+ highlightCtx.lineWidth = 2;
+ highlightCtx.strokeRect(px, py, pw, ph);
+
+ 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);
+ // 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) => {
+ 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';
+ }
+ 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 });
+
+previewContent.addEventListener('dblclick', () => {
+ applyZoom(1);
+});
+
+// --- 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 {
+ 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)`;
+ switchToTab('errors');
+ return;
+ }
+
+ errorsPane.textContent = '';
+
+ const start = performance.now();
+ const dataArg = json.trim() === '{}' || json.trim() === '' ? null : json;
+ const pngBytes = debugMode
+ ? api.RenderDebugPng(yaml, dataArg)
+ : 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;
+ 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;
+ lastLayoutData = layoutData;
+
+ layoutPane.innerHTML = '' + buildLayoutTree(layoutData, 0) + '
';
+ previewImg.addEventListener('load', () => {
+ if (boundsMode) showAllBounds(layoutData);
+ }, { once: true });
+
+ } catch (layoutErr) {
+ console.warn('Layout computation failed:', layoutErr);
+ layoutPane.innerHTML = 'Layout unavailable
';
+ lastLayoutData = null;
+ }
+
+ switchToTab('preview');
+ } else {
+ statusText.textContent = 'Render returned empty \u2014 check console';
+ }
+ } catch (e) {
+ errorsPane.textContent = e.message || String(e);
+ statusBar.classList.add('error');
+ statusText.textContent = 'Error';
+ switchToTab('errors');
+ }
+}
+
+// --- Debounced render ---
+function scheduleRender() {
+ clearTimeout(renderTimeout);
+ renderTimeout = setTimeout(render, 300);
+}
+
+yamlEditor.onDidChangeModelContent(scheduleRender);
+jsonEditor.onDidChangeModelContent(scheduleRender);
+
+// --- 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(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';
+});
+
+// --- 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);
+}
+
+// --- 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();
+ 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();
+ setTimeout(() => URL.revokeObjectURL(a.href), 60000);
+ }
+ } catch (e) {
+ alert('Export failed: ' + (e.message || e));
+ }
+});
+
+// --- Drag & drop from OS ---
+const dropOverlay = document.getElementById('drop-overlay');
+let dragCounter = 0;
+
+document.addEventListener('dragenter', (e) => {
+ e.preventDefault();
+ if (e.dataTransfer.types.includes('text/x-vfs-path')) return;
+ 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) => {
+ if (e.dataTransfer.getData('text/x-vfs-path')) return;
+ e.preventDefault();
+ 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 = [];
+
+ 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 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);
+ 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 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);
+ }
+ }
+}
+
+// --- 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();
+
+// --- Show app & initialize projects ---
+document.getElementById('loading').style.display = 'none';
+document.getElementById('app').style.display = 'flex';
+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/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'));
+ }
+ });
+ });
+}
diff --git a/src/FlexRender.Playground/wwwroot/style.css b/src/FlexRender.Playground/wwwroot/style.css
new file mode 100644
index 0000000..41f4e77
--- /dev/null
+++ b/src/FlexRender.Playground/wwwroot/style.css
@@ -0,0 +1,637 @@
+* { 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; }
+
+#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;
+ overflow: hidden;
+}
+
+.editor-panel {
+ display: flex;
+ flex-direction: column;
+ flex: 0 0 50%;
+ min-width: 200px;
+ border-right: none;
+}
+
+.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;
+ 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;
+ 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;
+ min-height: 0;
+}
+
+.tab-pane { display: none; width: 100%; height: 100%; }
+.tab-pane.active {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* preview-pane image styles moved to #preview-image-wrap img */
+
+#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 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 {
+ 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,
+#bounds-toggle {
+ font-size: 11px;
+ color: #aaa;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-right: 8px;
+ cursor: pointer;
+ user-select: none;
+}
+
+#overlay-toggle:hover,
+#bounds-toggle:hover {
+ color: #ddd;
+}
+
+#overlay-toggle input,
+#bounds-toggle input {
+ cursor: pointer;
+}
+
+.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; }
+
+/* --- 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;
+}
diff --git a/src/FlexRender.Playground/wwwroot/vfs.mjs b/src/FlexRender.Playground/wwwroot/vfs.mjs
new file mode 100644
index 0000000..8bcc6b6
--- /dev/null
+++ b/src/FlexRender.Playground/wwwroot/vfs.mjs
@@ -0,0 +1,175 @@
+// Virtual File System for FlexRender Playground.
+// Purely in-memory Map. Persistence is owned by projects.mjs.
+
+/** @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); }
+ }
+}
+
+// --- Public API ---
+
+/** 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 });
+ notify('add', path);
+}
+
+/** Remove a file (in-memory only). */
+export function removeFile(path) {
+ path = normalizePath(path);
+ if (!files.has(path)) return;
+ files.delete(path);
+ notify('remove', path);
+}
+
+/** 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);
+ 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 (in-memory only). */
+export function clearAll() {
+ files.clear();
+ notify('clear', '');
+}
+
+/**
+ * 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,
+ }));
+}
+
+/**
+ * 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;
+}
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;
+}
diff --git a/src/FlexRender.Skia.Render/Rendering/FontManager.cs b/src/FlexRender.Skia.Render/Rendering/FontManager.cs
index 4cc3c00..ca64553 100644
--- a/src/FlexRender.Skia.Render/Rendering/FontManager.cs
+++ b/src/FlexRender.Skia.Render/Rendering/FontManager.cs
@@ -17,9 +17,30 @@ 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;
+ ///
+ /// 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,28 +135,102 @@ 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);
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())
+ {
+ if (_fontFallbacks.TryGetValue(fontName, out var fallbackName))
+ {
+ 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)
{
- var fallback = SKTypeface.FromFamilyName(fallbackName);
- if (fallback != null)
+ if (_typefaces.TryGetValue(name, out var typeface))
+ return typeface;
+ }
+
+ return null;
+ }
+
+ ///
+ /// 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.
+ // 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))
+ {
+ _fileLoadedTypefaces[name] = 1;
+ }
+ else
+ {
+ typeface.Dispose();
+ }
+ return true;
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
{
- return fallback;
+ // Resource loader failed (e.g. file not found, invalid format), try next
}
}
- // Use default fallback
- return SKTypeface.FromFamilyName(_defaultFallback) ?? SKTypeface.Default;
+ return false;
}
///
@@ -150,13 +245,21 @@ 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.
+ // 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;
+
if (!string.Equals(typeface.FamilyName, familyName, StringComparison.OrdinalIgnoreCase))
{
continue;
@@ -185,19 +288,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
@@ -206,10 +314,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.
@@ -220,32 +329,78 @@ 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.
+ // 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)
{
- 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;
- // System returned an unrelated font; dispose and try sibling scan
- systemMatch.Dispose();
+ // 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();
+ }
}
- // 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;
}
@@ -364,19 +519,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);
@@ -390,16 +545,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.
///
@@ -440,7 +607,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()
@@ -450,23 +618,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();
}
///
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..b1425e1 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);
@@ -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).
@@ -159,7 +168,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 +191,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 +230,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);
@@ -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);
@@ -282,7 +291,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 +339,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 +354,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 +405,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);
@@ -410,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);
@@ -453,7 +463,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);
@@ -468,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);
@@ -507,7 +517,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);
@@ -522,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/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/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);
+ }
+ }
}
}
}
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)
};
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.
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));
+ }
}
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