template engine

This commit is contained in:
EugeneTes
2026-02-03 14:27:13 +01:00
parent 46b648d753
commit ec5decb12e
31 changed files with 4640 additions and 946 deletions

View File

@@ -0,0 +1,123 @@
using System.Text.Json;
namespace Inspectron.Epson.TemplateEngine.DataBinding;
public class DataContext
{
private readonly JsonElement _root;
private readonly DataContext? _parent;
private readonly Dictionary<string, JsonElement> _variables = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, object> _loopVariables = new(StringComparer.OrdinalIgnoreCase);
public DataContext(JsonElement root)
{
_root = root;
}
private DataContext(JsonElement root, DataContext parent)
{
_root = root;
_parent = parent;
}
public DataContext CreateChildScope()
{
return new DataContext(_root, this);
}
public void SetVariable(string name, JsonElement value)
{
_variables[name] = value;
}
public void SetLoopVariable(string name, object value)
{
_loopVariables[name] = value;
}
public object? GetLoopVariable(string name)
{
if (_loopVariables.TryGetValue(name, out var value))
return value;
return _parent?.GetLoopVariable(name);
}
public JsonElement? Resolve(string expression)
{
if (string.IsNullOrWhiteSpace(expression))
return null;
var parts = expression.Split('.');
var firstPart = parts[0];
// Check local variables first
JsonElement? current = null;
if (_variables.TryGetValue(firstPart, out var varValue))
{
current = varValue;
}
else if (_parent != null)
{
// Walk up scope chain for variables
var ctx = _parent;
while (ctx != null)
{
if (ctx._variables.TryGetValue(firstPart, out var parentVar))
{
current = parentVar;
break;
}
ctx = ctx._parent;
}
}
// If not found in variables, try root
if (current == null)
{
current = GetProperty(_root, firstPart);
}
if (current == null)
return null;
// Navigate remaining parts
for (int i = 1; i < parts.Length; i++)
{
current = GetProperty(current.Value, parts[i]);
if (current == null)
return null;
}
return current;
}
private static JsonElement? GetProperty(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object)
return null;
// Case-insensitive property lookup
foreach (var prop in element.EnumerateObject())
{
if (string.Equals(prop.Name, propertyName, StringComparison.OrdinalIgnoreCase))
return prop.Value;
}
return null;
}
public static string GetStringValue(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString() ?? "",
JsonValueKind.Number => element.TryGetInt64(out var l) ? l.ToString() : element.GetDouble().ToString(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => "",
JsonValueKind.Undefined => "",
_ => element.GetRawText()
};
}
}

View File

@@ -0,0 +1,218 @@
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Inspectron.Epson.TemplateEngine.DataBinding;
public class ExpressionEvaluator
{
private static readonly Regex ExpressionPattern = new(@"\{\{(.+?)\}\}", RegexOptions.Compiled);
private readonly DataContext _context;
public ExpressionEvaluator(DataContext context)
{
_context = context;
}
public string Evaluate(string template)
{
if (string.IsNullOrEmpty(template))
return "";
return ExpressionPattern.Replace(template, match =>
{
var expression = match.Groups[1].Value.Trim();
return ResolveExpression(expression);
});
}
private string ResolveExpression(string expression)
{
// Loop variables: $index, $first, $last
if (expression.StartsWith("$"))
{
var loopVar = _context.GetLoopVariable(expression);
return loopVar?.ToString() ?? "";
}
// Format string: PropertyName:format
string? format = null;
var colonIndex = expression.IndexOf(':');
if (colonIndex > 0)
{
format = expression[(colonIndex + 1)..];
expression = expression[..colonIndex];
}
var element = _context.Resolve(expression);
if (element == null)
return "";
if (format != null)
return FormatValue(element.Value, format);
return DataContext.GetStringValue(element.Value);
}
private static string FormatValue(JsonElement element, string format)
{
if (element.ValueKind == JsonValueKind.String)
{
var str = element.GetString();
if (str != null && DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt))
{
return dt.ToString(format, CultureInfo.InvariantCulture);
}
return str ?? "";
}
if (element.ValueKind == JsonValueKind.Number)
{
if (element.TryGetDecimal(out var d))
return d.ToString(format, CultureInfo.InvariantCulture);
}
return DataContext.GetStringValue(element);
}
public bool EvaluateCondition(string test)
{
if (string.IsNullOrWhiteSpace(test))
return false;
// Negation
bool negate = false;
var expr = test.Trim();
if (expr.StartsWith("!"))
{
negate = true;
expr = expr[1..].Trim();
}
// Loop variables: $first, $last
if (expr.StartsWith("$"))
{
var loopVar = _context.GetLoopVariable(expr);
bool loopResult = loopVar is bool b ? b : loopVar != null;
return negate ? !loopResult : loopResult;
}
// Comparison operators
var comparisonOps = new[] { "==", "!=", ">=", "<=", ">", "<" };
foreach (var op in comparisonOps)
{
var parts = SplitComparison(expr, op);
if (parts != null)
{
bool compResult = EvaluateComparison(parts.Value.left, op, parts.Value.right);
return negate ? !compResult : compResult;
}
}
// Truthiness check - property exists and has a value
var result = IsTruthy(expr);
return negate ? !result : result;
}
private (string left, string right)? SplitComparison(string expr, string op)
{
var idx = expr.IndexOf(op, StringComparison.Ordinal);
if (idx < 0) return null;
// Make sure we don't confuse == with = or != with !
if (op == "=" && idx > 0 && expr[idx - 1] == '!') return null;
if (op == ">" && idx > 0 && (expr[idx - 1] == '>' || expr[idx - 1] == '<')) return null;
var left = expr[..idx].Trim();
var right = expr[(idx + op.Length)..].Trim();
if (string.IsNullOrEmpty(left) || string.IsNullOrEmpty(right))
return null;
return (left, right);
}
private bool EvaluateComparison(string left, string op, string right)
{
var leftVal = ResolveComparisonValue(left);
var rightVal = ResolveComparisonValue(right);
// Try numeric comparison
if (decimal.TryParse(leftVal, CultureInfo.InvariantCulture, out var leftNum)
&& decimal.TryParse(rightVal, CultureInfo.InvariantCulture, out var rightNum))
{
return op switch
{
"==" => leftNum == rightNum,
"!=" => leftNum != rightNum,
">" => leftNum > rightNum,
"<" => leftNum < rightNum,
">=" => leftNum >= rightNum,
"<=" => leftNum <= rightNum,
_ => false
};
}
// String comparison
int cmp = string.Compare(leftVal, rightVal, StringComparison.OrdinalIgnoreCase);
return op switch
{
"==" => cmp == 0,
"!=" => cmp != 0,
">" => cmp > 0,
"<" => cmp < 0,
">=" => cmp >= 0,
"<=" => cmp <= 0,
_ => false
};
}
private string ResolveComparisonValue(string value)
{
// Quoted string literal
if ((value.StartsWith("'") && value.EndsWith("'")) ||
(value.StartsWith("\"") && value.EndsWith("\"")))
{
return value[1..^1];
}
// Numeric literal
if (decimal.TryParse(value, CultureInfo.InvariantCulture, out _))
{
return value;
}
// Loop variable
if (value.StartsWith("$"))
{
var loopVar = _context.GetLoopVariable(value);
return loopVar?.ToString() ?? "";
}
// Property resolution
var element = _context.Resolve(value);
if (element == null)
return "";
return DataContext.GetStringValue(element.Value);
}
private bool IsTruthy(string expression)
{
var element = _context.Resolve(expression);
if (element == null)
return false;
return element.Value.ValueKind switch
{
JsonValueKind.Null => false,
JsonValueKind.Undefined => false,
JsonValueKind.False => false,
JsonValueKind.String => !string.IsNullOrEmpty(element.Value.GetString()),
JsonValueKind.Number => element.Value.GetDouble() != 0,
JsonValueKind.Array => element.Value.GetArrayLength() > 0,
_ => true
};
}
}

View File

@@ -0,0 +1,13 @@
namespace Inspectron.Epson.TemplateEngine;
public class TemplateParsingException : Exception
{
public TemplateParsingException(string message) : base(message) { }
public TemplateParsingException(string message, Exception innerException) : base(message, innerException) { }
}
public class TemplateRenderingException : Exception
{
public TemplateRenderingException(string message) : base(message) { }
public TemplateRenderingException(string message, Exception innerException) : base(message, innerException) { }
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Inspectron.Epson\Inspectron.Epson.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,84 @@
namespace Inspectron.Epson.TemplateEngine.Parsing;
public abstract class TemplateNode
{
public List<TemplateNode> Children { get; set; } = new();
}
public class ReceiptNode : TemplateNode { }
public class LineNode : TemplateNode
{
public string? Text { get; set; }
public bool Bold { get; set; }
public bool Big { get; set; }
public bool Tall { get; set; }
public bool Red { get; set; }
public string Align { get; set; } = "left";
public int? LineSpacing { get; set; }
public bool Wrap { get; set; }
public int WrapIndent { get; set; }
}
public class ColumnsNode : TemplateNode
{
public string? Left { get; set; }
public string? Right { get; set; }
public bool Bold { get; set; }
public bool Big { get; set; }
public bool Tall { get; set; }
public bool Red { get; set; }
public int? LineSpacing { get; set; }
public bool Wrap { get; set; }
public int WrapIndent { get; set; }
}
public class RowNode : TemplateNode
{
public List<ColumnDef> Columns { get; set; } = new();
public bool Bold { get; set; }
public bool Big { get; set; }
public bool Tall { get; set; }
public bool Red { get; set; }
public int? LineSpacing { get; set; }
}
public class ColumnDef
{
public string? Text { get; set; }
public int? Width { get; set; }
public string Align { get; set; } = "left";
}
public class SeparatorNode : TemplateNode
{
public char Character { get; set; } = '-';
}
public class CutNode : TemplateNode { }
public class FeedNode : TemplateNode
{
public int Lines { get; set; } = 1;
}
public class ForeachNode : TemplateNode
{
public string Items { get; set; } = "";
public string Var { get; set; } = "";
}
public class IfNode : TemplateNode
{
public string Test { get; set; } = "";
}
public class ElseNode : TemplateNode { }
public class TableNode : TemplateNode
{
public List<ColumnDef> Columns { get; set; } = new();
public string? HeaderItems { get; set; }
public string? Items { get; set; }
public string? Var { get; set; }
}

View File

@@ -0,0 +1,250 @@
using System.Xml.Linq;
namespace Inspectron.Epson.TemplateEngine.Parsing;
public class TemplateParser
{
public ReceiptNode Parse(string xml)
{
XDocument doc;
try
{
doc = XDocument.Parse(xml);
}
catch (Exception ex)
{
throw new TemplateParsingException($"Invalid XML: {ex.Message}", ex);
}
var root = doc.Root;
if (root == null || root.Name.LocalName != "receipt")
throw new TemplateParsingException("Root element must be <receipt>");
var receiptNode = new ReceiptNode();
ParseChildren(root, receiptNode.Children);
return receiptNode;
}
private void ParseChildren(XElement parent, List<TemplateNode> children)
{
foreach (var element in parent.Elements())
{
var node = ParseElement(element);
if (node != null)
children.Add(node);
}
}
private TemplateNode? ParseElement(XElement element)
{
return element.Name.LocalName switch
{
"line" => ParseLine(element),
"columns" => ParseColumns(element),
"row" => ParseRow(element),
"separator" => ParseSeparator(element),
"cut" => new CutNode(),
"feed" => ParseFeed(element),
"foreach" => ParseForeach(element),
"if" => ParseIf(element),
"else" => ParseElse(element),
"table" => ParseTable(element),
_ => throw new TemplateParsingException($"Unknown element: <{element.Name.LocalName}>")
};
}
private LineNode ParseLine(XElement element)
{
var node = new LineNode();
ApplyFormatting(element, node);
// Text content: either inner text (with {{}} expressions) or empty line
var text = GetTextContent(element);
node.Text = text;
node.Align = GetAttr(element, "align", "left");
node.Wrap = GetBoolAttr(element, "wrap");
node.WrapIndent = GetIntAttr(element, "wrapIndent", 0);
return node;
}
private ColumnsNode ParseColumns(XElement element)
{
var node = new ColumnsNode();
ApplyFormatting(element, node);
node.Left = GetAttr(element, "left", "");
node.Right = GetAttr(element, "right", "");
node.Wrap = GetBoolAttr(element, "wrap");
node.WrapIndent = GetIntAttr(element, "wrapIndent", 0);
return node;
}
private RowNode ParseRow(XElement element)
{
var node = new RowNode();
ApplyFormatting(element, node);
foreach (var colElement in element.Elements("col"))
{
var col = new ColumnDef
{
Text = GetTextContent(colElement),
Width = GetNullableIntAttr(colElement, "width"),
Align = GetAttr(colElement, "align", "left")
};
node.Columns.Add(col);
}
return node;
}
private SeparatorNode ParseSeparator(XElement element)
{
var charAttr = GetAttr(element, "char", "-");
return new SeparatorNode
{
Character = string.IsNullOrEmpty(charAttr) ? '-' : charAttr[0]
};
}
private FeedNode ParseFeed(XElement element)
{
return new FeedNode
{
Lines = GetIntAttr(element, "lines", 1)
};
}
private ForeachNode ParseForeach(XElement element)
{
var items = GetRequiredAttr(element, "items", "foreach")!;
var var_ = GetRequiredAttr(element, "var", "foreach")!;
var node = new ForeachNode { Items = items, Var = var_ };
ParseChildren(element, node.Children);
return node;
}
private IfNode ParseIf(XElement element)
{
var test = GetRequiredAttr(element, "test", "if")!;
var node = new IfNode { Test = test };
ParseChildren(element, node.Children);
return node;
}
private ElseNode ParseElse(XElement element)
{
var node = new ElseNode();
ParseChildren(element, node.Children);
return node;
}
private TableNode ParseTable(XElement element)
{
var node = new TableNode
{
Items = GetAttr(element, "items", null),
Var = GetAttr(element, "var", null),
HeaderItems = GetAttr(element, "headerItems", null)
};
foreach (var colElement in element.Elements("col"))
{
var col = new ColumnDef
{
Text = GetTextContent(colElement),
Width = GetNullableIntAttr(colElement, "width"),
Align = GetAttr(colElement, "align", "left")
};
node.Columns.Add(col);
}
// Parse non-col children (col elements are consumed above as column definitions)
foreach (var child in element.Elements().Where(e => e.Name.LocalName != "col"))
{
var childNode = ParseElement(child);
if (childNode != null)
node.Children.Add(childNode);
}
return node;
}
private static void ApplyFormatting(XElement element, LineNode node)
{
node.Bold = GetBoolAttr(element, "bold");
node.Big = GetBoolAttr(element, "big");
node.Tall = GetBoolAttr(element, "tall");
node.Red = GetBoolAttr(element, "red");
node.LineSpacing = GetNullableIntAttr(element, "lineSpacing");
}
private static void ApplyFormatting(XElement element, ColumnsNode node)
{
node.Bold = GetBoolAttr(element, "bold");
node.Big = GetBoolAttr(element, "big");
node.Tall = GetBoolAttr(element, "tall");
node.Red = GetBoolAttr(element, "red");
node.LineSpacing = GetNullableIntAttr(element, "lineSpacing");
}
private static void ApplyFormatting(XElement element, RowNode node)
{
node.Bold = GetBoolAttr(element, "bold");
node.Big = GetBoolAttr(element, "big");
node.Tall = GetBoolAttr(element, "tall");
node.Red = GetBoolAttr(element, "red");
node.LineSpacing = GetNullableIntAttr(element, "lineSpacing");
}
private static string GetTextContent(XElement element)
{
// Get all inner text content (may include {{}} expressions)
// Use element.Value to get concatenated text of all text nodes
if (!element.HasElements)
return element.Value;
// If element has child elements, only get direct text nodes
return string.Concat(element.Nodes().OfType<XText>().Select(t => t.Value));
}
private static string? GetRequiredAttr(XElement element, string name, string elementName)
{
var attr = element.Attribute(name);
if (attr == null)
throw new TemplateParsingException($"<{elementName}> requires '{name}' attribute");
return attr.Value;
}
private static string GetAttr(XElement element, string name, string? defaultValue)
{
var attr = element.Attribute(name);
return attr?.Value ?? defaultValue ?? "";
}
private static bool GetBoolAttr(XElement element, string name)
{
var attr = element.Attribute(name);
if (attr == null) return false;
return attr.Value.Equals("true", StringComparison.OrdinalIgnoreCase);
}
private static int GetIntAttr(XElement element, string name, int defaultValue)
{
var attr = element.Attribute(name);
if (attr == null) return defaultValue;
return int.TryParse(attr.Value, out var v) ? v : defaultValue;
}
private static int? GetNullableIntAttr(XElement element, string name)
{
var attr = element.Attribute(name);
if (attr == null) return null;
return int.TryParse(attr.Value, out var v) ? v : null;
}
}

View File

@@ -0,0 +1,38 @@
using System.Text.Json;
using Inspectron.Epson.PrintServer.Printers.Utils;
using Inspectron.Epson.TemplateEngine.DataBinding;
using Inspectron.Epson.TemplateEngine.Parsing;
using Inspectron.Epson.TemplateEngine.Rendering;
namespace Inspectron.Epson.TemplateEngine;
public class ReceiptTemplateEngine
{
public List<PrintCommand> Render(
string xmlTemplate,
string jsonData,
int lineWidth = 48,
int bigFontLineWidth = 24)
{
// Parse XML template into node tree
var parser = new TemplateParser();
var receiptNode = parser.Parse(xmlTemplate);
// Parse JSON data into DataContext
JsonElement root;
try
{
root = JsonDocument.Parse(jsonData).RootElement;
}
catch (JsonException ex)
{
throw new TemplateRenderingException($"Invalid JSON data: {ex.Message}", ex);
}
var context = new DataContext(root);
// Render node tree to PrintCommands
var renderer = new TemplateRenderer(lineWidth, bigFontLineWidth);
return renderer.Render(receiptNode, context);
}
}

View File

@@ -0,0 +1,275 @@
namespace Inspectron.Epson.TemplateEngine.Rendering;
public class LayoutEngine
{
public string AlignText(string text, string alignment, int lineWidth)
{
if (string.IsNullOrEmpty(text))
return text ?? "";
if (text.Length >= lineWidth)
return text;
return alignment.ToLowerInvariant() switch
{
"center" => CenterText(text, lineWidth),
"right" => text.PadLeft(lineWidth),
_ => text // left-aligned is default (no padding)
};
}
private static string CenterText(string text, int lineWidth)
{
int totalPadding = lineWidth - text.Length;
int leftPadding = totalPadding / 2;
return new string(' ', leftPadding) + text;
}
public string FormatTwoColumns(string left, string right, int lineWidth)
{
left ??= "";
right ??= "";
int halfWidth = lineWidth / 2;
return left.PadRight(halfWidth) + right.PadLeft(lineWidth - halfWidth);
}
public List<string> FormatTwoColumnsWithWrap(string left, string right, int lineWidth)
{
left ??= "";
right ??= "";
int halfWidth = lineWidth / 2;
// Try simple format first
if (left.Length + right.Length <= lineWidth)
{
int spaces = lineWidth - left.Length - right.Length;
if (spaces < 1) spaces = 1;
return new List<string> { left + new string(' ', spaces) + right };
}
// Wrap left side, right-align right on first line
var leftLines = WrapText(left, halfWidth);
var result = new List<string>();
for (int i = 0; i < leftLines.Count; i++)
{
if (i == 0)
{
int spaces = lineWidth - leftLines[i].Length - right.Length;
if (spaces < 1)
{
result.Add(leftLines[i]);
result.Add(right.PadLeft(lineWidth));
}
else
{
result.Add(leftLines[i] + new string(' ', spaces) + right);
}
}
else
{
result.Add(leftLines[i]);
}
}
return result;
}
public string FormatMultiColumn(List<(string text, int width, string align)> columns, int lineWidth)
{
var parts = new List<string>();
// Calculate widths: distribute remaining width among columns without explicit width
int totalExplicit = columns.Where(c => c.width > 0).Sum(c => c.width);
int unspecifiedCount = columns.Count(c => c.width <= 0);
int remaining = lineWidth - totalExplicit;
int defaultWidth = unspecifiedCount > 0 ? remaining / unspecifiedCount : 0;
foreach (var (text, width, align) in columns)
{
int colWidth = width > 0 ? width : defaultWidth;
if (colWidth <= 0) colWidth = 1;
string formatted = align.ToLowerInvariant() switch
{
"center" => CenterInWidth(text ?? "", colWidth),
"right" => (text ?? "").PadLeft(colWidth),
_ => (text ?? "").PadRight(colWidth)
};
// Truncate if too long
if (formatted.Length > colWidth)
formatted = formatted[..colWidth];
parts.Add(formatted);
}
return string.Concat(parts);
}
public List<string> WrapText(string text, int maxWidth, int indent = 0)
{
var lines = new List<string>();
if (string.IsNullOrEmpty(text) || maxWidth <= 0)
{
lines.Add(text ?? "");
return lines;
}
if (text.Length <= maxWidth)
{
lines.Add(text);
return lines;
}
var words = text.Split(' ');
string currentLine = "";
bool isFirst = true;
foreach (var word in words)
{
int available = isFirst ? maxWidth : maxWidth - indent;
if (available <= 0) available = 1;
if (currentLine.Length == 0)
{
currentLine = word;
}
else if (currentLine.Length + 1 + word.Length <= available)
{
currentLine += " " + word;
}
else
{
if (isFirst)
{
lines.Add(currentLine);
isFirst = false;
}
else
{
lines.Add(new string(' ', indent) + currentLine);
}
currentLine = word;
}
}
if (currentLine.Length > 0)
{
if (isFirst)
lines.Add(currentLine);
else
lines.Add(new string(' ', indent) + currentLine);
}
return lines;
}
public string CreateSeparator(char character, int lineWidth)
{
return new string(character, lineWidth);
}
public List<string> FormatTable(
List<(string text, int width, string align)> columns,
List<List<string>> headerRows,
List<List<string>> dataRows,
int lineWidth)
{
var result = new List<string>();
// Calculate column widths
var colWidths = CalculateTableColumnWidths(columns, lineWidth);
// Top border
result.Add(FormatTableBorder(colWidths));
// Header rows
foreach (var row in headerRows)
{
result.Add(FormatTableRow(row, colWidths, columns));
}
// Separator between header and data
if (headerRows.Count > 0 && dataRows.Count > 0)
{
result.Add(FormatTableBorder(colWidths));
}
// Data rows
foreach (var row in dataRows)
{
result.Add(FormatTableRow(row, colWidths, columns));
}
// Bottom border
result.Add(FormatTableBorder(colWidths));
return result;
}
private List<int> CalculateTableColumnWidths(List<(string text, int width, string align)> columns, int lineWidth)
{
// Account for borders: |col1|col2|col3| = columns.Count + 1 border chars
int availableWidth = lineWidth - columns.Count - 1;
int totalExplicit = columns.Where(c => c.width > 0).Sum(c => c.width);
int unspecifiedCount = columns.Count(c => c.width <= 0);
int remaining = availableWidth - totalExplicit;
int defaultWidth = unspecifiedCount > 0 ? remaining / unspecifiedCount : 0;
var widths = new List<int>();
foreach (var (_, width, _) in columns)
{
widths.Add(width > 0 ? width : Math.Max(defaultWidth, 1));
}
return widths;
}
private string FormatTableBorder(List<int> colWidths)
{
return "+" + string.Join("+", colWidths.Select(w => new string('-', w))) + "+";
}
private string FormatTableRow(List<string> cells, List<int> colWidths, List<(string text, int width, string align)> columns)
{
var parts = new List<string>();
for (int i = 0; i < colWidths.Count; i++)
{
string cell = i < cells.Count ? cells[i] : "";
string align = i < columns.Count ? columns[i].align : "left";
parts.Add(FormatCellContent(cell, colWidths[i], align));
}
return "|" + string.Join("|", parts) + "|";
}
private string FormatCellContent(string text, int width, string align)
{
if (text.Length > width)
text = text[..width];
return align.ToLowerInvariant() switch
{
"center" => CenterInWidth(text, width),
"right" => text.PadLeft(width),
_ => text.PadRight(width)
};
}
private static string CenterInWidth(string text, int width)
{
if (string.IsNullOrEmpty(text))
return new string(' ', width);
if (text.Length >= width)
return text[..width];
int totalPadding = width - text.Length;
int leftPadding = totalPadding / 2;
int rightPadding = totalPadding - leftPadding;
return new string(' ', leftPadding) + text + new string(' ', rightPadding);
}
}

View File

@@ -0,0 +1,286 @@
using System.Text.Json;
using Inspectron.Epson.PrintServer.Printers.Utils;
using Inspectron.Epson.TemplateEngine.DataBinding;
using Inspectron.Epson.TemplateEngine.Parsing;
namespace Inspectron.Epson.TemplateEngine.Rendering;
public class TemplateRenderer
{
private readonly int _lineWidth;
private readonly int _bigFontLineWidth;
private readonly LayoutEngine _layout = new();
public TemplateRenderer(int lineWidth, int bigFontLineWidth)
{
_lineWidth = lineWidth;
_bigFontLineWidth = bigFontLineWidth;
}
public List<PrintCommand> Render(ReceiptNode receipt, DataContext context)
{
var commands = new List<PrintCommand>();
RenderChildren(receipt.Children, context, commands);
return commands;
}
private void RenderChildren(List<TemplateNode> children, DataContext context, List<PrintCommand> commands)
{
for (int i = 0; i < children.Count; i++)
{
var node = children[i];
if (node is IfNode ifNode)
{
var evaluator = new ExpressionEvaluator(context);
bool condition = evaluator.EvaluateCondition(ifNode.Test);
if (condition)
{
RenderChildren(ifNode.Children, context, commands);
// Skip following else
if (i + 1 < children.Count && children[i + 1] is ElseNode)
i++;
}
else
{
// Check for following else
if (i + 1 < children.Count && children[i + 1] is ElseNode elseNode)
{
RenderChildren(elseNode.Children, context, commands);
i++;
}
}
}
else
{
RenderNode(node, context, commands);
}
}
}
private void RenderNode(TemplateNode node, DataContext context, List<PrintCommand> commands)
{
switch (node)
{
case LineNode line:
RenderLine(line, context, commands);
break;
case ColumnsNode columns:
RenderColumns(columns, context, commands);
break;
case RowNode row:
RenderRow(row, context, commands);
break;
case SeparatorNode separator:
RenderSeparator(separator, commands);
break;
case CutNode:
commands.Add(new PrintCommand("") { IsCut = true });
break;
case FeedNode feed:
RenderFeed(feed, commands);
break;
case ForeachNode foreachNode:
RenderForeach(foreachNode, context, commands);
break;
case TableNode table:
RenderTable(table, context, commands);
break;
case ElseNode:
// Handled by IfNode processing in RenderChildren
break;
}
}
private void RenderLine(LineNode line, DataContext context, List<PrintCommand> commands)
{
var evaluator = new ExpressionEvaluator(context);
string text = evaluator.Evaluate(line.Text ?? "");
int effectiveWidth = line.Big ? _bigFontLineWidth : _lineWidth;
if (line.Wrap && text.Length > effectiveWidth)
{
var wrappedLines = _layout.WrapText(text, effectiveWidth, line.WrapIndent);
foreach (var wrappedLine in wrappedLines)
{
var cmd = CreateCommand(wrappedLine, line.Bold, line.Big, line.Tall, line.Red, line.LineSpacing);
commands.Add(cmd);
}
}
else
{
text = _layout.AlignText(text, line.Align, effectiveWidth);
var cmd = CreateCommand(text, line.Bold, line.Big, line.Tall, line.Red, line.LineSpacing);
commands.Add(cmd);
}
}
private void RenderColumns(ColumnsNode columns, DataContext context, List<PrintCommand> commands)
{
var evaluator = new ExpressionEvaluator(context);
string left = evaluator.Evaluate(columns.Left ?? "");
string right = evaluator.Evaluate(columns.Right ?? "");
int effectiveWidth = columns.Big ? _bigFontLineWidth : _lineWidth;
if (columns.Wrap)
{
var lines = _layout.FormatTwoColumnsWithWrap(left, right, effectiveWidth);
foreach (var line in lines)
{
var cmd = CreateCommand(line, columns.Bold, columns.Big, columns.Tall, columns.Red, columns.LineSpacing);
commands.Add(cmd);
}
}
else
{
string text = _layout.FormatTwoColumns(left, right, effectiveWidth);
var cmd = CreateCommand(text, columns.Bold, columns.Big, columns.Tall, columns.Red, columns.LineSpacing);
commands.Add(cmd);
}
}
private void RenderRow(RowNode row, DataContext context, List<PrintCommand> commands)
{
var evaluator = new ExpressionEvaluator(context);
int effectiveWidth = row.Big ? _bigFontLineWidth : _lineWidth;
var columnData = row.Columns.Select(c => (
text: evaluator.Evaluate(c.Text ?? ""),
width: c.Width ?? 0,
align: c.Align
)).ToList();
string text = _layout.FormatMultiColumn(columnData, effectiveWidth);
var cmd = CreateCommand(text, row.Bold, row.Big, row.Tall, row.Red, row.LineSpacing);
commands.Add(cmd);
}
private void RenderSeparator(SeparatorNode separator, List<PrintCommand> commands)
{
string text = _layout.CreateSeparator(separator.Character, _lineWidth);
commands.Add(new PrintCommand(text));
}
private void RenderFeed(FeedNode feed, List<PrintCommand> commands)
{
for (int i = 0; i < feed.Lines; i++)
{
commands.Add(new PrintCommand(""));
}
}
private void RenderForeach(ForeachNode foreachNode, DataContext context, List<PrintCommand> commands)
{
var evaluator = new ExpressionEvaluator(context);
// Resolve the items collection
var itemsElement = context.Resolve(foreachNode.Items);
if (itemsElement == null || itemsElement.Value.ValueKind != JsonValueKind.Array)
return;
var array = itemsElement.Value;
int count = array.GetArrayLength();
for (int i = 0; i < count; i++)
{
var item = array[i];
var childContext = context.CreateChildScope();
childContext.SetVariable(foreachNode.Var, item);
childContext.SetLoopVariable("$index", i);
childContext.SetLoopVariable("$first", i == 0);
childContext.SetLoopVariable("$last", i == count - 1);
RenderChildren(foreachNode.Children, childContext, commands);
}
}
private void RenderTable(TableNode table, DataContext context, List<PrintCommand> commands)
{
var evaluator = new ExpressionEvaluator(context);
var columnDefs = table.Columns.Select(c => (
text: evaluator.Evaluate(c.Text ?? ""),
width: c.Width ?? 0,
align: c.Align
)).ToList();
// Header rows from headerItems or column text
var headerRows = new List<List<string>>();
if (!string.IsNullOrEmpty(table.HeaderItems))
{
var headerArray = context.Resolve(table.HeaderItems);
if (headerArray != null && headerArray.Value.ValueKind == JsonValueKind.Array)
{
foreach (var headerItem in headerArray.Value.EnumerateArray())
{
var row = new List<string>();
foreach (var col in table.Columns)
{
var childContext = context.CreateChildScope();
childContext.SetVariable(table.Var ?? "item", headerItem);
var childEval = new ExpressionEvaluator(childContext);
row.Add(childEval.Evaluate(col.Text ?? ""));
}
headerRows.Add(row);
}
}
}
else if (columnDefs.Any(c => !string.IsNullOrEmpty(c.text)))
{
headerRows.Add(columnDefs.Select(c => c.text).ToList());
}
// Data rows
var dataRows = new List<List<string>>();
if (!string.IsNullOrEmpty(table.Items))
{
var itemsArray = context.Resolve(table.Items);
if (itemsArray != null && itemsArray.Value.ValueKind == JsonValueKind.Array)
{
foreach (var dataItem in itemsArray.Value.EnumerateArray())
{
var childContext = context.CreateChildScope();
childContext.SetVariable(table.Var ?? "item", dataItem);
var childEval = new ExpressionEvaluator(childContext);
var row = new List<string>();
foreach (var child in table.Children)
{
if (child is LineNode lineChild)
{
row.Add(childEval.Evaluate(lineChild.Text ?? ""));
}
}
// If no line children, use column defs
if (row.Count == 0)
{
foreach (var col in table.Columns)
{
row.Add(childEval.Evaluate(col.Text ?? ""));
}
}
dataRows.Add(row);
}
}
}
var lines = _layout.FormatTable(columnDefs, headerRows, dataRows, _lineWidth);
foreach (var line in lines)
{
commands.Add(new PrintCommand(line));
}
}
private static PrintCommand CreateCommand(string text, bool bold, bool big, bool tall, bool red, int? lineSpacing)
{
return new PrintCommand(text, isBig: big, isBold: bold)
{
IsTall = tall,
IsRed = red,
SetLineSpacing = lineSpacing
};
}
}

View File

@@ -0,0 +1,642 @@
# XML Receipt Template Syntax
## Overview
Templates are XML documents that combine static text, data binding expressions, and control flow to produce a list of `PrintCommand` objects for Epson thermal receipt printers.
```csharp
var engine = new ReceiptTemplateEngine();
List<PrintCommand> commands = engine.Render(
xmlTemplate, // XML template string
jsonData, // JSON data string
lineWidth: 42, // characters per line (normal font)
bigFontLineWidth: 22 // characters per line (big font)
);
```
---
## Root Element
### `<receipt>`
Required root element. All other elements must be nested inside it.
```xml
<receipt>
<!-- template content here -->
</receipt>
```
---
## Content Elements
### `<line>`
Outputs a single line of text. The most common element.
**Attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `align` | `left` / `center` / `right` | `left` | Text alignment within the line width |
| `bold` | `true` / `false` | `false` | Bold text |
| `big` | `true` / `false` | `false` | Double-width font (uses `bigFontLineWidth` for alignment) |
| `tall` | `true` / `false` | `false` | Double-height font |
| `red` | `true` / `false` | `false` | Red text (on supported printers) |
| `lineSpacing` | int | _(none)_ | Override line spacing in dots |
| `wrap` | `true` / `false` | `false` | Word-wrap text that exceeds line width |
| `wrapIndent` | int | `0` | Indent (in characters) for continuation lines when wrapping |
**Examples:**
```xml
<!-- Simple text -->
<line>Hello World</line>
<!-- Empty line -->
<line />
<!-- Centered bold header -->
<line align="center" bold="true">RECEIPT</line>
<!-- Big red title -->
<line align="center" big="true" tall="true" red="true">{{Title}}</line>
<!-- Right-aligned -->
<line align="right">---------</line>
<!-- Long text with word wrap -->
<line wrap="true">{{dish.Number}}x {{dish.Name}}</line>
<!-- Wrap with indent for continuation lines -->
<line wrap="true" wrapIndent="4">1x Very Long Dish Name That Will Wrap To Next Line</line>
<!-- Output:
1x Very Long Dish Name That
Will Wrap To Next Line -->
<!-- Custom line spacing -->
<line lineSpacing="50">Spaced out text</line>
```
When `big="true"`, alignment uses `bigFontLineWidth` instead of `lineWidth`.
---
### `<columns>`
Two-column layout: left-aligned left text, right-aligned right text. The line width is split in half.
**Attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `left` | string | `""` | Left column content (supports `{{}}` expressions) |
| `right` | string | `""` | Right column content (supports `{{}}` expressions) |
| `bold` | `true` / `false` | `false` | Bold text |
| `big` | `true` / `false` | `false` | Double-width font |
| `tall` | `true` / `false` | `false` | Double-height font |
| `red` | `true` / `false` | `false` | Red text |
| `lineSpacing` | int | _(none)_ | Override line spacing |
| `wrap` | `true` / `false` | `false` | Wrap left column if combined text exceeds line width |
| `wrapIndent` | int | `0` | Indent for wrapped continuation lines |
**Examples:**
```xml
<!-- Label-value pair -->
<columns left="Tisch:" right="{{TableNumber}}" />
<!-- Bold receipt info -->
<columns left="Rechnung Nr. {{ReceiptNumber}}" right="{{DateTime:HH:mm dd.MM.yyyy}}" bold="true" />
<!-- Price line with wrapping for long descriptions -->
<columns left="{{item.Quantity}}x {{item.Description}}" right="{{item.PriceDisplay}}" wrap="true" />
<!-- Payment line -->
<columns left="{{PaymentMethod}}" right="{{PaymentAmount:F2}} {{Currency}}" bold="true" />
```
Without `wrap`, the left column is padded to half the line width and the right column is right-padded to fill the remaining space. With `wrap="true"`, if the combined text exceeds the line width, the left column wraps and the right column appears right-aligned on the first line.
---
### `<row>`
Multi-column layout with explicit column definitions. Each column is defined by a nested `<col>` element.
**Row attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `bold` | `true` / `false` | `false` | Bold text |
| `big` | `true` / `false` | `false` | Double-width font |
| `tall` | `true` / `false` | `false` | Double-height font |
| `red` | `true` / `false` | `false` | Red text |
| `lineSpacing` | int | _(none)_ | Override line spacing |
**`<col>` attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `width` | int | _(auto)_ | Column width in characters. Unspecified columns share remaining space equally. |
| `align` | `left` / `center` / `right` | `left` | Text alignment within the column |
**Examples:**
```xml
<!-- Tax breakdown header -->
<row>
<col width="10" align="left">MwSt %</col>
<col width="10" align="right">Brutto</col>
<col width="10" align="right">Netto</col>
<col align="right">MwSt</col>
</row>
<!-- Tax breakdown data row -->
<row>
<col width="10" align="left">{{tax.Category}}:{{tax.Rate}}%</col>
<col width="10" align="right">{{tax.Gross:F2}} {{tax.Currency}}</col>
<col width="10" align="right">{{tax.Net:F2}} {{tax.Currency}}</col>
<col align="right">{{tax.TaxAmount:F2}} {{tax.Currency}}</col>
</row>
```
In this example, the first three columns are 10 characters wide. The fourth column gets all remaining space (`lineWidth - 30`).
---
### `<separator>`
Outputs a line of repeated characters spanning the full line width.
**Attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `char` | single char | `-` | Character to repeat |
**Examples:**
```xml
<!-- Default dashed line: ------------------------------------------ -->
<separator />
<!-- Asterisk line: ****************************************** -->
<separator char="*" />
<!-- Equals line: ========================================== -->
<separator char="=" />
```
Always uses `lineWidth` (not `bigFontLineWidth`).
---
### `<cut />`
Triggers a paper cut. Produces a `PrintCommand` with `IsCut = true`.
```xml
<cut />
```
---
### `<feed>`
Outputs one or more empty lines.
**Attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `lines` | int | `1` | Number of empty lines |
**Examples:**
```xml
<!-- Single empty line (same as <line />) -->
<feed />
<!-- Three empty lines -->
<feed lines="3" />
```
---
### `<table>`
Renders an ASCII box table with borders (`+`, `-`, `|`). Column definitions are provided via nested `<col>` elements.
**Attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `items` | string | _(none)_ | JSON array path for data rows |
| `var` | string | `"item"` | Loop variable name for each data row |
| `headerItems` | string | _(none)_ | JSON array path for header rows (optional) |
If `headerItems` is not set, the text content of `<col>` elements is used as the header row. Columns without text produce no header.
**`<col>` attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `width` | int | _(auto)_ | Column width in characters (excluding border characters) |
| `align` | `left` / `center` / `right` | `left` | Cell content alignment |
Column widths exclude border characters. With 3 columns, 4 border characters (`|`) are used, so the available content width is `lineWidth - 4`.
**Examples:**
```xml
<!-- Simple header-only table -->
<table>
<col width="8" align="center">Order:</col>
<col align="center">QR Info</col>
<col width="12" align="center">Date</col>
</table>
<!-- Output:
+--------+------------------+------------+
| Order: | QR Info | Date |
+--------+------------------+------------+ -->
<!-- Table with data rows -->
<table items="Products" var="p">
<col width="20" align="left">Name</col>
<col width="10" align="right">Price</col>
</table>
<!-- Output:
+--------------------+----------+
|Name | Price|
+--------------------+----------+
|Margherita | 12.50|
|Tiramisu | 8.00|
+--------------------+----------+ -->
```
---
## Control Flow
### `<foreach>`
Iterates over a JSON array. For each item, the child elements are rendered with the loop variable available in the data context.
**Attributes (both required):**
| Attribute | Type | Description |
|-----------|------|-------------|
| `items` | string | Path to the JSON array (supports dot notation for nested arrays) |
| `var` | string | Variable name to bind each array element to |
**Loop variables** (available inside `<foreach>`):
| Variable | Type | Description |
|----------|------|-------------|
| `{{$index}}` | int | Zero-based index of the current item |
| `{{$first}}` | bool | `true` for the first item |
| `{{$last}}` | bool | `true` for the last item |
**Examples:**
```xml
<!-- Simple list -->
<foreach items="Items" var="item">
<line>{{item.Name}}: {{item.Price:F2}}</line>
</foreach>
<!-- Nested loops -->
<foreach items="Gangs" var="gang">
<line big="true" red="true">{{gang.Id}}. {{gang.Name}}</line>
<foreach items="gang.Dishes" var="dish">
<line tall="true">{{dish.Number}}x {{dish.Name}}</line>
</foreach>
</foreach>
<!-- Using $index -->
<foreach items="Items" var="item">
<line>{{$index}}: {{item.Name}}</line>
</foreach>
<!-- Output:
0: Pizza
1: Pasta
2: Salad -->
<!-- Cut between gangs, but not after the last one -->
<foreach items="Gangs" var="gang">
<line>{{gang.Name}}</line>
<if test="!$last">
<cut />
</if>
</foreach>
```
If `items` resolves to `null`, an empty array, or a non-array value, the loop body is skipped entirely.
---
### `<if>` / `<else>`
Conditionally renders child elements. An `<else>` block is optional and must immediately follow its corresponding `<if>`.
**Attributes:**
| Attribute | Type | Description |
|-----------|------|-------------|
| `test` | string | Condition expression (see Condition Expressions below) |
**Examples:**
```xml
<!-- Simple truthiness check -->
<if test="SpecialInstruction">
<line bold="true">{{SpecialInstruction}}</line>
</if>
<!-- With else branch -->
<if test="DiscountInfo">
<line>Discount: {{DiscountInfo.Description}}</line>
</if>
<else>
<line>No discount applied</line>
</else>
<!-- Negated condition -->
<if test="!$last">
<cut />
</if>
<!-- Comparison -->
<if test="Total>100">
<line>Large order!</line>
</if>
<!-- String comparison -->
<if test="Status=='active'">
<line>Active order</line>
</if>
<!-- Check nested property exists -->
<if test="dish.Modifications.HasModifications">
<line>Modified</line>
</if>
<!-- Loop variable condition -->
<if test="$first">
<line bold="true">First item header</line>
</if>
```
---
## Data Binding
### Expression Syntax `{{}}`
Expressions are enclosed in double curly braces and can appear in text content, `left`/`right` attributes of `<columns>`, and `<col>` text content.
**Property access:**
```xml
<!-- Simple property -->
<line>{{Name}}</line>
<!-- Nested property -->
<line>{{Person.Address.City}}</line>
<!-- Multiple expressions in one line -->
<line>{{FirstName}} {{LastName}}</line>
<!-- Mixed static text and expressions -->
<line>Order #{{OrderNumber}} - Table {{TableNumber}}</line>
<!-- Loop variable access -->
<foreach items="Items" var="item">
<line>{{item.Name}} - {{item.Price}}</line>
</foreach>
```
Property lookup is **case-insensitive**. Both `{{name}}` and `{{Name}}` resolve the same JSON property.
If a property is missing or `null`, the expression resolves to an empty string.
### Format Strings
Use a colon after the property name to apply a format string:
```
{{PropertyName:format}}
```
**DateTime formatting** (the JSON value must be an ISO 8601 date string):
```xml
<line>{{DateTime:dd.MM.yyyy}}</line> <!-- 15.03.2024 -->
<line>{{DateTime:HH:mm:ss}}</line> <!-- 14:30:00 -->
<line>{{DateTime:dd-MMM-yy HH:mm}}</line> <!-- 15-Mar-24 14:30 -->
```
**Number formatting** (standard .NET format strings):
```xml
<line>{{Price:F2}}</line> <!-- 9.50 -->
<line>{{Amount:N0}}</line> <!-- 1,234 -->
```
### Loop Variables
Available only inside `<foreach>` blocks:
```xml
<foreach items="Items" var="item">
<line>{{$index}}: {{item.Name}}</line> <!-- 0: Pizza -->
</foreach>
```
| Variable | Type | Description |
|----------|------|-------------|
| `{{$index}}` | int | Zero-based index |
| `{{$first}}` | bool | `true` on first iteration |
| `{{$last}}` | bool | `true` on last iteration |
`$first` and `$last` are primarily useful in `<if>` conditions rather than text output.
---
## Condition Expressions
Used in the `test` attribute of `<if>`. The following forms are supported:
### Truthiness
A property path by itself checks whether the value is "truthy":
```xml
<if test="PropertyName">
```
| JSON value | Truthy? |
|------------|---------|
| `"hello"` | yes |
| `""` | no |
| `42` | yes |
| `0` | no |
| `true` | yes |
| `false` | no |
| `[1, 2]` | yes |
| `[]` | no |
| `null` | no |
| _(missing)_ | no |
| `{ ... }` | yes |
### Negation
Prefix with `!` to negate:
```xml
<if test="!PropertyName"> <!-- true when property is falsy or missing -->
<if test="!$last"> <!-- true when NOT the last loop iteration -->
```
### Comparisons
Six comparison operators are supported. Operands can be property paths, numeric literals, or quoted string literals:
```xml
<if test="Count==5"> <!-- numeric equality -->
<if test="Count!=0"> <!-- numeric inequality -->
<if test="Count>3"> <!-- greater than -->
<if test="Count<10"> <!-- less than -->
<if test="Count>=1"> <!-- greater or equal -->
<if test="Count<=100"> <!-- less or equal -->
<if test="Status=='active'"> <!-- string equality (single quotes) -->
```
If both operands parse as numbers, numeric comparison is used. Otherwise, case-insensitive string comparison is used.
Comparisons can also be negated:
```xml
<if test="!Status=='inactive'">
```
### Loop Variables in Conditions
```xml
<if test="$first"> <!-- first iteration -->
<if test="$last"> <!-- last iteration -->
<if test="!$last"> <!-- not the last iteration -->
```
---
## Complete Example
### Kitchen Receipt Template
```xml
<receipt>
<line align="center" big="true" tall="true" red="true">{{Title}}</line>
<separator />
<line />
<line align="center">{{TransactionDateTime:dd-MMM-yy HH:mm}} Nr.:{{ReceiptNumber}}</line>
<line align="center">{{WaiterName}}</line>
<line align="center" big="true" bold="true">Tisch: {{TableNumber}}</line>
<if test="SpecialInstruction">
<line />
<line align="center" big="true" bold="true">{{SpecialInstruction}}</line>
<line />
</if>
<separator />
<foreach items="Gangs" var="gang">
<line align="center" big="true" tall="true" red="true">{{gang.Id}}. {{gang.Name}}</line>
<foreach items="gang.Dishes" var="dish">
<line tall="true" wrap="true">{{dish.Number}}x {{dish.Name}}</line>
<if test="dish.Comment">
<line tall="true" bold="true">Comment: {{dish.Comment}}</line>
</if>
</foreach>
<if test="!$last">
<cut />
<line />
<line />
</if>
</foreach>
<separator />
</receipt>
```
### Sample JSON Data
```json
{
"Title": "Ristorante Bella",
"TransactionDateTime": "2024-03-15T14:30:00Z",
"ReceiptNumber": "42",
"WaiterName": "Max Mustermann",
"TableNumber": "5",
"SpecialInstruction": null,
"Gangs": [
{
"Id": 1,
"Name": "Vorspeise",
"Dishes": [
{ "Number": 2, "Name": "Caesar Salad", "Comment": null }
]
},
{
"Id": 2,
"Name": "Hauptgang",
"Dishes": [
{ "Number": 1, "Name": "Wiener Schnitzel", "Comment": "Well done" }
]
}
]
}
```
### Printed Output (42 char width)
```
Ristorante Bella
------------------------------------------
15-Mar-24 14:30 Nr.:42
Max Mustermann
Tisch: 5
------------------------------------------
1. Vorspeise
2x Caesar Salad
------------------------------------------
2. Hauptgang
1x Wiener Schnitzel
Comment: Well done
------------------------------------------
```
---
## Quick Reference
| Element | Produces | Key Attributes |
|---------|----------|----------------|
| `<receipt>` | _(root)_ | - |
| `<line>` | 1 PrintCommand (or N if wrapping) | `align`, `bold`, `big`, `tall`, `red`, `wrap`, `wrapIndent`, `lineSpacing` |
| `<columns>` | 1 PrintCommand (or N if wrapping) | `left`, `right`, `bold`, `big`, `tall`, `red`, `wrap`, `wrapIndent`, `lineSpacing` |
| `<row>` | 1 PrintCommand | `bold`, `big`, `tall`, `red`, `lineSpacing` + nested `<col>` |
| `<separator>` | 1 PrintCommand | `char` |
| `<cut />` | 1 PrintCommand (IsCut=true) | - |
| `<feed />` | N PrintCommands (empty lines) | `lines` |
| `<foreach>` | N x children | `items` (required), `var` (required) |
| `<if>` | 0 or children | `test` (required) |
| `<else>` | 0 or children | _(must follow `<if>`)_ |
| `<table>` | Multiple PrintCommands (bordered) | `items`, `var`, `headerItems` + nested `<col>` |