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'; }