templates support

This commit is contained in:
EugeneTes
2026-01-21 15:56:37 +01:00
parent 729679d283
commit 9fbc61dede
60 changed files with 5678 additions and 14 deletions

View File

@@ -0,0 +1,16 @@
namespace Inspectron.Epson.Templates.Configuration;
public record PrinterProfile(
string Id,
string Name,
int LineWidth,
int BigLineWidth,
bool SupportsRed)
{
public static PrinterProfile Default { get; } = new(
Id: "default",
Name: "Default Printer",
LineWidth: 48,
BigLineWidth: 24,
SupportsRed: false);
}

View File

@@ -0,0 +1,70 @@
namespace Inspectron.Epson.Templates.Configuration;
public class PrinterProfileRegistry
{
private readonly Dictionary<byte, PrinterProfile> _profilesById = new();
private readonly Dictionary<string, PrinterProfile> _profilesByName = new();
public PrinterProfileRegistry()
{
// Register built-in profiles
RegisterBuiltInProfiles();
}
private void RegisterBuiltInProfiles()
{
// TM-T30III (default thermal printer)
var tmT30III = new PrinterProfile(
Id: "tm-t30iii",
Name: "TM-T30III",
LineWidth: 48,
BigLineWidth: 24,
SupportsRed: false);
Register(0x01, tmT30III);
// TM-U220II (impact printer with red support)
var tmU220II = new PrinterProfile(
Id: "tm-u220ii",
Name: "TM-U220II",
LineWidth: 33,
BigLineWidth: 20,
SupportsRed: true);
Register(0x0D, tmU220II);
Register(0x13, tmU220II);
}
public void Register(byte printerId, PrinterProfile profile)
{
_profilesById[printerId] = profile;
_profilesByName[profile.Id] = profile;
}
public PrinterProfile GetProfile(byte printerId)
{
return _profilesById.TryGetValue(printerId, out var profile)
? profile
: PrinterProfile.Default;
}
public PrinterProfile GetProfile(string profileId)
{
return _profilesByName.TryGetValue(profileId, out var profile)
? profile
: PrinterProfile.Default;
}
public IEnumerable<PrinterProfile> GetAllProfiles()
{
return _profilesByName.Values.Distinct();
}
public bool TryGetProfile(byte printerId, out PrinterProfile? profile)
{
return _profilesById.TryGetValue(printerId, out profile);
}
public bool TryGetProfile(string profileId, out PrinterProfile? profile)
{
return _profilesByName.TryGetValue(profileId, out profile);
}
}

View File

@@ -0,0 +1,19 @@
namespace Inspectron.Epson.Templates.Configuration;
public record TemplateAssignment(
int ReceiptType,
string? ProfileId,
string TemplatePath)
{
public bool MatchesExact(int receiptType, string profileId)
{
return ReceiptType == receiptType &&
!string.IsNullOrEmpty(ProfileId) &&
ProfileId.Equals(profileId, StringComparison.OrdinalIgnoreCase);
}
public bool MatchesTypeOnly(int receiptType)
{
return ReceiptType == receiptType && string.IsNullOrEmpty(ProfileId);
}
}

View File

@@ -0,0 +1,46 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Inspectron.Epson.Templates.Configuration;
public class TemplateConfiguration
{
[JsonPropertyName("assignments")]
public List<TemplateAssignmentJson> Assignments { get; set; } = new();
[JsonPropertyName("fallbackTemplate")]
public string FallbackTemplate { get; set; } = "fallback.template";
public static TemplateConfiguration Load(string jsonPath)
{
var json = File.ReadAllText(jsonPath);
return JsonSerializer.Deserialize<TemplateConfiguration>(json)
?? new TemplateConfiguration();
}
public static TemplateConfiguration LoadFromJson(string json)
{
return JsonSerializer.Deserialize<TemplateConfiguration>(json)
?? new TemplateConfiguration();
}
public IEnumerable<TemplateAssignment> GetAssignments()
{
return Assignments.Select(a => new TemplateAssignment(
a.ReceiptType,
a.ProfileId,
a.TemplatePath));
}
}
public class TemplateAssignmentJson
{
[JsonPropertyName("receiptType")]
public int ReceiptType { get; set; }
[JsonPropertyName("profileId")]
public string? ProfileId { get; set; }
[JsonPropertyName("template")]
public string TemplatePath { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,45 @@
namespace Inspectron.Epson.Templates.Configuration;
public class TemplateResolver
{
private readonly List<TemplateAssignment> _assignments;
private readonly string _fallbackTemplate;
public TemplateResolver(TemplateConfiguration configuration)
{
_assignments = configuration.GetAssignments().ToList();
_fallbackTemplate = configuration.FallbackTemplate;
}
public TemplateResolver(IEnumerable<TemplateAssignment> assignments, string fallbackTemplate = "fallback.template")
{
_assignments = assignments.ToList();
_fallbackTemplate = fallbackTemplate;
}
public string Resolve(int receiptType, string profileId)
{
// Priority 1: Exact match (receiptType + profileId)
var exactMatch = _assignments.FirstOrDefault(a => a.MatchesExact(receiptType, profileId));
if (exactMatch != null)
{
return exactMatch.TemplatePath;
}
// Priority 2: Type-only match (receiptType without profileId)
var typeMatch = _assignments.FirstOrDefault(a => a.MatchesTypeOnly(receiptType));
if (typeMatch != null)
{
return typeMatch.TemplatePath;
}
// Priority 3: Fallback
return _fallbackTemplate;
}
public string Resolve(int receiptType, byte printerId, PrinterProfileRegistry registry)
{
var profile = registry.GetProfile(printerId);
return Resolve(receiptType, profile.Id);
}
}

View File

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

View File

@@ -0,0 +1,213 @@
using System.Text.Json;
namespace Inspectron.Epson.Templates.Interpreter;
public class DataContext
{
private readonly JsonElement _root;
private readonly DataContext? _parent;
private readonly Dictionary<string, JsonElement> _localVariables = new();
private readonly Dictionary<string, object> _loopMetadata = new();
public DataContext(JsonElement root)
{
_root = root;
_parent = null;
}
private DataContext(JsonElement root, DataContext parent)
{
_root = root;
_parent = parent;
}
public DataContext CreateChildContext(string variableName, JsonElement value, int index, int count)
{
var child = new DataContext(_root, this);
child._localVariables[variableName] = value;
child._loopMetadata["_index"] = index;
child._loopMetadata["_number"] = index + 1;
child._loopMetadata["_first"] = index == 0;
child._loopMetadata["_last"] = index == count - 1;
child._loopMetadata["_count"] = count;
return child;
}
public JsonElement? Resolve(string path)
{
if (string.IsNullOrEmpty(path))
{
return _root;
}
// Check loop metadata first
if (_loopMetadata.TryGetValue(path, out var metadata))
{
return JsonSerializer.SerializeToElement(metadata);
}
var segments = path.Split('.');
var firstSegment = segments[0];
// Check local variables (loop variables)
if (_localVariables.TryGetValue(firstSegment, out var localValue))
{
return NavigatePath(localValue, segments.Skip(1).ToArray());
}
// Check parent context for local variables
if (_parent != null)
{
// First check if parent has this as a local variable
var parentResult = _parent.ResolveLocalOnly(firstSegment);
if (parentResult.HasValue)
{
return NavigatePath(parentResult.Value, segments.Skip(1).ToArray());
}
// Also check parent's loop metadata
if (_parent._loopMetadata.TryGetValue(path, out var parentMetadata))
{
return JsonSerializer.SerializeToElement(parentMetadata);
}
}
// Navigate from root
return NavigatePath(_root, segments);
}
private JsonElement? ResolveLocalOnly(string name)
{
if (_localVariables.TryGetValue(name, out var value))
{
return value;
}
return _parent?.ResolveLocalOnly(name);
}
private JsonElement? NavigatePath(JsonElement element, string[] segments)
{
var current = element;
foreach (var segment in segments)
{
if (current.ValueKind == JsonValueKind.Null || current.ValueKind == JsonValueKind.Undefined)
{
return null;
}
// Handle array access: collection.count or collection.length
if (segment.Equals("count", StringComparison.OrdinalIgnoreCase) ||
segment.Equals("length", StringComparison.OrdinalIgnoreCase))
{
if (current.ValueKind == JsonValueKind.Array)
{
return JsonSerializer.SerializeToElement(current.GetArrayLength());
}
}
// Handle array index: collection.0, collection.1
if (int.TryParse(segment, out var index))
{
if (current.ValueKind == JsonValueKind.Array && index >= 0 && index < current.GetArrayLength())
{
current = current[index];
continue;
}
return null;
}
// Handle object property
if (current.ValueKind == JsonValueKind.Object)
{
if (current.TryGetProperty(segment, out var property))
{
current = property;
}
else
{
// Try case-insensitive match
var found = false;
foreach (var prop in current.EnumerateObject())
{
if (prop.Name.Equals(segment, StringComparison.OrdinalIgnoreCase))
{
current = prop.Value;
found = true;
break;
}
}
if (!found) return null;
}
}
else
{
return null;
}
}
return current;
}
public IEnumerable<JsonElement> ResolveCollection(string path)
{
var element = Resolve(path);
if (element.HasValue && element.Value.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.Value.EnumerateArray())
{
yield return item;
}
}
}
public string? GetString(string path)
{
var element = Resolve(path);
if (!element.HasValue) return null;
return element.Value.ValueKind switch
{
JsonValueKind.String => element.Value.GetString(),
JsonValueKind.Number => element.Value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => null,
_ => element.Value.GetRawText()
};
}
public bool IsTruthy(string path)
{
var element = Resolve(path);
if (!element.HasValue) return false;
return element.Value.ValueKind switch
{
JsonValueKind.Null or JsonValueKind.Undefined => false,
JsonValueKind.False => false,
JsonValueKind.True => true,
JsonValueKind.String => !string.IsNullOrEmpty(element.Value.GetString()),
JsonValueKind.Number => element.Value.GetDouble() != 0,
JsonValueKind.Array => element.Value.GetArrayLength() > 0,
JsonValueKind.Object => true,
_ => false
};
}
public object? GetValue(string path)
{
var element = Resolve(path);
if (!element.HasValue) return null;
return element.Value.ValueKind switch
{
JsonValueKind.String => element.Value.GetString(),
JsonValueKind.Number => element.Value.TryGetInt64(out var l) ? l : element.Value.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => element.Value.GetRawText()
};
}
}

View File

@@ -0,0 +1,176 @@
using System.Text.RegularExpressions;
namespace Inspectron.Epson.Templates.Interpreter;
public class ExpressionEvaluator
{
private static readonly Regex ComparisonRegex = new(
@"^(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)$",
RegexOptions.Compiled);
public bool Evaluate(string expression, DataContext context)
{
expression = expression.Trim();
if (string.IsNullOrEmpty(expression))
{
return false;
}
// Handle negation: !property
if (expression.StartsWith("!"))
{
var inner = expression[1..].Trim();
return !Evaluate(inner, context);
}
// Handle comparison operators
var match = ComparisonRegex.Match(expression);
if (match.Success)
{
var left = match.Groups[1].Value.Trim();
var op = match.Groups[2].Value;
var right = match.Groups[3].Value.Trim();
return EvaluateComparison(left, op, right, context);
}
// Handle logical AND: property1 && property2
if (expression.Contains("&&"))
{
var parts = expression.Split("&&", 2);
return Evaluate(parts[0], context) && Evaluate(parts[1], context);
}
// Handle logical OR: property1 || property2
if (expression.Contains("||"))
{
var parts = expression.Split("||", 2);
return Evaluate(parts[0], context) || Evaluate(parts[1], context);
}
// Simple truthy check
return context.IsTruthy(expression);
}
private bool EvaluateComparison(string left, string op, string right, DataContext context)
{
var leftValue = ResolveValue(left, context);
var rightValue = ResolveValue(right, context);
// Handle null comparisons
if (leftValue == null && rightValue == null)
{
return op == "==" || op == ">=" || op == "<=";
}
if (leftValue == null || rightValue == null)
{
return op switch
{
"==" => false,
"!=" => true,
_ => false
};
}
// Try numeric comparison first
if (TryGetNumeric(leftValue, out var leftNum) && TryGetNumeric(rightValue, out var rightNum))
{
return op switch
{
"==" => Math.Abs(leftNum - rightNum) < 0.0001,
"!=" => Math.Abs(leftNum - rightNum) >= 0.0001,
">" => leftNum > rightNum,
"<" => leftNum < rightNum,
">=" => leftNum >= rightNum,
"<=" => leftNum <= rightNum,
_ => false
};
}
// Fall back to string comparison
var leftStr = leftValue.ToString() ?? "";
var rightStr = rightValue.ToString() ?? "";
return op switch
{
"==" => leftStr.Equals(rightStr, StringComparison.OrdinalIgnoreCase),
"!=" => !leftStr.Equals(rightStr, StringComparison.OrdinalIgnoreCase),
">" => string.Compare(leftStr, rightStr, StringComparison.OrdinalIgnoreCase) > 0,
"<" => string.Compare(leftStr, rightStr, StringComparison.OrdinalIgnoreCase) < 0,
">=" => string.Compare(leftStr, rightStr, StringComparison.OrdinalIgnoreCase) >= 0,
"<=" => string.Compare(leftStr, rightStr, StringComparison.OrdinalIgnoreCase) <= 0,
_ => false
};
}
private object? ResolveValue(string expression, DataContext context)
{
expression = expression.Trim();
// Check for quoted string literal
if ((expression.StartsWith("\"") && expression.EndsWith("\"")) ||
(expression.StartsWith("'") && expression.EndsWith("'")))
{
return expression[1..^1];
}
// Check for numeric literal
if (double.TryParse(expression, out var num))
{
return num;
}
// Check for boolean literal
if (expression.Equals("true", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (expression.Equals("false", StringComparison.OrdinalIgnoreCase))
{
return false;
}
// Check for null literal
if (expression.Equals("null", StringComparison.OrdinalIgnoreCase))
{
return null;
}
// Resolve as path
return context.GetValue(expression);
}
private bool TryGetNumeric(object? value, out double result)
{
result = 0;
if (value == null) return false;
if (value is double d)
{
result = d;
return true;
}
if (value is long l)
{
result = l;
return true;
}
if (value is int i)
{
result = i;
return true;
}
if (value is string s && double.TryParse(s, out result))
{
return true;
}
return false;
}
}

View File

@@ -0,0 +1,129 @@
using System.Globalization;
using System.Text.Json;
namespace Inspectron.Epson.Templates.Interpreter;
public class FormatResolver
{
private readonly CultureInfo _culture;
public FormatResolver(CultureInfo? culture = null)
{
_culture = culture ?? CultureInfo.InvariantCulture;
}
public string Format(JsonElement? element, string? format)
{
if (!element.HasValue || element.Value.ValueKind == JsonValueKind.Null)
{
return string.Empty;
}
var value = element.Value;
if (string.IsNullOrEmpty(format))
{
return GetDefaultString(value);
}
return value.ValueKind switch
{
JsonValueKind.Number => FormatNumber(value, format),
JsonValueKind.String => FormatString(value.GetString(), format),
_ => GetDefaultString(value)
};
}
private string FormatNumber(JsonElement value, string format)
{
// Try to get as decimal for precision
if (value.TryGetDecimal(out var decimalValue))
{
try
{
return decimalValue.ToString(format, _culture);
}
catch (FormatException)
{
return decimalValue.ToString(_culture);
}
}
if (value.TryGetDouble(out var doubleValue))
{
try
{
return doubleValue.ToString(format, _culture);
}
catch (FormatException)
{
return doubleValue.ToString(_culture);
}
}
return value.GetRawText();
}
private string FormatString(string? value, string format)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
// Try to parse as DateTime
if (DateTime.TryParse(value, out var dateTime))
{
try
{
return dateTime.ToString(format, _culture);
}
catch (FormatException)
{
return value;
}
}
// Try to parse as DateTimeOffset (for ISO 8601 strings)
if (DateTimeOffset.TryParse(value, out var dateTimeOffset))
{
try
{
return dateTimeOffset.ToString(format, _culture);
}
catch (FormatException)
{
return value;
}
}
// If it's a number in string form
if (decimal.TryParse(value, out var decimalValue))
{
try
{
return decimalValue.ToString(format, _culture);
}
catch (FormatException)
{
return value;
}
}
return value;
}
private string GetDefaultString(JsonElement value)
{
return value.ValueKind switch
{
JsonValueKind.String => value.GetString() ?? string.Empty,
JsonValueKind.Number => value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => string.Empty,
JsonValueKind.Undefined => string.Empty,
_ => value.GetRawText()
};
}
}

View File

@@ -0,0 +1,20 @@
using Inspectron.Epson.Templates.Language;
namespace Inspectron.Epson.Templates.Interpreter;
public class InterpreterException : Exception
{
public SourcePosition Position { get; }
public InterpreterException(string message, SourcePosition position)
: base($"{message} at {position}")
{
Position = position;
}
public InterpreterException(string message, SourcePosition position, Exception innerException)
: base($"{message} at {position}", innerException)
{
Position = position;
}
}

View File

@@ -0,0 +1,428 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using Inspectron.Epson.PrintServer.Printers.Utils;
using Inspectron.Epson.Templates.Configuration;
using Inspectron.Epson.Templates.Language;
using Inspectron.Epson.Templates.Language.Nodes;
namespace Inspectron.Epson.Templates.Interpreter;
public class TemplateInterpreter
{
private readonly PrinterProfile _profile;
private readonly ExpressionEvaluator _evaluator;
private readonly FormatResolver _formatter;
public TemplateInterpreter(PrinterProfile profile, CultureInfo? culture = null)
{
_profile = profile ?? throw new ArgumentNullException(nameof(profile));
_evaluator = new ExpressionEvaluator();
_formatter = new FormatResolver(culture);
}
public List<PrintCommand> Interpret(TemplateNode template, string jsonData)
{
var document = JsonDocument.Parse(jsonData);
var context = new DataContext(document.RootElement);
return Interpret(template, context);
}
public List<PrintCommand> Interpret(TemplateNode template, DataContext context)
{
var commands = new List<PrintCommand>();
InterpretNodes(template.ChildNodes, context, commands, new StyleContext());
return commands;
}
private void InterpretNodes(
IReadOnlyList<ITemplateNode> nodes,
DataContext context,
List<PrintCommand> commands,
StyleContext style)
{
foreach (var node in nodes)
{
InterpretNode(node, context, commands, style);
}
}
private void InterpretNode(
ITemplateNode node,
DataContext context,
List<PrintCommand> commands,
StyleContext style)
{
switch (node)
{
case TextNode textNode:
InterpretText(textNode, context, commands, style);
break;
case BindingNode bindingNode:
InterpretBinding(bindingNode, context, commands, style);
break;
case StyledTextNode styledNode:
InterpretStyledText(styledNode, context, commands, style);
break;
case SeparatorNode separatorNode:
InterpretSeparator(separatorNode, commands, style);
break;
case EmptyLineNode:
commands.Add(CreateCommand(string.Empty, style));
break;
case IfNode ifNode:
InterpretIf(ifNode, context, commands, style);
break;
case ForeachNode foreachNode:
InterpretForeach(foreachNode, context, commands, style);
break;
case RowNode rowNode:
InterpretRow(rowNode, context, commands, style);
break;
case ColumnNode columnNode:
InterpretColumn(columnNode, context, commands, style);
break;
}
}
private void InterpretText(
TextNode node,
DataContext context,
List<PrintCommand> commands,
StyleContext style)
{
var text = ApplyAlignment(node.Text, style);
commands.Add(CreateCommand(text, style));
}
private void InterpretBinding(
BindingNode node,
DataContext context,
List<PrintCommand> commands,
StyleContext style)
{
var element = context.Resolve(node.Path);
var text = _formatter.Format(element, node.Format);
text = ApplyAlignment(text, style);
commands.Add(CreateCommand(text, style));
}
private void InterpretStyledText(
StyledTextNode node,
DataContext context,
List<PrintCommand> commands,
StyleContext style)
{
// Create new style context with inherited and new styles
var newStyle = style.Clone();
ApplyStyles(node.Styles, newStyle);
// Build content string from child nodes
var contentBuilder = new StringBuilder();
foreach (var child in node.ContentNodes)
{
switch (child)
{
case TextNode textNode:
contentBuilder.Append(textNode.Text);
break;
case BindingNode bindingNode:
var element = context.Resolve(bindingNode.Path);
contentBuilder.Append(_formatter.Format(element, bindingNode.Format));
break;
}
}
var text = contentBuilder.ToString();
text = ApplyAlignment(text, newStyle);
commands.Add(CreateCommand(text, newStyle));
}
private void InterpretSeparator(
SeparatorNode node,
List<PrintCommand> commands,
StyleContext style)
{
var width = style.IsBig ? _profile.BigLineWidth : _profile.LineWidth;
var ch = node.Style switch
{
SeparatorStyle.Dash => '-',
SeparatorStyle.Equals => '=',
SeparatorStyle.Star => '*',
SeparatorStyle.Tilde => '~',
_ => '-'
};
var line = new string(ch, width);
commands.Add(CreateCommand(line, style));
}
private void InterpretIf(
IfNode node,
DataContext context,
List<PrintCommand> commands,
StyleContext style)
{
// Evaluate @if condition
if (_evaluator.Evaluate(node.IfBranch.Condition, context))
{
InterpretNodes(node.IfBranch.Body, context, commands, style);
return;
}
// Evaluate @elseif conditions
foreach (var branch in node.ElseIfBranches)
{
if (_evaluator.Evaluate(branch.Condition, context))
{
InterpretNodes(branch.Body, context, commands, style);
return;
}
}
// Execute @else branch if present
if (node.ElseBranch != null)
{
InterpretNodes(node.ElseBranch, context, commands, style);
}
}
private void InterpretForeach(
ForeachNode node,
DataContext context,
List<PrintCommand> commands,
StyleContext style)
{
var collection = context.ResolveCollection(node.CollectionPath).ToList();
var count = collection.Count;
for (int i = 0; i < count; i++)
{
var item = collection[i];
var childContext = context.CreateChildContext(node.ItemVariable, item, i, count);
InterpretNodes(node.Body, childContext, commands, style);
}
}
private void InterpretRow(
RowNode node,
DataContext context,
List<PrintCommand> commands,
StyleContext style)
{
var lineWidth = style.IsBig ? _profile.BigLineWidth : _profile.LineWidth;
var rowBuilder = new StringBuilder();
foreach (var column in node.Columns)
{
// Build column content
var columnContent = new StringBuilder();
foreach (var child in column.ContentNodes)
{
switch (child)
{
case TextNode textNode:
columnContent.Append(textNode.Text);
break;
case BindingNode bindingNode:
var element = context.Resolve(bindingNode.Path);
columnContent.Append(_formatter.Format(element, bindingNode.Format));
break;
case StyledTextNode styledNode:
foreach (var styledChild in styledNode.ContentNodes)
{
if (styledChild is TextNode st)
columnContent.Append(st.Text);
else if (styledChild is BindingNode sb)
{
var elem = context.Resolve(sb.Path);
columnContent.Append(_formatter.Format(elem, sb.Format));
}
}
break;
}
}
var content = columnContent.ToString();
var width = column.Width > 0 ? column.Width : content.Length;
// Apply column alignment
var aligned = column.Alignment switch
{
ColumnAlignment.Right => content.PadLeft(width),
ColumnAlignment.Center => CenterText(content, width),
_ => content.PadRight(width)
};
// Truncate if too long
if (aligned.Length > width && width > 0)
{
aligned = aligned[..width];
}
rowBuilder.Append(aligned);
}
commands.Add(CreateCommand(rowBuilder.ToString(), style));
}
private void InterpretColumn(
ColumnNode node,
DataContext context,
List<PrintCommand> commands,
StyleContext style)
{
// Standalone column - just output content with width constraints
var contentBuilder = new StringBuilder();
foreach (var child in node.ContentNodes)
{
switch (child)
{
case TextNode textNode:
contentBuilder.Append(textNode.Text);
break;
case BindingNode bindingNode:
var element = context.Resolve(bindingNode.Path);
contentBuilder.Append(_formatter.Format(element, bindingNode.Format));
break;
}
}
var content = contentBuilder.ToString();
var width = node.Width > 0 ? node.Width : content.Length;
var aligned = node.Alignment switch
{
ColumnAlignment.Right => content.PadLeft(width),
ColumnAlignment.Center => CenterText(content, width),
_ => content.PadRight(width)
};
if (aligned.Length > width && width > 0)
{
aligned = aligned[..width];
}
commands.Add(CreateCommand(aligned, style));
}
private void ApplyStyles(IReadOnlyList<string> styles, StyleContext styleContext)
{
foreach (var style in styles)
{
var lower = style.ToLowerInvariant();
if (lower.StartsWith("spacing:"))
{
if (int.TryParse(lower[8..], out var spacing))
{
styleContext.LineSpacing = spacing;
}
continue;
}
switch (lower)
{
case "bold":
styleContext.IsBold = true;
break;
case "big":
styleContext.IsBig = true;
break;
case "tall":
styleContext.IsTall = true;
break;
case "red":
styleContext.IsRed = true;
break;
case "center":
styleContext.Alignment = TextAlignment.Center;
break;
case "right":
styleContext.Alignment = TextAlignment.Right;
break;
case "left":
styleContext.Alignment = TextAlignment.Left;
break;
}
}
}
private string ApplyAlignment(string text, StyleContext style)
{
if (style.Alignment == TextAlignment.Left)
{
return text;
}
var width = style.IsBig ? _profile.BigLineWidth : _profile.LineWidth;
return style.Alignment switch
{
TextAlignment.Center => CenterText(text, width),
TextAlignment.Right => text.PadLeft(width),
_ => text
};
}
private string CenterText(string text, int width)
{
if (string.IsNullOrEmpty(text) || text.Length >= width)
{
return text;
}
var totalPadding = width - text.Length;
var leftPadding = totalPadding / 2;
var rightPadding = totalPadding - leftPadding;
return new string(' ', leftPadding) + text + new string(' ', rightPadding);
}
private PrintCommand CreateCommand(string text, StyleContext style)
{
var command = new PrintCommand(text, style.IsBig, style.IsBold)
{
IsTall = style.IsTall,
IsRed = style.IsRed && _profile.SupportsRed,
SetLineSpacing = style.LineSpacing
};
return command;
}
}
internal enum TextAlignment
{
Left,
Center,
Right
}
internal class StyleContext
{
public bool IsBold { get; set; }
public bool IsBig { get; set; }
public bool IsTall { get; set; }
public bool IsRed { get; set; }
public TextAlignment Alignment { get; set; } = TextAlignment.Left;
public int? LineSpacing { get; set; }
public StyleContext Clone()
{
return new StyleContext
{
IsBold = IsBold,
IsBig = IsBig,
IsTall = IsTall,
IsRed = IsRed,
Alignment = Alignment,
LineSpacing = LineSpacing
};
}
}

View File

@@ -0,0 +1,331 @@
using System.Text;
using System.Text.RegularExpressions;
namespace Inspectron.Epson.Templates.Language;
public class Lexer
{
private readonly string _source;
private readonly List<string> _lines;
private int _lineIndex;
private int _columnIndex;
private readonly List<Token> _tokens = new();
private readonly List<LexerError> _errors = new();
private bool _inMultiLineComment;
private SourcePosition _multiLineCommentStart;
private static readonly Regex StyleStartRegex = new(@"^#([a-zA-Z0-9,:]+)#", RegexOptions.Compiled);
private static readonly Regex BindingRegex = new(@"^\{([^}]+)\}", RegexOptions.Compiled);
private static readonly Regex ColumnRegex = new(@"^\|(\d+)(?:,(\w+))?\|", RegexOptions.Compiled);
public Lexer(string source)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
_lines = _source.Split('\n').ToList();
// Normalize line endings
for (int i = 0; i < _lines.Count; i++)
{
_lines[i] = _lines[i].TrimEnd('\r');
}
}
public LexerResult Tokenize()
{
_tokens.Clear();
_errors.Clear();
_lineIndex = 0;
_columnIndex = 0;
_inMultiLineComment = false;
_multiLineCommentStart = default;
while (_lineIndex < _lines.Count)
{
TokenizeLine(_lines[_lineIndex]);
_tokens.Add(new Token(TokenType.NewLine, "\n", new SourcePosition(_lineIndex + 1, _columnIndex + 1)));
_lineIndex++;
_columnIndex = 0;
}
if (_inMultiLineComment)
{
_errors.Add(new LexerError("Unclosed multi-line comment", _multiLineCommentStart));
}
_tokens.Add(Token.Eof(_lineIndex + 1));
return new LexerResult(_tokens.ToList(), _errors.ToList());
}
private void TokenizeLine(string line)
{
int lineNumber = _lineIndex + 1;
// Handle multi-line comment state
if (_inMultiLineComment)
{
var closeIndex = line.IndexOf("*@", StringComparison.Ordinal);
if (closeIndex >= 0)
{
// Found comment closer - exit comment mode
_inMultiLineComment = false;
var afterCloseIndex = closeIndex + 2;
if (afterCloseIndex < line.Length && !string.IsNullOrWhiteSpace(line[afterCloseIndex..]))
{
// Process content after the closing *@
_columnIndex = afterCloseIndex;
TokenizeContent(line, lineNumber);
}
else
{
_columnIndex = line.Length;
}
}
else
{
// Still in comment - skip entire line
_columnIndex = line.Length;
}
return;
}
// Check for comment lines (must be checked before separators)
var trimmed = line.TrimStart();
var leadingWhitespace = line.Length - trimmed.Length;
if (trimmed.StartsWith("@*"))
{
// Check for inline comment: @* ... *@
var closeIndex = trimmed.IndexOf("*@", 2, StringComparison.Ordinal);
if (closeIndex >= 0)
{
// Inline single-line comment - skip entire comment portion
var afterCloseIndex = leadingWhitespace + closeIndex + 2;
if (afterCloseIndex < line.Length && !string.IsNullOrWhiteSpace(line[afterCloseIndex..]))
{
// Process content after the closing *@
_columnIndex = afterCloseIndex;
TokenizeContent(line, lineNumber);
}
else
{
_columnIndex = line.Length;
}
}
else if (trimmed.TrimEnd() == "@*")
{
// Start of multi-line comment block
_inMultiLineComment = true;
_multiLineCommentStart = new SourcePosition(lineNumber, leadingWhitespace + 1);
_columnIndex = line.Length;
}
else
{
// Single-line comment to end of line
_columnIndex = line.Length;
}
return;
}
// Check for separator lines (entire line)
if (IsSeparatorLine(line, out var separatorType))
{
_tokens.Add(new Token(separatorType, line, new SourcePosition(lineNumber, 1)));
_columnIndex = line.Length;
return;
}
// Check for directive lines
if (trimmed.StartsWith("@"))
{
if (TryTokenizeDirective(trimmed, leadingWhitespace, lineNumber))
{
return;
}
}
// Otherwise, process as content line
TokenizeContent(line, lineNumber);
}
private bool IsSeparatorLine(string line, out TokenType separatorType)
{
var trimmed = line.Trim();
separatorType = TokenType.Text;
if (trimmed.Length >= 3)
{
if (trimmed.All(c => c == '-'))
{
separatorType = TokenType.DashSeparator;
return true;
}
if (trimmed.All(c => c == '='))
{
separatorType = TokenType.EqualsSeparator;
return true;
}
if (trimmed.All(c => c == '*'))
{
separatorType = TokenType.StarSeparator;
return true;
}
if (trimmed.All(c => c == '~'))
{
separatorType = TokenType.TildeSeparator;
return true;
}
}
return false;
}
private bool TryTokenizeDirective(string trimmed, int leadingWhitespace, int lineNumber)
{
var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0) return false;
var directive = parts[0].ToLowerInvariant();
var value = parts.Length > 1 ? string.Join(" ", parts.Skip(1)) : string.Empty;
var position = new SourcePosition(lineNumber, leadingWhitespace + 1);
switch (directive)
{
case "@if":
_tokens.Add(new Token(TokenType.If, value, position));
_columnIndex = trimmed.Length + leadingWhitespace;
return true;
case "@elseif":
_tokens.Add(new Token(TokenType.ElseIf, value, position));
_columnIndex = trimmed.Length + leadingWhitespace;
return true;
case "@else":
_tokens.Add(new Token(TokenType.Else, string.Empty, position));
_columnIndex = trimmed.Length + leadingWhitespace;
return true;
case "@end":
_tokens.Add(new Token(TokenType.End, string.Empty, position));
_columnIndex = trimmed.Length + leadingWhitespace;
return true;
case "@foreach":
_tokens.Add(new Token(TokenType.Foreach, value, position));
_columnIndex = trimmed.Length + leadingWhitespace;
return true;
case "@row":
_tokens.Add(new Token(TokenType.Row, string.Empty, position));
_columnIndex = trimmed.Length + leadingWhitespace;
return true;
case "@endrow":
_tokens.Add(new Token(TokenType.EndRow, string.Empty, position));
_columnIndex = trimmed.Length + leadingWhitespace;
return true;
}
return false;
}
private void TokenizeContent(string line, int lineNumber)
{
var textBuffer = new StringBuilder();
int textStartColumn = 1;
bool inStyle = false;
while (_columnIndex < line.Length)
{
var remaining = line[_columnIndex..];
var currentColumn = _columnIndex + 1;
// Check for style start: #styles#
if (!inStyle)
{
var styleMatch = StyleStartRegex.Match(remaining);
if (styleMatch.Success)
{
FlushTextBuffer(textBuffer, lineNumber, textStartColumn);
_tokens.Add(new Token(TokenType.StyleStart, styleMatch.Groups[1].Value,
new SourcePosition(lineNumber, currentColumn)));
_columnIndex += styleMatch.Length;
textStartColumn = _columnIndex + 1;
inStyle = true;
continue;
}
}
// Check for style end: trailing #
if (inStyle && remaining.StartsWith("#"))
{
FlushTextBuffer(textBuffer, lineNumber, textStartColumn);
_tokens.Add(new Token(TokenType.StyleEnd, "#",
new SourcePosition(lineNumber, currentColumn)));
_columnIndex++;
textStartColumn = _columnIndex + 1;
inStyle = false;
continue;
}
// Check for binding: {path} or {path:format}
var bindingMatch = BindingRegex.Match(remaining);
if (bindingMatch.Success)
{
FlushTextBuffer(textBuffer, lineNumber, textStartColumn);
_tokens.Add(new Token(TokenType.Binding, bindingMatch.Groups[1].Value,
new SourcePosition(lineNumber, currentColumn)));
_columnIndex += bindingMatch.Length;
textStartColumn = _columnIndex + 1;
continue;
}
// Check for column: |width| or |width,align|
var columnMatch = ColumnRegex.Match(remaining);
if (columnMatch.Success)
{
FlushTextBuffer(textBuffer, lineNumber, textStartColumn);
var width = columnMatch.Groups[1].Value;
var align = columnMatch.Groups[2].Success ? columnMatch.Groups[2].Value : "left";
_tokens.Add(new Token(TokenType.Column, $"{width},{align}",
new SourcePosition(lineNumber, currentColumn)));
_columnIndex += columnMatch.Length;
textStartColumn = _columnIndex + 1;
continue;
}
// Regular text character
if (textBuffer.Length == 0)
{
textStartColumn = currentColumn;
}
textBuffer.Append(line[_columnIndex]);
_columnIndex++;
}
// Flush remaining text
FlushTextBuffer(textBuffer, lineNumber, textStartColumn);
// If style wasn't closed on this line, add error
if (inStyle)
{
_errors.Add(new LexerError("Unclosed style block", new SourcePosition(lineNumber, _columnIndex)));
}
}
private void FlushTextBuffer(StringBuilder buffer, int line, int startColumn)
{
if (buffer.Length > 0)
{
_tokens.Add(new Token(TokenType.Text, buffer.ToString(),
new SourcePosition(line, startColumn)));
buffer.Clear();
}
}
}
public record LexerResult(List<Token> Tokens, List<LexerError> Errors)
{
public bool HasErrors => Errors.Count > 0;
}
public record LexerError(string Message, SourcePosition Position);

View File

@@ -0,0 +1,14 @@
namespace Inspectron.Epson.Templates.Language.Nodes;
public record BindingNode(string Path, string? Format, SourcePosition Position) : ITemplateNode
{
public IReadOnlyList<ITemplateNode> Children => Array.Empty<ITemplateNode>();
public static BindingNode Parse(string value, SourcePosition position)
{
var parts = value.Split(':', 2);
var path = parts[0].Trim();
var format = parts.Length > 1 ? parts[1].Trim() : null;
return new BindingNode(path, format, position);
}
}

View File

@@ -0,0 +1,27 @@
namespace Inspectron.Epson.Templates.Language.Nodes;
public enum ColumnAlignment
{
Left,
Right,
Center
}
public record ColumnNode(
int Width,
ColumnAlignment Alignment,
List<ITemplateNode> ContentNodes,
SourcePosition Position) : ITemplateNode
{
public IReadOnlyList<ITemplateNode> Children => ContentNodes;
public static (int Width, ColumnAlignment Alignment) ParseColumnSpec(string value)
{
var parts = value.Split(',', 2);
var width = int.Parse(parts[0]);
var align = parts.Length > 1
? Enum.TryParse<ColumnAlignment>(parts[1], true, out var a) ? a : ColumnAlignment.Left
: ColumnAlignment.Left;
return (width, align);
}
}

View File

@@ -0,0 +1,6 @@
namespace Inspectron.Epson.Templates.Language.Nodes;
public record EmptyLineNode(SourcePosition Position) : ITemplateNode
{
public IReadOnlyList<ITemplateNode> Children => Array.Empty<ITemplateNode>();
}

View File

@@ -0,0 +1,21 @@
namespace Inspectron.Epson.Templates.Language.Nodes;
public record ForeachNode(
string ItemVariable,
string CollectionPath,
List<ITemplateNode> Body,
SourcePosition Position) : ITemplateNode
{
public IReadOnlyList<ITemplateNode> Children => Body;
public static (string ItemVariable, string CollectionPath) ParseForeach(string value)
{
// Expected format: "item in collection" or "item in collection.path"
var parts = value.Split(" in ", 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2)
{
throw new FormatException($"Invalid foreach syntax: '{value}'. Expected 'item in collection'.");
}
return (parts[0], parts[1]);
}
}

View File

@@ -0,0 +1,7 @@
namespace Inspectron.Epson.Templates.Language.Nodes;
public interface ITemplateNode
{
SourcePosition Position { get; }
IReadOnlyList<ITemplateNode> Children { get; }
}

View File

@@ -0,0 +1,31 @@
namespace Inspectron.Epson.Templates.Language.Nodes;
public record ConditionBranch(
string Condition,
List<ITemplateNode> Body,
SourcePosition Position);
public record IfNode(
ConditionBranch IfBranch,
IReadOnlyList<ConditionBranch> ElseIfBranches,
List<ITemplateNode>? ElseBranch,
SourcePosition Position) : ITemplateNode
{
public IReadOnlyList<ITemplateNode> Children
{
get
{
var children = new List<ITemplateNode>();
children.AddRange(IfBranch.Body);
foreach (var branch in ElseIfBranches)
{
children.AddRange(branch.Body);
}
if (ElseBranch != null)
{
children.AddRange(ElseBranch);
}
return children;
}
}
}

View File

@@ -0,0 +1,6 @@
namespace Inspectron.Epson.Templates.Language.Nodes;
public record RowNode(List<ColumnNode> Columns, SourcePosition Position) : ITemplateNode
{
public IReadOnlyList<ITemplateNode> Children => Columns;
}

View File

@@ -0,0 +1,14 @@
namespace Inspectron.Epson.Templates.Language.Nodes;
public enum SeparatorStyle
{
Dash, // ---
Equals, // ===
Star, // ***
Tilde // ~~~
}
public record SeparatorNode(SeparatorStyle Style, SourcePosition Position) : ITemplateNode
{
public IReadOnlyList<ITemplateNode> Children => Array.Empty<ITemplateNode>();
}

View File

@@ -0,0 +1,9 @@
namespace Inspectron.Epson.Templates.Language.Nodes;
public record StyledTextNode(
IReadOnlyList<string> Styles,
List<ITemplateNode> ContentNodes,
SourcePosition Position) : ITemplateNode
{
public IReadOnlyList<ITemplateNode> Children => ContentNodes;
}

View File

@@ -0,0 +1,7 @@
namespace Inspectron.Epson.Templates.Language.Nodes;
public record TemplateNode(List<ITemplateNode> ChildNodes) : ITemplateNode
{
public SourcePosition Position => new(1, 1);
public IReadOnlyList<ITemplateNode> Children => ChildNodes;
}

View File

@@ -0,0 +1,6 @@
namespace Inspectron.Epson.Templates.Language.Nodes;
public record TextNode(string Text, SourcePosition Position) : ITemplateNode
{
public IReadOnlyList<ITemplateNode> Children => Array.Empty<ITemplateNode>();
}

View File

@@ -0,0 +1,377 @@
using Inspectron.Epson.Templates.Language.Nodes;
namespace Inspectron.Epson.Templates.Language;
public class Parser
{
private readonly List<Token> _tokens;
private int _position;
private readonly List<ParseError> _errors = new();
public Parser(List<Token> tokens)
{
_tokens = tokens ?? throw new ArgumentNullException(nameof(tokens));
}
public ParseResult Parse()
{
_position = 0;
_errors.Clear();
var nodes = ParseNodeList(TokenType.EndOfFile);
return new ParseResult(new TemplateNode(nodes), _errors.ToList());
}
private List<ITemplateNode> ParseNodeList(params TokenType[] terminators)
{
var nodes = new List<ITemplateNode>();
while (!IsAtEnd() && !terminators.Contains(Current.Type))
{
var node = ParseNode();
if (node != null)
{
nodes.Add(node);
}
}
return nodes;
}
private ITemplateNode? ParseNode()
{
var token = Current;
switch (token.Type)
{
case TokenType.NewLine:
Advance();
// Check if this was an empty line (previous was also newline or start)
if (_position >= 2 && _tokens[_position - 2].Type == TokenType.NewLine)
{
return new EmptyLineNode(token.Position);
}
return null;
case TokenType.Text:
return ParseText();
case TokenType.StyleStart:
return ParseStyledText();
case TokenType.Binding:
return ParseBinding();
case TokenType.DashSeparator:
Advance();
return new SeparatorNode(SeparatorStyle.Dash, token.Position);
case TokenType.EqualsSeparator:
Advance();
return new SeparatorNode(SeparatorStyle.Equals, token.Position);
case TokenType.StarSeparator:
Advance();
return new SeparatorNode(SeparatorStyle.Star, token.Position);
case TokenType.TildeSeparator:
Advance();
return new SeparatorNode(SeparatorStyle.Tilde, token.Position);
case TokenType.If:
return ParseIf();
case TokenType.Foreach:
return ParseForeach();
case TokenType.Row:
return ParseRow();
case TokenType.Column:
return ParseColumn();
case TokenType.ElseIf:
case TokenType.Else:
case TokenType.End:
case TokenType.EndRow:
// These are handled by parent parsers; don't consume here
return null;
case TokenType.StyleEnd:
// Orphan style end - skip with error
AddError("Unexpected style end marker '#' without matching style start", token.Position);
Advance();
return null;
case TokenType.EndOfFile:
return null;
default:
AddError($"Unexpected token type: {token.Type}", token.Position);
Advance();
return null;
}
}
private TextNode ParseText()
{
var token = Current;
Advance();
return new TextNode(token.Value, token.Position);
}
private BindingNode ParseBinding()
{
var token = Current;
Advance();
return BindingNode.Parse(token.Value, token.Position);
}
private StyledTextNode ParseStyledText()
{
var startToken = Current;
var styles = startToken.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
Advance(); // consume StyleStart
var contentNodes = new List<ITemplateNode>();
// Parse content until StyleEnd or NewLine
while (!IsAtEnd() && Current.Type != TokenType.StyleEnd && Current.Type != TokenType.NewLine)
{
if (Current.Type == TokenType.Text)
{
contentNodes.Add(ParseText());
}
else if (Current.Type == TokenType.Binding)
{
contentNodes.Add(ParseBinding());
}
else
{
// Unexpected token inside styled text
AddError($"Unexpected token inside styled text: {Current.Type}", Current.Position);
Advance();
}
}
// Consume StyleEnd if present
if (Current.Type == TokenType.StyleEnd)
{
Advance();
}
return new StyledTextNode(styles, contentNodes, startToken.Position);
}
private IfNode ParseIf()
{
var ifToken = Current;
var ifCondition = ifToken.Value;
Advance(); // consume @if
// Skip newline after @if
SkipNewlines();
var ifBody = ParseNodeList(TokenType.ElseIf, TokenType.Else, TokenType.End, TokenType.EndOfFile);
var elseIfBranches = new List<ConditionBranch>();
List<ITemplateNode>? elseBranch = null;
// Parse @elseif branches
while (Current.Type == TokenType.ElseIf)
{
var elseIfToken = Current;
var elseIfCondition = elseIfToken.Value;
Advance(); // consume @elseif
SkipNewlines();
var elseIfBody = ParseNodeList(TokenType.ElseIf, TokenType.Else, TokenType.End, TokenType.EndOfFile);
elseIfBranches.Add(new ConditionBranch(elseIfCondition, elseIfBody, elseIfToken.Position));
}
// Parse @else branch
if (Current.Type == TokenType.Else)
{
Advance(); // consume @else
SkipNewlines();
elseBranch = ParseNodeList(TokenType.End, TokenType.EndOfFile);
}
// Expect @end
if (Current.Type == TokenType.End)
{
Advance(); // consume @end
}
else
{
AddError("Expected @end to close @if block", Current.Position);
}
return new IfNode(
new ConditionBranch(ifCondition, ifBody, ifToken.Position),
elseIfBranches,
elseBranch,
ifToken.Position);
}
private ForeachNode ParseForeach()
{
var foreachToken = Current;
Advance(); // consume @foreach
string itemVariable;
string collectionPath;
try
{
(itemVariable, collectionPath) = ForeachNode.ParseForeach(foreachToken.Value);
}
catch (FormatException ex)
{
AddError(ex.Message, foreachToken.Position);
itemVariable = "item";
collectionPath = "items";
}
SkipNewlines();
var body = ParseNodeList(TokenType.End, TokenType.EndOfFile);
if (Current.Type == TokenType.End)
{
Advance(); // consume @end
}
else
{
AddError("Expected @end to close @foreach block", Current.Position);
}
return new ForeachNode(itemVariable, collectionPath, body, foreachToken.Position);
}
private RowNode ParseRow()
{
var rowToken = Current;
Advance(); // consume @row
SkipNewlines();
var columns = new List<ColumnNode>();
// Parse columns until @endrow
while (!IsAtEnd() && Current.Type != TokenType.EndRow && Current.Type != TokenType.EndOfFile)
{
if (Current.Type == TokenType.Column)
{
columns.Add(ParseColumnDef());
}
else if (Current.Type == TokenType.NewLine)
{
Advance();
}
else
{
// Content outside of column definition - create implicit column
var implicitContent = new List<ITemplateNode>();
while (!IsAtEnd() &&
Current.Type != TokenType.Column &&
Current.Type != TokenType.EndRow &&
Current.Type != TokenType.NewLine &&
Current.Type != TokenType.EndOfFile)
{
var node = ParseNode();
if (node != null) implicitContent.Add(node);
}
if (implicitContent.Count > 0)
{
columns.Add(new ColumnNode(0, ColumnAlignment.Left, implicitContent, Current.Position));
}
}
}
if (Current.Type == TokenType.EndRow)
{
Advance(); // consume @endrow
}
else
{
AddError("Expected @endrow to close @row block", Current.Position);
}
return new RowNode(columns, rowToken.Position);
}
private ColumnNode ParseColumnDef()
{
var columnToken = Current;
var (width, alignment) = ColumnNode.ParseColumnSpec(columnToken.Value);
Advance(); // consume column token
var content = new List<ITemplateNode>();
// Parse content until next column, newline, or endrow
while (!IsAtEnd() &&
Current.Type != TokenType.Column &&
Current.Type != TokenType.EndRow &&
Current.Type != TokenType.NewLine &&
Current.Type != TokenType.EndOfFile)
{
var node = ParseNode();
if (node != null) content.Add(node);
}
return new ColumnNode(width, alignment, content, columnToken.Position);
}
private ColumnNode? ParseColumn()
{
// Standalone column outside of @row
var columnToken = Current;
var (width, alignment) = ColumnNode.ParseColumnSpec(columnToken.Value);
Advance();
var content = new List<ITemplateNode>();
while (!IsAtEnd() &&
Current.Type != TokenType.Column &&
Current.Type != TokenType.NewLine &&
Current.Type != TokenType.EndOfFile)
{
var node = ParseNode();
if (node != null) content.Add(node);
}
return new ColumnNode(width, alignment, content, columnToken.Position);
}
private void SkipNewlines()
{
while (Current.Type == TokenType.NewLine)
{
Advance();
}
}
private Token Current => _position < _tokens.Count ? _tokens[_position] : Token.Eof(_tokens.Count);
private bool IsAtEnd() => _position >= _tokens.Count || Current.Type == TokenType.EndOfFile;
private void Advance()
{
if (!IsAtEnd()) _position++;
}
private void AddError(string message, SourcePosition position)
{
_errors.Add(new ParseError(message, position));
}
}
public record ParseResult(TemplateNode Template, List<ParseError> Errors)
{
public bool HasErrors => Errors.Count > 0;
}
public record ParseError(string Message, SourcePosition Position);

View File

@@ -0,0 +1,44 @@
namespace Inspectron.Epson.Templates.Language;
public enum TokenType
{
// Text content
Text,
// Styled text: #style1,style2# text #
StyleStart, // #style1,style2#
StyleEnd, // trailing #
// Bindings: {path} or {path:format}
Binding,
// Separators
DashSeparator, // ---
EqualsSeparator, // ===
StarSeparator, // ***
TildeSeparator, // ~~~
// Directives
If, // @if condition
ElseIf, // @elseif condition
Else, // @else
End, // @end
Foreach, // @foreach item in collection
Row, // @row
EndRow, // @endrow
Column, // |width| or |width,align|
// Structural
NewLine,
EndOfFile
}
public record struct SourcePosition(int Line, int Column)
{
public override string ToString() => $"({Line}:{Column})";
}
public record Token(TokenType Type, string Value, SourcePosition Position)
{
public static Token Eof(int line) => new(TokenType.EndOfFile, string.Empty, new SourcePosition(line, 0));
}

View File

@@ -0,0 +1,91 @@
using System.Collections.Concurrent;
namespace Inspectron.Epson.Templates.Storage;
public class FileTemplateStorage : ITemplateStorage
{
private readonly string _basePath;
private readonly string _templateExtension;
private readonly ConcurrentDictionary<string, string> _cache = new();
public FileTemplateStorage(string basePath, string templateExtension = ".template")
{
_basePath = Path.GetFullPath(basePath);
_templateExtension = templateExtension;
if (!Directory.Exists(_basePath))
{
Directory.CreateDirectory(_basePath);
}
}
public string? Load(string templatePath)
{
var normalizedPath = NormalizePath(templatePath);
if (_cache.TryGetValue(normalizedPath, out var cached))
{
return cached;
}
var fullPath = GetFullPath(normalizedPath);
if (!File.Exists(fullPath))
{
return null;
}
var content = File.ReadAllText(fullPath);
_cache[normalizedPath] = content;
return content;
}
public bool Exists(string templatePath)
{
var normalizedPath = NormalizePath(templatePath);
var fullPath = GetFullPath(normalizedPath);
return File.Exists(fullPath);
}
public void Reload()
{
_cache.Clear();
}
public IEnumerable<string> ListTemplates()
{
if (!Directory.Exists(_basePath))
{
yield break;
}
var files = Directory.GetFiles(_basePath, $"*{_templateExtension}", SearchOption.AllDirectories);
foreach (var file in files)
{
var relativePath = Path.GetRelativePath(_basePath, file);
yield return relativePath.Replace('\\', '/');
}
}
private string NormalizePath(string path)
{
// Ensure the path has the correct extension
if (!path.EndsWith(_templateExtension, StringComparison.OrdinalIgnoreCase))
{
path += _templateExtension;
}
// Normalize path separators
return path.Replace('\\', '/');
}
private string GetFullPath(string normalizedPath)
{
// Security: prevent directory traversal
var fullPath = Path.GetFullPath(Path.Combine(_basePath, normalizedPath));
if (!fullPath.StartsWith(_basePath, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Invalid template path: {normalizedPath}");
}
return fullPath;
}
}

View File

@@ -0,0 +1,9 @@
namespace Inspectron.Epson.Templates.Storage;
public interface ITemplateStorage
{
string? Load(string templatePath);
bool Exists(string templatePath);
void Reload();
IEnumerable<string> ListTemplates();
}

View File

@@ -0,0 +1,115 @@
using System.Globalization;
using Inspectron.Epson.PrintServer.Printers.Utils;
using Inspectron.Epson.Templates.Configuration;
using Inspectron.Epson.Templates.Interpreter;
using Inspectron.Epson.Templates.Language;
using Inspectron.Epson.Templates.Language.Nodes;
using Inspectron.Epson.Templates.Storage;
using Inspectron.Epson.Templates.Validation;
namespace Inspectron.Epson.Templates;
public class TemplateEngine
{
private readonly ITemplateStorage _storage;
private readonly PrinterProfileRegistry _profileRegistry;
private readonly TemplateResolver _resolver;
private readonly CultureInfo? _culture;
public TemplateEngine(
ITemplateStorage storage,
PrinterProfileRegistry profileRegistry,
TemplateResolver resolver,
CultureInfo? culture = null)
{
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
_profileRegistry = profileRegistry ?? throw new ArgumentNullException(nameof(profileRegistry));
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_culture = culture;
}
public List<PrintCommand> Render(int receiptType, byte printerId, string jsonData)
{
var profile = _profileRegistry.GetProfile(printerId);
var templatePath = _resolver.Resolve(receiptType, profile.Id);
return RenderTemplate(templatePath, profile, jsonData);
}
public List<PrintCommand> Render(int receiptType, string profileId, string jsonData)
{
var profile = _profileRegistry.GetProfile(profileId);
var templatePath = _resolver.Resolve(receiptType, profileId);
return RenderTemplate(templatePath, profile, jsonData);
}
public List<PrintCommand> RenderTemplate(string templatePath, PrinterProfile profile, string jsonData)
{
var templateSource = _storage.Load(templatePath);
if (templateSource == null)
{
throw new FileNotFoundException($"Template not found: {templatePath}");
}
return RenderSource(templateSource, profile, jsonData);
}
public List<PrintCommand> RenderSource(string templateSource, PrinterProfile profile, string jsonData)
{
var template = Parse(templateSource);
var interpreter = new TemplateInterpreter(profile, _culture);
return interpreter.Interpret(template, jsonData);
}
public TemplateValidationResult Validate(string templateSource, PrinterProfile? profile = null)
{
var validator = new TemplateValidator(profile);
return validator.Validate(templateSource);
}
public TemplateValidationResult ValidateTemplate(string templatePath, PrinterProfile? profile = null)
{
var templateSource = _storage.Load(templatePath);
if (templateSource == null)
{
return TemplateValidationResult.WithErrors(
new TemplateError(
TemplateErrorCode.RTL001_UnexpectedToken,
$"Template not found: {templatePath}",
new SourcePosition(0, 0)));
}
return Validate(templateSource, profile);
}
public void ReloadTemplates()
{
_storage.Reload();
}
private TemplateNode Parse(string templateSource)
{
var lexer = new Lexer(templateSource);
var lexerResult = lexer.Tokenize();
if (lexerResult.HasErrors)
{
var firstError = lexerResult.Errors.First();
throw new InterpreterException(
$"Lexer error: {firstError.Message}",
firstError.Position);
}
var parser = new Parser(lexerResult.Tokens);
var parseResult = parser.Parse();
if (parseResult.HasErrors)
{
var firstError = parseResult.Errors.First();
throw new InterpreterException(
$"Parse error: {firstError.Message}",
firstError.Position);
}
return parseResult.Template;
}
}

View File

@@ -0,0 +1,23 @@
using Inspectron.Epson.PrintServer.Printers.Utils;
using Inspectron.Epson.Templates.Configuration;
namespace Inspectron.Epson.Templates;
public class TemplateReceiptConverter : IReceiptConverter
{
private readonly TemplateEngine _engine;
private readonly int _receiptType;
private readonly PrinterProfile _profile;
public TemplateReceiptConverter(TemplateEngine engine, int receiptType, PrinterProfile profile)
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
_receiptType = receiptType;
_profile = profile ?? throw new ArgumentNullException(nameof(profile));
}
public List<PrintCommand> Convert(string jsonContent)
{
return _engine.Render(_receiptType, _profile.Id, jsonContent);
}
}

View File

@@ -0,0 +1,22 @@
using Inspectron.Epson.PrintServer.Printers.Utils;
using Inspectron.Epson.Templates.Configuration;
namespace Inspectron.Epson.Templates;
public class TemplateReceiptConverterFactory : IReceiptConverterFactory
{
private readonly TemplateEngine _engine;
private readonly PrinterProfileRegistry _profileRegistry;
public TemplateReceiptConverterFactory(TemplateEngine engine, PrinterProfileRegistry profileRegistry)
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
_profileRegistry = profileRegistry ?? throw new ArgumentNullException(nameof(profileRegistry));
}
public IReceiptConverter Create(int receiptType, byte printerId)
{
var profile = _profileRegistry.GetProfile(printerId);
return new TemplateReceiptConverter(_engine, receiptType, profile);
}
}

View File

@@ -0,0 +1,25 @@
{
"assignments": [
{
"receiptType": 1,
"profileId": "tm-t30iii",
"template": "kitchen-default.template"
},
{
"receiptType": 1,
"profileId": "tm-u220ii",
"template": "kitchen-u220.template"
},
{
"receiptType": 1,
"profileId": null,
"template": "kitchen-default.template"
},
{
"receiptType": 2,
"profileId": null,
"template": "bar-default.template"
}
],
"fallbackTemplate": "fallback.template"
}

View File

@@ -0,0 +1,59 @@
#red,big,center# {Title} #
---
#center# {TransactionDateTime:dd-MMM-yy HH:mm} Nr.:{ReceiptNumber} #
#center# {WaiterName} #
#center# {WaiterId} #
#big,bold,center# Tisch: {TableNumber} #
@if SpecialInstruction
#big,bold,center# {SpecialInstruction} #
@end
---
@foreach gang in Gangs
#red,center# {gang.Id}. {gang.Name} #
@foreach drink in gang.Dishes
{drink.Number}x {drink.Name}
@if drink.Modifications.Removed.count > 0
@foreach removed in drink.Modifications.Removed
#bold# - {removed} #
@end
@end
@if drink.Modifications.Added.count > 0
@foreach added in drink.Modifications.Added
#bold# + {added} #
@end
@end
@if drink.Comment
#bold# Comment: {drink.Comment} #
@end
@end
@end
@if Gangs.count > 0
---
@end
@foreach drink in Dishes
{drink.Number}x {drink.Name}
@if drink.Modifications.Removed.count > 0
@foreach removed in drink.Modifications.Removed
#bold# - {removed} #
@end
@end
@if drink.Modifications.Added.count > 0
@foreach added in drink.Modifications.Added
#bold# + {added} #
@end
@end
@if drink.Comment
#bold# Comment: {drink.Comment} #
@end
@end
@if Dishes.count > 0
---
@end

View File

@@ -0,0 +1,9 @@
#center# Receipt #
---
@if Title
{Title}
@end
@if TransactionDateTime
{TransactionDateTime:dd-MMM-yy HH:mm}
@end
---

View File

@@ -0,0 +1,57 @@
#red,big,tall,center# {Title} #
---
#center# {TransactionDateTime:dd-MMM-yy HH:mm} Nr.:{ReceiptNumber} #
#center# {WaiterName} #
#center# {WaiterId} #
#big,bold,center# Tisch: {TableNumber} #
@if SpecialInstruction
#big,bold,center# {SpecialInstruction} #
@end
---
@foreach gang in Gangs
#red,big,tall,center# {gang.Id}. {gang.Name} #
@foreach dish in gang.Dishes
#tall# {dish.Number}x {dish.Name} #
@if dish.Modifications.Removed.count > 0
@foreach removed in dish.Modifications.Removed
#bold,tall# - {removed} #
@end
@end
@if dish.Modifications.Added.count > 0
@foreach added in dish.Modifications.Added
#bold,tall# + {added} #
@end
@end
@if dish.Comment
#bold,tall# Comment: {dish.Comment} #
@end
@end
@end
@if Gangs.count > 0
---
@end
@foreach dish in Dishes
#tall# {dish.Number}x {dish.Name} #
@if dish.Modifications.Removed.count > 0
@foreach removed in dish.Modifications.Removed
#bold,tall# - {removed} #
@end
@end
@if dish.Modifications.Added.count > 0
@foreach added in dish.Modifications.Added
#bold,tall# + {added} #
@end
@end
@if dish.Comment
#bold,tall# Comment: {dish.Comment} #
@end
@end
@if Dishes.count > 0
---
@end

View File

@@ -0,0 +1,57 @@
#red,big,tall,center# {Title} #
---
#center# {TransactionDateTime:dd-MMM-yy HH:mm} Nr.:{ReceiptNumber} #
#center# {WaiterName} #
#center# {WaiterId} #
#big,bold,center# Tisch: {TableNumber} #
@if SpecialInstruction
#big,bold,center# {SpecialInstruction} #
@end
---
@foreach gang in Gangs
#red,big,tall,center# {gang.Id}. {gang.Name} #
@foreach dish in gang.Dishes
#tall# {dish.Number}x {dish.Name} #
@if dish.Modifications.Removed.count > 0
@foreach removed in dish.Modifications.Removed
#bold,tall# - {removed} #
@end
@end
@if dish.Modifications.Added.count > 0
@foreach added in dish.Modifications.Added
#bold,tall# + {added} #
@end
@end
@if dish.Comment
#bold,tall# Comment: {dish.Comment} #
@end
@end
@end
@if Gangs.count > 0
---
@end
@foreach dish in Dishes
#tall# {dish.Number}x {dish.Name} #
@if dish.Modifications.Removed.count > 0
@foreach removed in dish.Modifications.Removed
#bold,tall# - {removed} #
@end
@end
@if dish.Modifications.Added.count > 0
@foreach added in dish.Modifications.Added
#bold,tall# + {added} #
@end
@end
@if dish.Comment
#bold,tall# Comment: {dish.Comment} #
@end
@end
@if Dishes.count > 0
---
@end

View File

@@ -0,0 +1,28 @@
using Inspectron.Epson.Templates.Language;
namespace Inspectron.Epson.Templates.Validation;
public enum TemplateErrorCode
{
// Syntax errors (RTL001-RTL010)
RTL001_UnexpectedToken = 1,
RTL002_UnclosedBlock = 2,
RTL003_InvalidDirective = 3,
RTL004_InvalidBinding = 4,
RTL005_InvalidForeachSyntax = 5,
RTL006_InvalidCondition = 6,
RTL007_UnclosedStyleBlock = 7,
RTL008_InvalidColumnSpec = 8,
RTL009_InvalidSeparator = 9,
RTL010_UnexpectedEndOfFile = 10
}
public record TemplateError(
TemplateErrorCode Code,
string Message,
SourcePosition Position)
{
public string CodeString => Code.ToString().Split('_')[0];
public override string ToString() => $"{CodeString}: {Message} at {Position}";
}

View File

@@ -0,0 +1,35 @@
namespace Inspectron.Epson.Templates.Validation;
public record TemplateValidationResult(
List<TemplateError> Errors,
List<TemplateWarning> Warnings)
{
public bool IsValid => Errors.Count == 0;
public bool HasWarnings => Warnings.Count > 0;
public static TemplateValidationResult Valid() => new(new List<TemplateError>(), new List<TemplateWarning>());
public static TemplateValidationResult WithErrors(params TemplateError[] errors) =>
new(errors.ToList(), new List<TemplateWarning>());
public override string ToString()
{
if (IsValid && !HasWarnings)
{
return "Template is valid.";
}
var lines = new List<string>();
if (Errors.Count > 0)
{
lines.Add($"Errors ({Errors.Count}):");
lines.AddRange(Errors.Select(e => $" {e}"));
}
if (Warnings.Count > 0)
{
lines.Add($"Warnings ({Warnings.Count}):");
lines.AddRange(Warnings.Select(w => $" {w}"));
}
return string.Join(Environment.NewLine, lines);
}
}

View File

@@ -0,0 +1,228 @@
using Inspectron.Epson.Templates.Configuration;
using Inspectron.Epson.Templates.Language;
using Inspectron.Epson.Templates.Language.Nodes;
namespace Inspectron.Epson.Templates.Validation;
public class TemplateValidator
{
private readonly PrinterProfile? _profile;
private readonly List<TemplateError> _errors = new();
private readonly List<TemplateWarning> _warnings = new();
private readonly HashSet<string> _definedVariables = new();
public TemplateValidator(PrinterProfile? profile = null)
{
_profile = profile;
}
public TemplateValidationResult Validate(string templateSource)
{
_errors.Clear();
_warnings.Clear();
_definedVariables.Clear();
// Lexer phase
var lexer = new Lexer(templateSource);
var lexerResult = lexer.Tokenize();
foreach (var error in lexerResult.Errors)
{
_errors.Add(new TemplateError(
TemplateErrorCode.RTL007_UnclosedStyleBlock,
error.Message,
error.Position));
}
// Parser phase
var parser = new Parser(lexerResult.Tokens);
var parseResult = parser.Parse();
foreach (var error in parseResult.Errors)
{
var code = MapParseError(error.Message);
_errors.Add(new TemplateError(code, error.Message, error.Position));
}
// Semantic validation
if (_errors.Count == 0)
{
ValidateNode(parseResult.Template);
}
return new TemplateValidationResult(_errors.ToList(), _warnings.ToList());
}
private void ValidateNode(ITemplateNode node)
{
switch (node)
{
case TemplateNode template:
foreach (var child in template.ChildNodes)
{
ValidateNode(child);
}
break;
case IfNode ifNode:
ValidateCondition(ifNode.IfBranch.Condition, ifNode.Position);
if (ifNode.IfBranch.Body.Count == 0)
{
_warnings.Add(new TemplateWarning(
TemplateWarningCode.RTL100_EmptyBlock,
"Empty @if block body",
ifNode.Position));
}
foreach (var child in ifNode.IfBranch.Body)
{
ValidateNode(child);
}
foreach (var branch in ifNode.ElseIfBranches)
{
ValidateCondition(branch.Condition, branch.Position);
foreach (var child in branch.Body)
{
ValidateNode(child);
}
}
if (ifNode.ElseBranch != null)
{
foreach (var child in ifNode.ElseBranch)
{
ValidateNode(child);
}
}
break;
case ForeachNode foreachNode:
_definedVariables.Add(foreachNode.ItemVariable);
if (foreachNode.Body.Count == 0)
{
_warnings.Add(new TemplateWarning(
TemplateWarningCode.RTL100_EmptyBlock,
"Empty @foreach block body",
foreachNode.Position));
}
foreach (var child in foreachNode.Body)
{
ValidateNode(child);
}
_definedVariables.Remove(foreachNode.ItemVariable);
break;
case RowNode rowNode:
ValidateRowWidth(rowNode);
foreach (var column in rowNode.Columns)
{
ValidateNode(column);
}
break;
case ColumnNode columnNode:
foreach (var child in columnNode.ContentNodes)
{
ValidateNode(child);
}
break;
case StyledTextNode styledNode:
ValidateStyles(styledNode.Styles, styledNode.Position);
foreach (var child in styledNode.ContentNodes)
{
ValidateNode(child);
}
break;
case BindingNode bindingNode:
ValidateBinding(bindingNode);
break;
case TextNode:
case SeparatorNode:
case EmptyLineNode:
// No additional validation needed
break;
}
}
private void ValidateCondition(string condition, SourcePosition position)
{
if (string.IsNullOrWhiteSpace(condition))
{
_errors.Add(new TemplateError(
TemplateErrorCode.RTL006_InvalidCondition,
"Empty condition expression",
position));
}
}
private void ValidateBinding(BindingNode node)
{
if (string.IsNullOrWhiteSpace(node.Path))
{
_errors.Add(new TemplateError(
TemplateErrorCode.RTL004_InvalidBinding,
"Empty binding path",
node.Position));
}
}
private void ValidateStyles(IReadOnlyList<string> styles, SourcePosition position)
{
var validStyles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"bold", "big", "tall", "red", "center", "right", "left"
};
foreach (var style in styles)
{
var styleName = style.ToLowerInvariant();
if (styleName.StartsWith("spacing:"))
{
var spacingValue = styleName[8..];
if (!int.TryParse(spacingValue, out _))
{
_warnings.Add(new TemplateWarning(
TemplateWarningCode.RTL102_PossibleNullReference,
$"Invalid spacing value: '{spacingValue}'",
position));
}
continue;
}
if (!validStyles.Contains(styleName))
{
_warnings.Add(new TemplateWarning(
TemplateWarningCode.RTL102_PossibleNullReference,
$"Unknown style: '{style}'",
position));
}
}
}
private void ValidateRowWidth(RowNode row)
{
if (_profile == null) return;
var totalWidth = row.Columns.Sum(c => c.Width);
if (totalWidth > _profile.LineWidth)
{
_warnings.Add(new TemplateWarning(
TemplateWarningCode.RTL103_LineWidthExceeded,
$"Row total width ({totalWidth}) exceeds printer line width ({_profile.LineWidth})",
row.Position));
}
}
private TemplateErrorCode MapParseError(string message)
{
if (message.Contains("@end"))
return TemplateErrorCode.RTL002_UnclosedBlock;
if (message.Contains("foreach"))
return TemplateErrorCode.RTL005_InvalidForeachSyntax;
if (message.Contains("unexpected", StringComparison.OrdinalIgnoreCase))
return TemplateErrorCode.RTL001_UnexpectedToken;
return TemplateErrorCode.RTL001_UnexpectedToken;
}
}

View File

@@ -0,0 +1,22 @@
using Inspectron.Epson.Templates.Language;
namespace Inspectron.Epson.Templates.Validation;
public enum TemplateWarningCode
{
// Semantic warnings (RTL100-RTL103)
RTL100_EmptyBlock = 100,
RTL101_UnusedVariable = 101,
RTL102_PossibleNullReference = 102,
RTL103_LineWidthExceeded = 103
}
public record TemplateWarning(
TemplateWarningCode Code,
string Message,
SourcePosition Position)
{
public string CodeString => Code.ToString().Split('_')[0];
public override string ToString() => $"{CodeString}: {Message} at {Position}";
}