diff --git a/src/FlexRender.Playground/FlexRender.Playground.csproj b/src/FlexRender.Playground/FlexRender.Playground.csproj
index 8f409b6..20b16b5 100644
--- a/src/FlexRender.Playground/FlexRender.Playground.csproj
+++ b/src/FlexRender.Playground/FlexRender.Playground.csproj
@@ -18,6 +18,8 @@
+
+
diff --git a/src/FlexRender.Playground/PlaygroundApi.cs b/src/FlexRender.Playground/PlaygroundApi.cs
index 5f70f26..40bc9df 100644
--- a/src/FlexRender.Playground/PlaygroundApi.cs
+++ b/src/FlexRender.Playground/PlaygroundApi.cs
@@ -7,6 +7,8 @@
using FlexRender.Layout;
using FlexRender.Parsing;
using FlexRender.Parsing.Ast;
+using FlexRender.Barcode;
+using FlexRender.QrCode;
using FlexRender.Rendering;
using FlexRender.Skia;
using FlexRender.Yaml;
@@ -42,7 +44,7 @@ public static void Initialize()
var builder = new FlexRenderBuilder()
.WithNdc()
- .WithSkia();
+ .WithSkia(skia => skia.WithQr().WithBarcode());
// Insert memory loader at highest priority so uploaded files win
builder.ResourceLoaders.Insert(0, _memoryLoader);
diff --git a/src/FlexRender.Playground/wwwroot/main.js b/src/FlexRender.Playground/wwwroot/main.js
index cebb85e..6740fbc 100644
--- a/src/FlexRender.Playground/wwwroot/main.js
+++ b/src/FlexRender.Playground/wwwroot/main.js
@@ -1135,7 +1135,15 @@ function render() {
switchToTab('preview');
} else {
- statusText.textContent = 'Render returned empty \u2014 check console';
+ const lastError = api.GetLastError();
+ if (lastError) {
+ errorsPane.textContent = lastError;
+ statusBar.classList.add('error');
+ statusText.textContent = 'Error';
+ switchToTab('errors');
+ } else {
+ statusText.textContent = 'Render returned empty';
+ }
}
} catch (e) {
errorsPane.textContent = e.message || String(e);
diff --git a/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json b/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json
index abace48..91c516c 100644
--- a/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json
+++ b/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json
@@ -892,10 +892,89 @@
"enum": ["ndc", "markdown", "html"]
},
"options": {
- "description": "Format-specific rendering options.",
- "type": "object"
+ "description": "Format-specific rendering options. Properties depend on the chosen format.",
+ "oneOf": [
+ { "$ref": "#/definitions/ndcOptions" },
+ { "type": "object", "description": "No options for this format.", "properties": {}, "additionalProperties": false }
+ ]
}
}
+ },
+
+ "charsetStyle": {
+ "description": "Per-charset style configuration for NDC content parser.",
+ "type": "object",
+ "properties": {
+ "font": {
+ "description": "Font registration name (e.g., \"bold\", \"default\").",
+ "type": "string"
+ },
+ "font_family": {
+ "description": "Explicit font family for this charset.",
+ "type": "string"
+ },
+ "font_style": {
+ "description": "Font style.",
+ "type": "string",
+ "enum": ["bold", "italic", "bold-italic"]
+ },
+ "font_size": {
+ "description": "Explicit font size in pixels.",
+ "type": "integer",
+ "minimum": 1
+ },
+ "color": {
+ "description": "Text color (hex format, e.g., \"#333\").",
+ "type": "string"
+ },
+ "encoding": {
+ "description": "Character encoding (e.g., \"none\", \"qwerty-jcuken\").",
+ "type": "string"
+ },
+ "uppercase": {
+ "description": "Convert text to uppercase.",
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "additionalProperties": false
+ },
+
+ "ndcOptions": {
+ "description": "Options for the NDC content parser.",
+ "type": "object",
+ "properties": {
+ "input_encoding": {
+ "description": "Byte encoding for binary data.",
+ "type": "string",
+ "enum": ["latin1", "iso-8859-1", "utf-8", "utf8", "ascii"],
+ "default": "latin1"
+ },
+ "columns": {
+ "description": "Max characters per line (receipt width).",
+ "type": "integer",
+ "minimum": 1,
+ "default": 40
+ },
+ "font_family": {
+ "description": "Font family for all text (e.g., \"JetBrains Mono\").",
+ "type": "string"
+ },
+ "char_width_ratio": {
+ "description": "Character width as fraction of font size for monospace fonts.",
+ "type": "number",
+ "minimum": 0.1,
+ "default": 0.6
+ },
+ "charsets": {
+ "description": "Per-charset style mappings keyed by designator character.",
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/charsetStyle"
+ }
+ }
+ },
+ "additionalProperties": false
}
}
}
diff --git a/src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs b/src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs
index 802985e..4e555ab 100644
--- a/src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs
+++ b/src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs
@@ -127,6 +127,17 @@ export function registerYamlAutocomplete(monaco, schema, options = {}) {
}
case 'template':
return makeSuggestions(monaco, rootProps.template?.properties || {}, range, 'template');
+ case 'content-options': {
+ const formatDef = context.format
+ ? defs[context.format + 'Options']
+ : null;
+ const props = formatDef?.properties || {};
+ return makeSuggestions(monaco, props, range, 'content-options');
+ }
+ case 'content-charset-item': {
+ const props = defs.charsetStyle?.properties || {};
+ return makeSuggestions(monaco, props, range, 'content-charset-item');
+ }
default:
return { suggestions: [] };
}
@@ -149,6 +160,30 @@ function detectContext(text, currentIndent) {
if (lineIndent < currentIndent) {
if (trimmed === 'canvas:') return { type: 'canvas' };
if (trimmed === 'template:') return { type: 'template' };
+
+ // Check for charset designator (single word key, e.g. "I:")
+ const isDesignatorKey = trimmed.match(/^\w+:$/);
+ if (isDesignatorKey) {
+ for (let k = i - 1; k >= 0; k--) {
+ const prev = lines[k];
+ const prevTrimmed = prev.trim();
+ if (!prevTrimmed || prevTrimmed.startsWith('#')) continue;
+ const prevIndent = prev.match(/^(\s*)/)[1].length;
+ if (prevIndent < lineIndent) {
+ if (prevTrimmed === 'charsets:') {
+ return { type: 'content-charset-item' };
+ }
+ break;
+ }
+ }
+ }
+
+ if (trimmed === 'options:') {
+ const parentInfo = findContentParent(lines, i, lineIndent);
+ if (parentInfo) {
+ return { type: 'content-options', format: parentInfo.format };
+ }
+ }
if (trimmed === 'fonts:' || trimmed === '- name:' || trimmed.startsWith('- name:')) {
return { type: 'font-item' };
}
@@ -190,6 +225,32 @@ function detectContext(text, currentIndent) {
return { type: 'unknown' };
}
+function findContentParent(lines, fromIndex, optionsIndent) {
+ let format = null;
+ for (let i = fromIndex - 1; i >= 0; i--) {
+ const line = lines[i];
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#')) continue;
+ const indent = line.match(/^(\s*)/)[1].length;
+ if (indent >= optionsIndent) {
+ const formatMatch = trimmed.match(/^format:\s*(\w+)/);
+ if (formatMatch) format = formatMatch[1];
+ continue;
+ }
+ const typeMatch = trimmed.match(/^-?\s*type:\s*(\w+)/);
+ if (typeMatch) {
+ if (typeMatch[1] === 'content') return { format };
+ return null;
+ }
+ if (indent < optionsIndent) {
+ const formatMatch = trimmed.match(/^-?\s*format:\s*(\w+)/);
+ if (formatMatch) format = formatMatch[1];
+ }
+ if (indent === 0) break;
+ }
+ return null;
+}
+
function suggestValues(monaco, key, textUntilPosition, schema, defs, elementPropsMap, range, options) {
const suggestions = [];
@@ -312,6 +373,16 @@ function findPropertyDef(key, textUntilPosition, schema, defs, elementPropsMap)
}
}
+ // Check if inside content options or charset item
+ const optionsContext = detectContentOptionsContext(lines);
+ if (optionsContext === 'charset-item') {
+ const charsetDef = defs.charsetStyle;
+ if (charsetDef?.properties?.[cleanKey]) return charsetDef.properties[cleanKey];
+ } else if (optionsContext) {
+ const optDef = defs[optionsContext + 'Options'];
+ if (optDef?.properties?.[cleanKey]) return optDef.properties[cleanKey];
+ }
+
// Check flex item properties as fallback
if (defs.flexItemProperties?.properties?.[cleanKey]) {
return defs.flexItemProperties.properties[cleanKey];
@@ -320,6 +391,37 @@ function findPropertyDef(key, textUntilPosition, schema, defs, elementPropsMap)
return null;
}
+function detectContentOptionsContext(lines) {
+ for (let i = lines.length - 2; i >= 0; i--) {
+ const line = lines[i];
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#')) continue;
+ const indent = line.match(/^(\s*)/)[1].length;
+
+ // Check for charset designator pattern
+ if (trimmed.match(/^\w+:$/)) {
+ for (let k = i - 1; k >= 0; k--) {
+ const prev = lines[k];
+ const prevTrimmed = prev.trim();
+ if (!prevTrimmed || prevTrimmed.startsWith('#')) continue;
+ const prevIndent = prev.match(/^(\s*)/)[1].length;
+ if (prevIndent < indent && prevTrimmed === 'charsets:') {
+ return 'charset-item';
+ }
+ if (prevIndent < indent) break;
+ }
+ }
+
+ if (trimmed === 'options:') {
+ const parentInfo = findContentParent(lines, i, indent);
+ return parentInfo?.format || null;
+ }
+
+ if (indent === 0) break;
+ }
+ return null;
+}
+
function makeSuggestions(monaco, properties, range, context) {
const suggestions = [];
for (const [key, def] of Object.entries(properties)) {
@@ -367,6 +469,8 @@ function getSortOrder(key, context) {
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' },
+ 'content-options': { input_encoding: '0', columns: '1', font_family: '2', char_width_ratio: '3', charsets: '4' },
+ 'content-charset-item': { font: '0', font_family: '1', font_style: '2', font_size: '3', color: '4', encoding: '5', uppercase: '6' },
};
return priority[context]?.[key] || '5';
}