diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 4a1edf1..88fe641 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -1,7 +1,13 @@
{
"permissions": {
"allow": [
- "Bash(grep:*)"
+ "Bash(grep:*)",
+ "Bash(xargs:*)",
+ "Bash(find:*)",
+ "Bash(tree:*)",
+ "Bash(wc:*)",
+ "Bash(dotnet build:*)",
+ "Bash(dotnet test:*)"
]
}
}
diff --git a/EpsonTemplatesTest/EpsonTemplatesTest.csproj b/EpsonTemplatesTest/EpsonTemplatesTest.csproj
new file mode 100644
index 0000000..4a4c1b5
--- /dev/null
+++ b/EpsonTemplatesTest/EpsonTemplatesTest.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/EpsonTemplatesTest/Program.cs b/EpsonTemplatesTest/Program.cs
new file mode 100644
index 0000000..b082c11
--- /dev/null
+++ b/EpsonTemplatesTest/Program.cs
@@ -0,0 +1,100 @@
+using Inspectron.Epson;
+using Inspectron.Epson.PrintServer.Printers.Utils;
+using Inspectron.Epson.PrintServer.Printers.Utils.KitchenReceipt;
+using Inspectron.Epson.Templates.Configuration;
+using Inspectron.Epson.Templates.Interpreter;
+using Inspectron.Epson.Templates.Language;
+using System.Text.Json;
+
+var receipt = new KitchenReceipt
+{
+ Title = "Warme Küche",
+ TransactionDateTime = new DateTime(2025, 10, 23, 12, 18, 0),
+ ReceiptNumber = "132018166",
+ WaiterName = "Mariano Amato",
+ WaiterId = "32 (VK Restaurant)",
+ TableNumber = "22",
+ SpecialInstruction = "Next dish"
+};
+
+// Add dishes (this section can be empty if no dishes were ordered)
+
+receipt.Gangs.Add(new Gang(2, "Gang"));
+
+receipt.Gangs[0].Dishes.Add(new Dish(1, "Avocado Sashimi")
+{
+ Modifications = new DishModifications
+ {
+ Removed = new List { "Wasabi" },
+ Added = new List { "Extra Ginger" }
+ },
+ Comment = "No soy sauce"
+});
+receipt.Gangs[0].Dishes.Add(new Dish(1, "Baby Spinach Salad with Truffl"));
+receipt.Gangs[0].Dishes.Add(new Dish(1, "Beef Tataki")
+{
+ Modifications = new DishModifications
+ {
+ Removed = new List { "Onion" },
+ Added = new List { "Extra Sauce" }
+ },
+ Comment = "Medium rare"
+});
+receipt.Gangs[0].Dishes.Add(new Dish(1, "Salmon Taco")
+{
+ Modifications = new DishModifications
+ {
+ Added = new List { "Extra Lime" }
+ }
+});
+
+// Add additional info
+
+var serializedReceipt = JsonSerializer.Serialize(receipt, new JsonSerializerOptions { WriteIndented = true });
+
+var lexer = new Lexer(File.ReadAllText("kitchen-u220.template"));
+var parser = new Parser(lexer.Tokenize().Tokens);
+var template = parser.Parse().Template;
+PrinterProfile _profileT30 = new("tm-t30iii", "TM-T30III", 48, 24, false);
+PrinterProfile _profile220 = new("tm-220", "TM-220", 33, 18, true);
+
+var interpreter = new TemplateInterpreter(_profile220);
+var printCommands = interpreter.Interpret(template, serializedReceipt);
+Console.WriteLine("=== PRINT COMMANDS ===\n");
+var printer = new HtmlPrinter(@".\test.html", paperWidth: 258);
+await printer.ConnectAsync("");
+bool useDelay = false;
+//var printer = new EpsonPrinter();
+//await printer.ConnectAsync("127.0.0.1", 8888);
+if (useDelay) await Task.Delay(200);
+await printer.FeedLinesAsync(1);
+//await printer.SetCustomLineSpacing(22);
+foreach (var command in printCommands)
+{
+ string attributes = "";
+ if (command.IsBig) attributes += "[BIG] ";
+ if (command.IsBold) attributes += "[BOLD] ";
+ if (command.IsRed) attributes += "[RED] ";
+
+ Console.WriteLine($"{attributes}{command.Text}");
+ await printer.SetBiggerFontTM220(command.IsBig, command.IsTall , secondaryFont: false);
+
+
+
+ if (useDelay) await Task.Delay(200);
+
+ await printer.SetRedColor(command.IsRed);
+
+ if (useDelay) await Task.Delay(200);
+
+ await printer.SetEmphasized(command.IsBold);
+ if (useDelay) await Task.Delay(200);
+
+ await printer.PrintTextAsync(command.Text + "\n");
+ if (useDelay) await Task.Delay(200);
+
+}
+
+await printer.FeedLinesAsync(5);
+if (useDelay) await Task.Delay(200 * 5);
+await printer.CutAsync();
\ No newline at end of file
diff --git a/EpsonTemplatesTest/kitchen-u220.template b/EpsonTemplatesTest/kitchen-u220.template
new file mode 100644
index 0000000..fe52d4a
--- /dev/null
+++ b/EpsonTemplatesTest/kitchen-u220.template
@@ -0,0 +1,57 @@
+#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,big,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
diff --git a/EpsonTest/Program.cs b/EpsonTest/Program.cs
index bcd250d..fe1a323 100644
--- a/EpsonTest/Program.cs
+++ b/EpsonTest/Program.cs
@@ -378,7 +378,7 @@ receipt.Gangs[0].Dishes.Add(new Dish(1, "Salmon Taco")
var serializedReceipt = JsonSerializer.Serialize(receipt, new JsonSerializerOptions { WriteIndented = true });
var deserializedReceipt = JsonSerializer.Deserialize(serializedReceipt);
-KitchenReceiptConverter converter = new KitchenReceiptConverter(bigLineWidth: 12, lineWidth: 33);
+KitchenReceiptConverter converter = new KitchenReceiptConverter(bigLineWidth: 18, lineWidth: 33);
var printCommands = converter.ConvertToPrintCommands(deserializedReceipt);
Console.WriteLine("=== PRINT COMMANDS ===\n");
@@ -398,7 +398,7 @@ foreach (var command in printCommands)
if (command.IsRed) attributes += "[RED] ";
Console.WriteLine($"{attributes}{command.Text}");
- await printer.SetBiggerFontTM220(command.IsBig, command.IsTall, secondaryFont: false);
+ await printer.SetBiggerFontTM220(command.IsBig, command.IsTall| command.IsBig, secondaryFont: false);
diff --git a/Inspectron.Epson.Templates.Tests/Configuration/PrinterProfileTests.cs b/Inspectron.Epson.Templates.Tests/Configuration/PrinterProfileTests.cs
new file mode 100644
index 0000000..21a1455
--- /dev/null
+++ b/Inspectron.Epson.Templates.Tests/Configuration/PrinterProfileTests.cs
@@ -0,0 +1,95 @@
+using Inspectron.Epson.Templates.Configuration;
+
+namespace Inspectron.Epson.Templates.Tests.Configuration;
+
+public class PrinterProfileTests
+{
+ [Fact]
+ public void Default_Profile_HasExpectedValues()
+ {
+ var profile = PrinterProfile.Default;
+
+ Assert.Equal("default", profile.Id);
+ Assert.Equal(48, profile.LineWidth);
+ Assert.Equal(24, profile.BigLineWidth);
+ Assert.False(profile.SupportsRed);
+ }
+}
+
+public class PrinterProfileRegistryTests
+{
+ [Fact]
+ public void GetProfile_TmT30III_ReturnsCorrectProfile()
+ {
+ var registry = new PrinterProfileRegistry();
+ var profile = registry.GetProfile(0x01);
+
+ Assert.Equal("tm-t30iii", profile.Id);
+ Assert.Equal(48, profile.LineWidth);
+ Assert.Equal(24, profile.BigLineWidth);
+ Assert.False(profile.SupportsRed);
+ }
+
+ [Fact]
+ public void GetProfile_TmU220II_0x0D_ReturnsCorrectProfile()
+ {
+ var registry = new PrinterProfileRegistry();
+ var profile = registry.GetProfile(0x0D);
+
+ Assert.Equal("tm-u220ii", profile.Id);
+ Assert.Equal(33, profile.LineWidth);
+ Assert.Equal(20, profile.BigLineWidth);
+ Assert.True(profile.SupportsRed);
+ }
+
+ [Fact]
+ public void GetProfile_TmU220II_0x13_ReturnsCorrectProfile()
+ {
+ var registry = new PrinterProfileRegistry();
+ var profile = registry.GetProfile(0x13);
+
+ Assert.Equal("tm-u220ii", profile.Id);
+ Assert.Equal(33, profile.LineWidth);
+ Assert.Equal(20, profile.BigLineWidth);
+ Assert.True(profile.SupportsRed);
+ }
+
+ [Fact]
+ public void GetProfile_UnknownPrinter_ReturnsDefault()
+ {
+ var registry = new PrinterProfileRegistry();
+ var profile = registry.GetProfile(0xFF);
+
+ Assert.Equal(PrinterProfile.Default.LineWidth, profile.LineWidth);
+ }
+
+ [Fact]
+ public void GetProfile_ByName_ReturnsCorrectProfile()
+ {
+ var registry = new PrinterProfileRegistry();
+ var profile = registry.GetProfile("tm-t30iii");
+
+ Assert.Equal("tm-t30iii", profile.Id);
+ }
+
+ [Fact]
+ public void Register_CustomProfile_CanBeRetrieved()
+ {
+ var registry = new PrinterProfileRegistry();
+ var customProfile = new PrinterProfile("custom", "Custom Printer", 40, 20, true);
+ registry.Register(0xAA, customProfile);
+
+ var retrieved = registry.GetProfile(0xAA);
+ Assert.Equal("custom", retrieved.Id);
+ }
+
+ [Fact]
+ public void GetAllProfiles_ReturnsDistinctProfiles()
+ {
+ var registry = new PrinterProfileRegistry();
+ var profiles = registry.GetAllProfiles().ToList();
+
+ // Should have TM-T30III and TM-U220II (distinct)
+ Assert.Equal(2, profiles.Count);
+ }
+}
diff --git a/Inspectron.Epson.Templates.Tests/Configuration/TemplateResolverTests.cs b/Inspectron.Epson.Templates.Tests/Configuration/TemplateResolverTests.cs
new file mode 100644
index 0000000..26098bd
--- /dev/null
+++ b/Inspectron.Epson.Templates.Tests/Configuration/TemplateResolverTests.cs
@@ -0,0 +1,100 @@
+using Inspectron.Epson.Templates.Configuration;
+
+namespace Inspectron.Epson.Templates.Tests.Configuration;
+
+public class TemplateResolverTests
+{
+ [Fact]
+ public void Resolve_ExactMatch_ReturnsExactTemplate()
+ {
+ var assignments = new[]
+ {
+ new TemplateAssignment(1, "tm-t30iii", "kitchen-t30.template"),
+ new TemplateAssignment(1, null, "kitchen-default.template")
+ };
+ var resolver = new TemplateResolver(assignments);
+
+ var result = resolver.Resolve(1, "tm-t30iii");
+
+ Assert.Equal("kitchen-t30.template", result);
+ }
+
+ [Fact]
+ public void Resolve_TypeOnlyMatch_ReturnsTypeTemplate()
+ {
+ var assignments = new[]
+ {
+ new TemplateAssignment(1, null, "kitchen-default.template"),
+ new TemplateAssignment(2, null, "bar-default.template")
+ };
+ var resolver = new TemplateResolver(assignments);
+
+ var result = resolver.Resolve(1, "unknown-profile");
+
+ Assert.Equal("kitchen-default.template", result);
+ }
+
+ [Fact]
+ public void Resolve_NoMatch_ReturnsFallback()
+ {
+ var assignments = new[]
+ {
+ new TemplateAssignment(1, null, "kitchen-default.template")
+ };
+ var resolver = new TemplateResolver(assignments, "fallback.template");
+
+ var result = resolver.Resolve(99, "unknown-profile");
+
+ Assert.Equal("fallback.template", result);
+ }
+
+ [Fact]
+ public void Resolve_PreferExactOverTypeOnly()
+ {
+ var assignments = new[]
+ {
+ new TemplateAssignment(1, null, "type-only.template"),
+ new TemplateAssignment(1, "tm-t30iii", "exact.template")
+ };
+ var resolver = new TemplateResolver(assignments);
+
+ var result = resolver.Resolve(1, "tm-t30iii");
+
+ Assert.Equal("exact.template", result);
+ }
+
+ [Fact]
+ public void Resolve_WithRegistry_MapsProfileCorrectly()
+ {
+ var registry = new PrinterProfileRegistry();
+ var assignments = new[]
+ {
+ new TemplateAssignment(1, "tm-t30iii", "kitchen-t30.template"),
+ new TemplateAssignment(1, "tm-u220ii", "kitchen-u220.template")
+ };
+ var resolver = new TemplateResolver(assignments);
+
+ var result = resolver.Resolve(1, 0x01, registry);
+
+ Assert.Equal("kitchen-t30.template", result);
+ }
+
+ [Fact]
+ public void LoadConfiguration_ParsesCorrectly()
+ {
+ var json = """
+ {
+ "assignments": [
+ {"receiptType": 1, "profileId": "tm-t30iii", "template": "kitchen.template"},
+ {"receiptType": 2, "profileId": null, "template": "bar.template"}
+ ],
+ "fallbackTemplate": "default.template"
+ }
+ """;
+
+ var config = TemplateConfiguration.LoadFromJson(json);
+
+ Assert.Equal(2, config.Assignments.Count);
+ Assert.Equal("default.template", config.FallbackTemplate);
+ }
+}
diff --git a/Inspectron.Epson.Templates.Tests/Helpers/PrintCommandAssertions.cs b/Inspectron.Epson.Templates.Tests/Helpers/PrintCommandAssertions.cs
new file mode 100644
index 0000000..e93a14a
--- /dev/null
+++ b/Inspectron.Epson.Templates.Tests/Helpers/PrintCommandAssertions.cs
@@ -0,0 +1,83 @@
+using Inspectron.Epson.PrintServer.Printers.Utils;
+
+namespace Inspectron.Epson.Templates.Tests.Helpers;
+
+public static class PrintCommandAssertions
+{
+ public static void AssertCommandExists(
+ List commands,
+ string textContains,
+ bool? isBold = null,
+ bool? isBig = null,
+ bool? isTall = null,
+ bool? isRed = null)
+ {
+ var matching = commands.Where(c => c.Text.Contains(textContains)).ToList();
+
+ if (matching.Count == 0)
+ {
+ throw new Xunit.Sdk.XunitException($"No command found containing text: '{textContains}'");
+ }
+
+ foreach (var cmd in matching)
+ {
+ if (isBold.HasValue && cmd.IsBold != isBold.Value)
+ {
+ throw new Xunit.Sdk.XunitException(
+ $"Command with text '{textContains}' has IsBold={cmd.IsBold}, expected {isBold.Value}");
+ }
+ if (isBig.HasValue && cmd.IsBig != isBig.Value)
+ {
+ throw new Xunit.Sdk.XunitException(
+ $"Command with text '{textContains}' has IsBig={cmd.IsBig}, expected {isBig.Value}");
+ }
+ if (isTall.HasValue && cmd.IsTall != isTall.Value)
+ {
+ throw new Xunit.Sdk.XunitException(
+ $"Command with text '{textContains}' has IsTall={cmd.IsTall}, expected {isTall.Value}");
+ }
+ if (isRed.HasValue && cmd.IsRed != isRed.Value)
+ {
+ throw new Xunit.Sdk.XunitException(
+ $"Command with text '{textContains}' has IsRed={cmd.IsRed}, expected {isRed.Value}");
+ }
+ }
+ }
+
+ public static void AssertSequence(List commands, params string[] expectedTexts)
+ {
+ var commandTexts = commands.Select(c => c.Text).ToList();
+ int searchIndex = 0;
+
+ foreach (var expected in expectedTexts)
+ {
+ var foundIndex = -1;
+ for (int i = searchIndex; i < commandTexts.Count; i++)
+ {
+ if (commandTexts[i].Contains(expected))
+ {
+ foundIndex = i;
+ break;
+ }
+ }
+
+ if (foundIndex < 0)
+ {
+ throw new Xunit.Sdk.XunitException(
+ $"Expected text '{expected}' not found in sequence after index {searchIndex}");
+ }
+
+ searchIndex = foundIndex + 1;
+ }
+ }
+
+ public static void AssertCommandCount(List commands, string textContains, int expectedCount)
+ {
+ var count = commands.Count(c => c.Text.Contains(textContains));
+ if (count != expectedCount)
+ {
+ throw new Xunit.Sdk.XunitException(
+ $"Expected {expectedCount} commands containing '{textContains}', found {count}");
+ }
+ }
+}
diff --git a/Inspectron.Epson.Templates.Tests/Inspectron.Epson.Templates.Tests.csproj b/Inspectron.Epson.Templates.Tests/Inspectron.Epson.Templates.Tests.csproj
new file mode 100644
index 0000000..cc9a729
--- /dev/null
+++ b/Inspectron.Epson.Templates.Tests/Inspectron.Epson.Templates.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Inspectron.Epson.Templates.Tests/Integration/KitchenReceiptTests.cs b/Inspectron.Epson.Templates.Tests/Integration/KitchenReceiptTests.cs
new file mode 100644
index 0000000..f3d9e45
--- /dev/null
+++ b/Inspectron.Epson.Templates.Tests/Integration/KitchenReceiptTests.cs
@@ -0,0 +1,193 @@
+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.Tests.Helpers;
+
+namespace Inspectron.Epson.Templates.Tests.Integration;
+
+public class KitchenReceiptTests
+{
+ private readonly PrinterProfile _profile = new("tm-t30iii", "TM-T30III", 48, 24, false);
+
+ private const string KitchenReceiptJson = """
+ {
+ "Title": "Restaurant Test",
+ "TransactionDateTime": "2024-03-15T14:30:00",
+ "ReceiptNumber": "12345",
+ "WaiterName": "John Doe",
+ "WaiterId": "W001",
+ "TableNumber": "5",
+ "SpecialInstruction": "Rush Order",
+ "Gangs": [
+ {
+ "Id": 1,
+ "Name": "Starters",
+ "Dishes": [
+ {
+ "Number": 2,
+ "Name": "Caesar Salad",
+ "Modifications": {
+ "Removed": ["Croutons"],
+ "Added": ["Extra Dressing"]
+ },
+ "Comment": "No anchovies"
+ }
+ ]
+ }
+ ],
+ "Dishes": [
+ {
+ "Number": 1,
+ "Name": "Grilled Salmon",
+ "Modifications": {
+ "Removed": [],
+ "Added": []
+ },
+ "Comment": null
+ }
+ ]
+ }
+ """;
+
+ private const string KitchenTemplate = """
+ #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} #
+ @end
+ """;
+
+ private TemplateNode Parse(string source)
+ {
+ var lexer = new Lexer(source);
+ var parser = new Parser(lexer.Tokenize().Tokens);
+ return parser.Parse().Template;
+ }
+
+ [Fact]
+ public void KitchenReceipt_RendersFully()
+ {
+ var template = Parse(KitchenTemplate);
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, KitchenReceiptJson);
+
+ // Verify key elements are present
+ Assert.Contains(commands, c => c.Text.Contains("Restaurant Test"));
+ Assert.Contains(commands, c => c.Text.Contains("John Doe"));
+ Assert.Contains(commands, c => c.Text.Contains("Tisch: 5"));
+ Assert.Contains(commands, c => c.Text.Contains("Rush Order"));
+ Assert.Contains(commands, c => c.Text.Contains("1. Starters"));
+ Assert.Contains(commands, c => c.Text.Contains("2x Caesar Salad"));
+ Assert.Contains(commands, c => c.Text.Contains("- Croutons"));
+ Assert.Contains(commands, c => c.Text.Contains("+ Extra Dressing"));
+ Assert.Contains(commands, c => c.Text.Contains("Comment: No anchovies"));
+ Assert.Contains(commands, c => c.Text.Contains("1x Grilled Salmon"));
+ }
+
+ [Fact]
+ public void KitchenReceipt_AppliesStyles()
+ {
+ var template = Parse(KitchenTemplate);
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, KitchenReceiptJson);
+
+ // Title should be big and tall
+ var titleCmd = commands.FirstOrDefault(c => c.Text.Contains("Restaurant Test"));
+ Assert.NotNull(titleCmd);
+ Assert.True(titleCmd.IsBig);
+ Assert.True(titleCmd.IsTall);
+
+ // Table should be bold and big
+ var tableCmd = commands.FirstOrDefault(c => c.Text.Contains("Tisch: 5"));
+ Assert.NotNull(tableCmd);
+ Assert.True(tableCmd.IsBold);
+ Assert.True(tableCmd.IsBig);
+
+ // Modifications should be bold and tall
+ var modCmd = commands.FirstOrDefault(c => c.Text.Contains("- Croutons"));
+ Assert.NotNull(modCmd);
+ Assert.True(modCmd.IsBold);
+ Assert.True(modCmd.IsTall);
+ }
+
+ [Fact]
+ public void KitchenReceipt_GeneratesSeparators()
+ {
+ var template = Parse(KitchenTemplate);
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, KitchenReceiptJson);
+
+ var separatorCount = commands.Count(c => c.Text.All(ch => ch == '-') && c.Text.Length == 48);
+ Assert.True(separatorCount >= 2, $"Expected at least 2 separators, found {separatorCount}");
+ }
+
+ [Fact]
+ public void KitchenReceipt_FormatsDateTime()
+ {
+ var template = Parse(KitchenTemplate);
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, KitchenReceiptJson);
+
+ Assert.Contains(commands, c => c.Text.Contains("15-Mar-24") || c.Text.Contains("15-Mär-24"));
+ }
+
+ [Fact]
+ public void KitchenReceipt_WithoutSpecialInstruction_OmitsSection()
+ {
+ var jsonWithoutInstruction = """
+ {
+ "Title": "Test",
+ "TransactionDateTime": "2024-03-15T14:30:00",
+ "ReceiptNumber": "123",
+ "WaiterName": "John",
+ "WaiterId": "W001",
+ "TableNumber": "1",
+ "SpecialInstruction": null,
+ "Gangs": [],
+ "Dishes": []
+ }
+ """;
+
+ var template = Parse(KitchenTemplate);
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, jsonWithoutInstruction);
+
+ // Should not have any content that looks like special instruction
+ Assert.DoesNotContain(commands, c => c.Text.Contains("Rush"));
+ }
+}
diff --git a/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterBindingTests.cs b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterBindingTests.cs
new file mode 100644
index 0000000..422bf66
--- /dev/null
+++ b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterBindingTests.cs
@@ -0,0 +1,132 @@
+using Inspectron.Epson.Templates.Configuration;
+using Inspectron.Epson.Templates.Interpreter;
+using Inspectron.Epson.Templates.Language;
+using Inspectron.Epson.Templates.Language.Nodes;
+
+namespace Inspectron.Epson.Templates.Tests.Interpreter;
+
+public class InterpreterBindingTests
+{
+ private readonly PrinterProfile _profile = new("test", "Test Printer", 48, 24, false);
+
+ private TemplateNode Parse(string source)
+ {
+ var lexer = new Lexer(source);
+ var parser = new Parser(lexer.Tokenize().Tokens);
+ return parser.Parse().Template;
+ }
+
+ [Fact]
+ public void Interpret_SimpleBinding_ResolvesValue()
+ {
+ var template = Parse("{Name}");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Name": "John"}""");
+
+ Assert.Contains(commands, c => c.Text == "John");
+ }
+
+ [Fact]
+ public void Interpret_NestedBinding_ResolvesNestedValue()
+ {
+ var template = Parse("{Person.Name}");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Person": {"Name": "Jane"}}""");
+
+ Assert.Contains(commands, c => c.Text == "Jane");
+ }
+
+ [Fact]
+ public void Interpret_NumericBinding_OutputsNumber()
+ {
+ var template = Parse("{Amount}");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Amount": 42}""");
+
+ Assert.Contains(commands, c => c.Text == "42");
+ }
+
+ [Fact]
+ public void Interpret_NumericBinding_WithFormat_FormatsNumber()
+ {
+ var template = Parse("{Price:F2}");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Price": 19.5}""");
+
+ Assert.Contains(commands, c => c.Text == "19.50");
+ }
+
+ [Fact]
+ public void Interpret_DateBinding_WithFormat_FormatsDate()
+ {
+ var template = Parse("{Date:dd-MMM-yy}");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Date": "2024-03-15T14:30:00"}""");
+
+ var cmd = commands.FirstOrDefault(c => c.Text.Contains("Mar"));
+ Assert.NotNull(cmd);
+ Assert.Contains("15", cmd.Text);
+ }
+
+ [Fact]
+ public void Interpret_MissingBinding_OutputsEmptyString()
+ {
+ var template = Parse("{Missing}");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Other": "value"}""");
+
+ Assert.Contains(commands, c => c.Text == "");
+ }
+
+ [Fact]
+ public void Interpret_NullBinding_OutputsEmptyString()
+ {
+ var template = Parse("{Value}");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Value": null}""");
+
+ Assert.Contains(commands, c => c.Text == "");
+ }
+
+ [Fact]
+ public void Interpret_ArrayCountBinding_OutputsCount()
+ {
+ var template = Parse("{Items.count}");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Items": [1, 2, 3, 4, 5]}""");
+
+ Assert.Contains(commands, c => c.Text == "5");
+ }
+
+ [Fact]
+ public void Interpret_BoolBinding_OutputsTrue()
+ {
+ var template = Parse("{Flag}");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Flag": true}""");
+
+ Assert.Contains(commands, c => c.Text == "true");
+ }
+
+ [Fact]
+ public void Interpret_BoolBinding_OutputsFalse()
+ {
+ var template = Parse("{Flag}");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Flag": false}""");
+
+ Assert.Contains(commands, c => c.Text == "false");
+ }
+
+ [Fact]
+ public void Interpret_BindingInStyledText_AppliesStyleToResolvedValue()
+ {
+ var template = Parse("#bold# {Name} #");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Name": "Test"}""");
+
+ var cmd = commands.FirstOrDefault(c => c.Text.Contains("Test"));
+ Assert.NotNull(cmd);
+ Assert.True(cmd.IsBold);
+ }
+}
diff --git a/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterConditionTests.cs b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterConditionTests.cs
new file mode 100644
index 0000000..43aabd6
--- /dev/null
+++ b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterConditionTests.cs
@@ -0,0 +1,161 @@
+using Inspectron.Epson.Templates.Configuration;
+using Inspectron.Epson.Templates.Interpreter;
+using Inspectron.Epson.Templates.Language;
+using Inspectron.Epson.Templates.Language.Nodes;
+
+namespace Inspectron.Epson.Templates.Tests.Interpreter;
+
+public class InterpreterConditionTests
+{
+ private readonly PrinterProfile _profile = new("test", "Test Printer", 48, 24, false);
+
+ private TemplateNode Parse(string source)
+ {
+ var lexer = new Lexer(source);
+ var parser = new Parser(lexer.Tokenize().Tokens);
+ return parser.Parse().Template;
+ }
+
+ [Fact]
+ public void Interpret_IfTruthy_ExecutesBody()
+ {
+ var template = Parse("@if HasValue\nYes\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"HasValue": true}""");
+
+ Assert.Contains(commands, c => c.Text == "Yes");
+ }
+
+ [Fact]
+ public void Interpret_IfFalsy_SkipsBody()
+ {
+ var template = Parse("@if HasValue\nYes\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"HasValue": false}""");
+
+ Assert.DoesNotContain(commands, c => c.Text == "Yes");
+ }
+
+ [Fact]
+ public void Interpret_IfStringEmpty_IsFalsy()
+ {
+ var template = Parse("@if Name\nHas Name\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Name": ""}""");
+
+ Assert.DoesNotContain(commands, c => c.Text == "Has Name");
+ }
+
+ [Fact]
+ public void Interpret_IfStringNotEmpty_IsTruthy()
+ {
+ var template = Parse("@if Name\nHas Name\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Name": "John"}""");
+
+ Assert.Contains(commands, c => c.Text == "Has Name");
+ }
+
+ [Fact]
+ public void Interpret_IfArrayEmpty_IsFalsy()
+ {
+ var template = Parse("@if Items\nHas Items\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Items": []}""");
+
+ Assert.DoesNotContain(commands, c => c.Text == "Has Items");
+ }
+
+ [Fact]
+ public void Interpret_IfArrayNotEmpty_IsTruthy()
+ {
+ var template = Parse("@if Items\nHas Items\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Items": [1, 2]}""");
+
+ Assert.Contains(commands, c => c.Text == "Has Items");
+ }
+
+ [Fact]
+ public void Interpret_IfNegation_InvertsCondition()
+ {
+ var template = Parse("@if !HasValue\nNo Value\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"HasValue": false}""");
+
+ Assert.Contains(commands, c => c.Text == "No Value");
+ }
+
+ [Fact]
+ public void Interpret_IfEqualsComparison_Works()
+ {
+ var template = Parse("@if Status == \"active\"\nActive\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Status": "active"}""");
+
+ Assert.Contains(commands, c => c.Text == "Active");
+ }
+
+ [Fact]
+ public void Interpret_IfNotEqualsComparison_Works()
+ {
+ var template = Parse("@if Status != \"inactive\"\nNot Inactive\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Status": "active"}""");
+
+ Assert.Contains(commands, c => c.Text == "Not Inactive");
+ }
+
+ [Fact]
+ public void Interpret_IfGreaterThanComparison_Works()
+ {
+ var template = Parse("@if Count > 5\nMany Items\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Count": 10}""");
+
+ Assert.Contains(commands, c => c.Text == "Many Items");
+ }
+
+ [Fact]
+ public void Interpret_IfLessThanComparison_Works()
+ {
+ var template = Parse("@if Count < 5\nFew Items\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Count": 2}""");
+
+ Assert.Contains(commands, c => c.Text == "Few Items");
+ }
+
+ [Fact]
+ public void Interpret_IfElse_ExecutesElseWhenFalse()
+ {
+ var template = Parse("@if HasValue\nYes\n@else\nNo\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"HasValue": false}""");
+
+ Assert.DoesNotContain(commands, c => c.Text == "Yes");
+ Assert.Contains(commands, c => c.Text == "No");
+ }
+
+ [Fact]
+ public void Interpret_IfElseIf_ExecutesCorrectBranch()
+ {
+ var template = Parse("@if Value == 1\nOne\n@elseif Value == 2\nTwo\n@else\nOther\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Value": 2}""");
+
+ Assert.DoesNotContain(commands, c => c.Text == "One");
+ Assert.Contains(commands, c => c.Text == "Two");
+ Assert.DoesNotContain(commands, c => c.Text == "Other");
+ }
+
+ [Fact]
+ public void Interpret_ArrayCountComparison_Works()
+ {
+ var template = Parse("@if Items.count > 0\nHas Items\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Items": [1, 2, 3]}""");
+
+ Assert.Contains(commands, c => c.Text == "Has Items");
+ }
+}
diff --git a/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterLoopTests.cs b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterLoopTests.cs
new file mode 100644
index 0000000..4016752
--- /dev/null
+++ b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterLoopTests.cs
@@ -0,0 +1,134 @@
+using Inspectron.Epson.Templates.Configuration;
+using Inspectron.Epson.Templates.Interpreter;
+using Inspectron.Epson.Templates.Language;
+using Inspectron.Epson.Templates.Language.Nodes;
+
+namespace Inspectron.Epson.Templates.Tests.Interpreter;
+
+public class InterpreterLoopTests
+{
+ private readonly PrinterProfile _profile = new("test", "Test Printer", 48, 24, false);
+
+ private TemplateNode Parse(string source)
+ {
+ var lexer = new Lexer(source);
+ var parser = new Parser(lexer.Tokenize().Tokens);
+ return parser.Parse().Template;
+ }
+
+ [Fact]
+ public void Interpret_Foreach_IteratesOverArray()
+ {
+ var template = Parse("@foreach item in Items\n{item}\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Items": ["A", "B", "C"]}""");
+
+ Assert.Contains(commands, c => c.Text == "A");
+ Assert.Contains(commands, c => c.Text == "B");
+ Assert.Contains(commands, c => c.Text == "C");
+ }
+
+ [Fact]
+ public void Interpret_Foreach_AccessesObjectProperties()
+ {
+ var template = Parse("@foreach item in Items\n{item.Name}\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Items": [{"Name": "First"}, {"Name": "Second"}]}""");
+
+ Assert.Contains(commands, c => c.Text == "First");
+ Assert.Contains(commands, c => c.Text == "Second");
+ }
+
+ [Fact]
+ public void Interpret_Foreach_EmptyArray_NoOutput()
+ {
+ var template = Parse("@foreach item in Items\n{item}\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Items": []}""");
+
+ Assert.DoesNotContain(commands, c => !string.IsNullOrWhiteSpace(c.Text));
+ }
+
+ [Fact]
+ public void Interpret_Foreach_IndexMetadata_Works()
+ {
+ var template = Parse("@foreach item in Items\n{_index}:{item}\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Items": ["A", "B"]}""");
+
+ Assert.Contains(commands, c => c.Text == "0");
+ Assert.Contains(commands, c => c.Text == "1");
+ }
+
+ [Fact]
+ public void Interpret_Foreach_NumberMetadata_Works()
+ {
+ var template = Parse("@foreach item in Items\n{_number}. {item}\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Items": ["A", "B"]}""");
+
+ Assert.Contains(commands, c => c.Text == "1");
+ Assert.Contains(commands, c => c.Text == "2");
+ }
+
+ [Fact]
+ public void Interpret_Foreach_FirstMetadata_Works()
+ {
+ var template = Parse("@foreach item in Items\n@if _first\nFirst: {item}\n@end\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Items": ["A", "B", "C"]}""");
+
+ var firstCmds = commands.Where(c => c.Text.Contains("First")).ToList();
+ Assert.Single(firstCmds);
+ Assert.Contains(commands, c => c.Text == "A");
+ }
+
+ [Fact]
+ public void Interpret_Foreach_LastMetadata_Works()
+ {
+ var template = Parse("@foreach item in Items\n@if _last\nLast: {item}\n@end\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Items": ["A", "B", "C"]}""");
+
+ Assert.Contains(commands, c => c.Text.Contains("Last"));
+ Assert.Contains(commands, c => c.Text == "C");
+ }
+
+ [Fact]
+ public void Interpret_NestedForeach_Works()
+ {
+ var template = Parse("@foreach group in Groups\n@foreach item in group.Items\n{item}\n@end\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template,
+ """{"Groups": [{"Items": ["A", "B"]}, {"Items": ["C", "D"]}]}""");
+
+ Assert.Contains(commands, c => c.Text == "A");
+ Assert.Contains(commands, c => c.Text == "B");
+ Assert.Contains(commands, c => c.Text == "C");
+ Assert.Contains(commands, c => c.Text == "D");
+ }
+
+ [Fact]
+ public void Interpret_Foreach_AccessesParentContext()
+ {
+ var template = Parse("@foreach item in Items\n{Title}: {item}\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Title": "List", "Items": ["A", "B"]}""");
+
+ Assert.Contains(commands, c => c.Text == "List");
+ Assert.Contains(commands, c => c.Text == "A");
+ Assert.Contains(commands, c => c.Text == "B");
+ }
+
+ [Fact]
+ public void Interpret_Foreach_NestedPath_Works()
+ {
+ var template = Parse("@foreach item in Data.Items\n{item.Name}\n@end");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template,
+ """{"Data": {"Items": [{"Name": "First"}, {"Name": "Second"}]}}""");
+
+ Assert.Contains(commands, c => c.Text == "First");
+ Assert.Contains(commands, c => c.Text == "Second");
+ }
+}
diff --git a/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterRowTests.cs b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterRowTests.cs
new file mode 100644
index 0000000..79f15b6
--- /dev/null
+++ b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterRowTests.cs
@@ -0,0 +1,105 @@
+using Inspectron.Epson.Templates.Configuration;
+using Inspectron.Epson.Templates.Interpreter;
+using Inspectron.Epson.Templates.Language;
+using Inspectron.Epson.Templates.Language.Nodes;
+
+namespace Inspectron.Epson.Templates.Tests.Interpreter;
+
+public class InterpreterRowTests
+{
+ private readonly PrinterProfile _profile = new("test", "Test Printer", 48, 24, false);
+
+ private TemplateNode Parse(string source)
+ {
+ var lexer = new Lexer(source);
+ var parser = new Parser(lexer.Tokenize().Tokens);
+ return parser.Parse().Template;
+ }
+
+ [Fact]
+ public void Interpret_Row_CombinesColumns()
+ {
+ var template = Parse("@row\n|20|Name|10|Value\n@endrow");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, "{}");
+
+ // Should have a single command with combined column text
+ var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Name") && c.Text.Contains("Value"));
+ Assert.NotNull(rowCmd);
+ }
+
+ [Fact]
+ public void Interpret_Row_LeftAlignsPaddsRight()
+ {
+ var template = Parse("@row\n|10|Hi\n@endrow");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, "{}");
+
+ var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Hi"));
+ Assert.NotNull(rowCmd);
+ Assert.Equal(10, rowCmd.Text.Length);
+ Assert.StartsWith("Hi", rowCmd.Text);
+ }
+
+ [Fact]
+ public void Interpret_Row_RightAlignsPaddsLeft()
+ {
+ var template = Parse("@row\n|10,right|Hi\n@endrow");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, "{}");
+
+ var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Hi"));
+ Assert.NotNull(rowCmd);
+ Assert.Equal(10, rowCmd.Text.Length);
+ Assert.EndsWith("Hi", rowCmd.Text);
+ }
+
+ [Fact]
+ public void Interpret_Row_CenterAlignsCentersText()
+ {
+ var template = Parse("@row\n|10,center|Hi\n@endrow");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, "{}");
+
+ var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Hi"));
+ Assert.NotNull(rowCmd);
+ Assert.Equal(10, rowCmd.Text.Length);
+ Assert.StartsWith(" Hi", rowCmd.Text);
+ }
+
+ [Fact]
+ public void Interpret_Row_TruncatesLongText()
+ {
+ var template = Parse("@row\n|5|HelloWorld\n@endrow");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, "{}");
+
+ var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Hello"));
+ Assert.NotNull(rowCmd);
+ Assert.Equal(5, rowCmd.Text.Length);
+ Assert.Equal("Hello", rowCmd.Text);
+ }
+
+ [Fact]
+ public void Interpret_Row_WithBindings_ResolvesValues()
+ {
+ var template = Parse("@row\n|20|{Name}|10,right|{Price:F2}\n@endrow");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, """{"Name": "Item", "Price": 9.99}""");
+
+ var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Item") && c.Text.Contains("9.99"));
+ Assert.NotNull(rowCmd);
+ }
+
+ [Fact]
+ public void Interpret_Row_MultipleColumns_CorrectTotalWidth()
+ {
+ var template = Parse("@row\n|10|A|10|B|10|C\n@endrow");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, "{}");
+
+ var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("A") && c.Text.Contains("B") && c.Text.Contains("C"));
+ Assert.NotNull(rowCmd);
+ Assert.Equal(30, rowCmd.Text.Length);
+ }
+}
diff --git a/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterTextTests.cs b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterTextTests.cs
new file mode 100644
index 0000000..af090c3
--- /dev/null
+++ b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterTextTests.cs
@@ -0,0 +1,153 @@
+using Inspectron.Epson.Templates.Configuration;
+using Inspectron.Epson.Templates.Interpreter;
+using Inspectron.Epson.Templates.Language;
+using Inspectron.Epson.Templates.Language.Nodes;
+
+namespace Inspectron.Epson.Templates.Tests.Interpreter;
+
+public class InterpreterTextTests
+{
+ private readonly PrinterProfile _profile = new("test", "Test Printer", 48, 24, false);
+
+ private TemplateNode Parse(string source)
+ {
+ var lexer = new Lexer(source);
+ var parser = new Parser(lexer.Tokenize().Tokens);
+ return parser.Parse().Template;
+ }
+
+ [Fact]
+ public void Interpret_PlainText_OutputsText()
+ {
+ var template = Parse("Hello World");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, "{}");
+
+ Assert.Contains(commands, c => c.Text == "Hello World");
+ }
+
+ [Fact]
+ public void Interpret_BoldStyle_SetsBoldFlag()
+ {
+ var template = Parse("#bold# text #");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, "{}");
+
+ var cmd = commands.FirstOrDefault(c => c.Text.Contains("text"));
+ Assert.NotNull(cmd);
+ Assert.True(cmd.IsBold);
+ }
+
+ [Fact]
+ public void Interpret_BigStyle_SetsBigFlag()
+ {
+ var template = Parse("#big# text #");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, "{}");
+
+ var cmd = commands.FirstOrDefault(c => c.Text.Contains("text"));
+ Assert.NotNull(cmd);
+ Assert.True(cmd.IsBig);
+ }
+
+ [Fact]
+ public void Interpret_TallStyle_SetsTallFlag()
+ {
+ var template = Parse("#tall# text #");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, "{}");
+
+ var cmd = commands.FirstOrDefault(c => c.Text.Contains("text"));
+ Assert.NotNull(cmd);
+ Assert.True(cmd.IsTall);
+ }
+
+ [Fact]
+ public void Interpret_RedStyle_SetsRedFlag_WhenSupported()
+ {
+ var profileWithRed = new PrinterProfile("test", "Test", 48, 24, true);
+ var template = Parse("#red# text #");
+ var interpreter = new TemplateInterpreter(profileWithRed);
+ var commands = interpreter.Interpret(template, "{}");
+
+ var cmd = commands.FirstOrDefault(c => c.Text.Contains("text"));
+ Assert.NotNull(cmd);
+ Assert.True(cmd.IsRed);
+ }
+
+ [Fact]
+ public void Interpret_RedStyle_IgnoresRedFlag_WhenNotSupported()
+ {
+ var profileNoRed = new PrinterProfile("test", "Test", 48, 24, false);
+ var template = Parse("#red# text #");
+ var interpreter = new TemplateInterpreter(profileNoRed);
+ var commands = interpreter.Interpret(template, "{}");
+
+ var cmd = commands.FirstOrDefault(c => c.Text.Contains("text"));
+ Assert.NotNull(cmd);
+ Assert.False(cmd.IsRed);
+ }
+
+ [Fact]
+ public void Interpret_CenterStyle_CentersText()
+ {
+ var template = Parse("#center# Hi #");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, "{}");
+
+ var cmd = commands.FirstOrDefault(c => c.Text.Contains("Hi"));
+ Assert.NotNull(cmd);
+ Assert.StartsWith(" ", cmd.Text);
+ }
+
+ [Fact]
+ public void Interpret_MultipleStyles_CombinesFlags()
+ {
+ var template = Parse("#bold,big,tall# text #");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, "{}");
+
+ var cmd = commands.FirstOrDefault(c => c.Text.Contains("text"));
+ Assert.NotNull(cmd);
+ Assert.True(cmd.IsBold);
+ Assert.True(cmd.IsBig);
+ Assert.True(cmd.IsTall);
+ }
+
+ [Fact]
+ public void Interpret_DashSeparator_OutputsFullWidthLine()
+ {
+ var template = Parse("---");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, "{}");
+
+ var cmd = commands.FirstOrDefault(c => c.Text.Contains('-'));
+ Assert.NotNull(cmd);
+ Assert.Equal(48, cmd.Text.Length);
+ Assert.True(cmd.Text.All(c => c == '-'));
+ }
+
+ [Fact]
+ public void Interpret_EqualsSeparator_OutputsFullWidthLine()
+ {
+ var template = Parse("===");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, "{}");
+
+ var cmd = commands.FirstOrDefault(c => c.Text.Contains('='));
+ Assert.NotNull(cmd);
+ Assert.Equal(48, cmd.Text.Length);
+ }
+
+ [Fact]
+ public void Interpret_SpacingStyle_SetsLineSpacing()
+ {
+ var template = Parse("#spacing:50# text #");
+ var interpreter = new TemplateInterpreter(_profile);
+ var commands = interpreter.Interpret(template, "{}");
+
+ var cmd = commands.FirstOrDefault(c => c.Text.Contains("text"));
+ Assert.NotNull(cmd);
+ Assert.Equal(50, cmd.SetLineSpacing);
+ }
+}
diff --git a/Inspectron.Epson.Templates.Tests/Language/CommentTests.cs b/Inspectron.Epson.Templates.Tests/Language/CommentTests.cs
new file mode 100644
index 0000000..fe5151b
--- /dev/null
+++ b/Inspectron.Epson.Templates.Tests/Language/CommentTests.cs
@@ -0,0 +1,219 @@
+using Inspectron.Epson.Templates.Language;
+
+namespace Inspectron.Epson.Templates.Tests.Language;
+
+public class CommentTests
+{
+ #region Single-line comments
+
+ [Fact]
+ public void Tokenize_SingleLineComment_ProducesNoTextTokens()
+ {
+ var lexer = new Lexer("@* This is a comment");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text);
+ }
+
+ [Fact]
+ public void Tokenize_InlineSingleLineComment_ProducesNoTextTokens()
+ {
+ var lexer = new Lexer("@* This is a comment *@");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text);
+ }
+
+ [Fact]
+ public void Tokenize_IndentedSingleLineComment_IsRecognizedAsComment()
+ {
+ var lexer = new Lexer(" @* indented comment");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text);
+ }
+
+ [Fact]
+ public void Tokenize_TextBeforeAndAfterCommentLine_IsPreserved()
+ {
+ var lexer = new Lexer("Before\n@* comment\nAfter");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "Before");
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "After");
+ Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text && t.Value.Contains("comment"));
+ }
+
+ [Fact]
+ public void Tokenize_InlineCommentWithContentAfter_PreservesContentAfter()
+ {
+ var lexer = new Lexer("@* comment *@After");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "After");
+ Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text && t.Value.Contains("comment"));
+ }
+
+ #endregion
+
+ #region Multi-line comments
+
+ [Fact]
+ public void Tokenize_MultiLineComment_ProducesNoTextTokens()
+ {
+ var lexer = new Lexer("@*\nThis is multi-line\ncomment text\n*@");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text);
+ }
+
+ [Fact]
+ public void Tokenize_MultiLineComment_ContentBeforeAndAfterPreserved()
+ {
+ var lexer = new Lexer("Before\n@*\ncomment\n*@\nAfter");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "Before");
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "After");
+ Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text && t.Value.Contains("comment"));
+ }
+
+ [Fact]
+ public void Tokenize_UnclosedMultiLineComment_ProducesError()
+ {
+ var lexer = new Lexer("@*\nThis comment is never closed");
+ var result = lexer.Tokenize();
+
+ Assert.True(result.HasErrors);
+ Assert.Contains(result.Errors, e => e.Message.Contains("Unclosed multi-line comment"));
+ }
+
+ [Fact]
+ public void Tokenize_MultiLineCommentWithContentAfterCloser_PreservesContent()
+ {
+ var lexer = new Lexer("@*\ncomment\n*@After");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "After");
+ }
+
+ #endregion
+
+ #region Edge cases
+
+ [Fact]
+ public void Tokenize_StarSeparator_StillWorksNotConfusedWithComment()
+ {
+ var lexer = new Lexer("***");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.StarSeparator);
+ }
+
+ [Fact]
+ public void Tokenize_DirectivesInsideComment_AreIgnored()
+ {
+ var lexer = new Lexer("@*\n@if condition\n@foreach item in items\n*@");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.If);
+ Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Foreach);
+ }
+
+ [Fact]
+ public void Tokenize_BindingsInsideComment_AreIgnored()
+ {
+ var lexer = new Lexer("@* {PropertyName} *@");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Binding);
+ }
+
+ [Fact]
+ public void Tokenize_CommentWithMultipleStars_HandledCorrectly()
+ {
+ var lexer = new Lexer("@*** This has extra stars ***@");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text && t.Value.Contains("stars"));
+ }
+
+ [Fact]
+ public void Tokenize_EmptyMultiLineComment_HandledCorrectly()
+ {
+ var lexer = new Lexer("@*\n*@");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text);
+ }
+
+ [Fact]
+ public void Tokenize_CommentInComplexTemplate_WorksCorrectly()
+ {
+ var template = @"#bold,center# Title #
+@* This is a header comment *@
+---
+@if HasItems
+@* Loop through items *@
+@foreach item in Items
+{item.Name}
+@end
+@end";
+ var lexer = new Lexer(template);
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.StyleStart);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.DashSeparator);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.If);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Foreach);
+ Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text && t.Value.Contains("comment"));
+ }
+
+ [Fact]
+ public void Tokenize_MultipleCommentsInTemplate_AllStripped()
+ {
+ var template = @"Line 1
+@* Comment 1 *@
+Line 2
+@* Comment 2
+still comment
+*@
+Line 3";
+ var lexer = new Lexer(template);
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "Line 1");
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "Line 2");
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "Line 3");
+ Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text && t.Value.Contains("Comment"));
+ }
+
+ [Fact]
+ public void Tokenize_SeparatorInsideMultiLineComment_IsIgnored()
+ {
+ var lexer = new Lexer("@*\n---\n===\n***\n*@");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.DashSeparator);
+ Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.EqualsSeparator);
+ Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.StarSeparator);
+ }
+
+ #endregion
+}
diff --git a/Inspectron.Epson.Templates.Tests/Language/LexerTests.cs b/Inspectron.Epson.Templates.Tests/Language/LexerTests.cs
new file mode 100644
index 0000000..43ce50c
--- /dev/null
+++ b/Inspectron.Epson.Templates.Tests/Language/LexerTests.cs
@@ -0,0 +1,171 @@
+using Inspectron.Epson.Templates.Language;
+
+namespace Inspectron.Epson.Templates.Tests.Language;
+
+public class LexerTests
+{
+ [Fact]
+ public void Tokenize_PlainText_ReturnsTextToken()
+ {
+ var lexer = new Lexer("Hello World");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "Hello World");
+ }
+
+ [Fact]
+ public void Tokenize_Binding_ReturnsBindingToken()
+ {
+ var lexer = new Lexer("{PropertyName}");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Binding && t.Value == "PropertyName");
+ }
+
+ [Fact]
+ public void Tokenize_BindingWithFormat_ReturnsBindingTokenWithFormat()
+ {
+ var lexer = new Lexer("{TransactionDateTime:dd-MMM-yy}");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Binding && t.Value == "TransactionDateTime:dd-MMM-yy");
+ }
+
+ [Fact]
+ public void Tokenize_StyleBlock_ReturnsStyleTokens()
+ {
+ var lexer = new Lexer("#bold,center# text #");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.StyleStart && t.Value == "bold,center");
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == " text ");
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.StyleEnd);
+ }
+
+ [Fact]
+ public void Tokenize_DashSeparator_ReturnsSeparatorToken()
+ {
+ var lexer = new Lexer("---");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.DashSeparator);
+ }
+
+ [Fact]
+ public void Tokenize_EqualsSeparator_ReturnsSeparatorToken()
+ {
+ var lexer = new Lexer("===");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.EqualsSeparator);
+ }
+
+ [Fact]
+ public void Tokenize_StarSeparator_ReturnsSeparatorToken()
+ {
+ var lexer = new Lexer("***");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.StarSeparator);
+ }
+
+ [Fact]
+ public void Tokenize_IfDirective_ReturnsIfToken()
+ {
+ var lexer = new Lexer("@if condition");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.If && t.Value == "condition");
+ }
+
+ [Fact]
+ public void Tokenize_ForeachDirective_ReturnsForeachToken()
+ {
+ var lexer = new Lexer("@foreach item in items");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Foreach && t.Value == "item in items");
+ }
+
+ [Fact]
+ public void Tokenize_EndDirective_ReturnsEndToken()
+ {
+ var lexer = new Lexer("@end");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.End);
+ }
+
+ [Fact]
+ public void Tokenize_RowDirectives_ReturnsRowTokens()
+ {
+ var lexer = new Lexer("@row\n@endrow");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Row);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.EndRow);
+ }
+
+ [Fact]
+ public void Tokenize_Column_ReturnsColumnToken()
+ {
+ var lexer = new Lexer("|20|");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Column && t.Value.StartsWith("20"));
+ }
+
+ [Fact]
+ public void Tokenize_ColumnWithAlignment_ReturnsColumnTokenWithAlignment()
+ {
+ var lexer = new Lexer("|20,right|");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Column && t.Value == "20,right");
+ }
+
+ [Fact]
+ public void Tokenize_MultipleLines_PreservesLineStructure()
+ {
+ var lexer = new Lexer("Line 1\nLine 2\nLine 3");
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ var newlines = result.Tokens.Count(t => t.Type == TokenType.NewLine);
+ Assert.Equal(3, newlines);
+ }
+
+ [Fact]
+ public void Tokenize_ComplexTemplate_ParsesCorrectly()
+ {
+ var template = @"#bold,center# {Title} #
+---
+@if HasItems
+@foreach item in Items
+{item.Name}
+@end
+@end";
+ var lexer = new Lexer(template);
+ var result = lexer.Tokenize();
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.StyleStart);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.DashSeparator);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.If);
+ Assert.Contains(result.Tokens, t => t.Type == TokenType.Foreach);
+ Assert.Equal(2, result.Tokens.Count(t => t.Type == TokenType.End));
+ }
+}
diff --git a/Inspectron.Epson.Templates.Tests/Language/ParserTests.cs b/Inspectron.Epson.Templates.Tests/Language/ParserTests.cs
new file mode 100644
index 0000000..a079aea
--- /dev/null
+++ b/Inspectron.Epson.Templates.Tests/Language/ParserTests.cs
@@ -0,0 +1,155 @@
+using Inspectron.Epson.Templates.Language;
+using Inspectron.Epson.Templates.Language.Nodes;
+
+namespace Inspectron.Epson.Templates.Tests.Language;
+
+public class ParserTests
+{
+ private ParseResult Parse(string source)
+ {
+ var lexer = new Lexer(source);
+ var lexerResult = lexer.Tokenize();
+ var parser = new Parser(lexerResult.Tokens);
+ return parser.Parse();
+ }
+
+ [Fact]
+ public void Parse_PlainText_ReturnsTextNode()
+ {
+ var result = Parse("Hello World");
+
+ Assert.False(result.HasErrors);
+ Assert.Single(result.Template.ChildNodes.OfType());
+ }
+
+ [Fact]
+ public void Parse_Binding_ReturnsBindingNode()
+ {
+ var result = Parse("{Name}");
+
+ Assert.False(result.HasErrors);
+ var binding = Assert.Single(result.Template.ChildNodes.OfType());
+ Assert.Equal("Name", binding.Path);
+ Assert.Null(binding.Format);
+ }
+
+ [Fact]
+ public void Parse_BindingWithFormat_PreservesFormat()
+ {
+ var result = Parse("{Date:yyyy-MM-dd}");
+
+ Assert.False(result.HasErrors);
+ var binding = Assert.Single(result.Template.ChildNodes.OfType());
+ Assert.Equal("Date", binding.Path);
+ Assert.Equal("yyyy-MM-dd", binding.Format);
+ }
+
+ [Fact]
+ public void Parse_StyledText_ReturnsStyledTextNode()
+ {
+ var result = Parse("#bold,center# text #");
+
+ Assert.False(result.HasErrors);
+ var styled = Assert.Single(result.Template.ChildNodes.OfType());
+ Assert.Contains("bold", styled.Styles);
+ Assert.Contains("center", styled.Styles);
+ }
+
+ [Fact]
+ public void Parse_DashSeparator_ReturnsSeparatorNode()
+ {
+ var result = Parse("---");
+
+ Assert.False(result.HasErrors);
+ var separator = Assert.Single(result.Template.ChildNodes.OfType());
+ Assert.Equal(SeparatorStyle.Dash, separator.Style);
+ }
+
+ [Fact]
+ public void Parse_IfBlock_ReturnsIfNode()
+ {
+ var result = Parse("@if condition\ntext\n@end");
+
+ Assert.False(result.HasErrors);
+ var ifNode = Assert.Single(result.Template.ChildNodes.OfType());
+ Assert.Equal("condition", ifNode.IfBranch.Condition);
+ Assert.NotEmpty(ifNode.IfBranch.Body);
+ }
+
+ [Fact]
+ public void Parse_IfElseBlock_ReturnsIfNodeWithElseBranch()
+ {
+ var result = Parse("@if condition\nif-text\n@else\nelse-text\n@end");
+
+ Assert.False(result.HasErrors);
+ var ifNode = Assert.Single(result.Template.ChildNodes.OfType());
+ Assert.NotNull(ifNode.ElseBranch);
+ }
+
+ [Fact]
+ public void Parse_IfElseIfElseBlock_ReturnsAllBranches()
+ {
+ var result = Parse("@if cond1\ntext1\n@elseif cond2\ntext2\n@else\ntext3\n@end");
+
+ Assert.False(result.HasErrors);
+ var ifNode = Assert.Single(result.Template.ChildNodes.OfType());
+ Assert.Single(ifNode.ElseIfBranches);
+ Assert.NotNull(ifNode.ElseBranch);
+ }
+
+ [Fact]
+ public void Parse_ForeachBlock_ReturnsForeachNode()
+ {
+ var result = Parse("@foreach item in items\n{item}\n@end");
+
+ Assert.False(result.HasErrors);
+ var foreach_ = Assert.Single(result.Template.ChildNodes.OfType());
+ Assert.Equal("item", foreach_.ItemVariable);
+ Assert.Equal("items", foreach_.CollectionPath);
+ }
+
+ [Fact]
+ public void Parse_NestedBlocks_HandlesNestingCorrectly()
+ {
+ var result = Parse("@foreach item in items\n@if item.visible\n{item.name}\n@end\n@end");
+
+ Assert.False(result.HasErrors);
+ var foreach_ = Assert.Single(result.Template.ChildNodes.OfType());
+ Assert.Single(foreach_.Body.OfType());
+ }
+
+ [Fact]
+ public void Parse_RowWithColumns_ReturnsRowNode()
+ {
+ var result = Parse("@row\n|20|Name|10,right|Value\n@endrow");
+
+ Assert.False(result.HasErrors);
+ var row = Assert.Single(result.Template.ChildNodes.OfType());
+ Assert.Equal(2, row.Columns.Count);
+ }
+
+ [Fact]
+ public void Parse_EmptyLine_ReturnsEmptyLineNode()
+ {
+ var result = Parse("line1\n\nline2");
+
+ Assert.False(result.HasErrors);
+ Assert.Contains(result.Template.ChildNodes, n => n is EmptyLineNode);
+ }
+
+ [Fact]
+ public void Parse_UnclosedIfBlock_ReportsError()
+ {
+ var result = Parse("@if condition\ntext");
+
+ Assert.True(result.HasErrors);
+ }
+
+ [Fact]
+ public void Parse_UnclosedForeachBlock_ReportsError()
+ {
+ var result = Parse("@foreach item in items\ntext");
+
+ Assert.True(result.HasErrors);
+ }
+}
diff --git a/Inspectron.Epson.Templates/Configuration/PrinterProfile.cs b/Inspectron.Epson.Templates/Configuration/PrinterProfile.cs
new file mode 100644
index 0000000..0ec8124
--- /dev/null
+++ b/Inspectron.Epson.Templates/Configuration/PrinterProfile.cs
@@ -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);
+}
diff --git a/Inspectron.Epson.Templates/Configuration/PrinterProfileRegistry.cs b/Inspectron.Epson.Templates/Configuration/PrinterProfileRegistry.cs
new file mode 100644
index 0000000..60c67c2
--- /dev/null
+++ b/Inspectron.Epson.Templates/Configuration/PrinterProfileRegistry.cs
@@ -0,0 +1,70 @@
+namespace Inspectron.Epson.Templates.Configuration;
+
+public class PrinterProfileRegistry
+{
+ private readonly Dictionary _profilesById = new();
+ private readonly Dictionary _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 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);
+ }
+}
diff --git a/Inspectron.Epson.Templates/Configuration/TemplateAssignment.cs b/Inspectron.Epson.Templates/Configuration/TemplateAssignment.cs
new file mode 100644
index 0000000..addd41e
--- /dev/null
+++ b/Inspectron.Epson.Templates/Configuration/TemplateAssignment.cs
@@ -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);
+ }
+}
diff --git a/Inspectron.Epson.Templates/Configuration/TemplateConfiguration.cs b/Inspectron.Epson.Templates/Configuration/TemplateConfiguration.cs
new file mode 100644
index 0000000..1b8499b
--- /dev/null
+++ b/Inspectron.Epson.Templates/Configuration/TemplateConfiguration.cs
@@ -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 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(json)
+ ?? new TemplateConfiguration();
+ }
+
+ public static TemplateConfiguration LoadFromJson(string json)
+ {
+ return JsonSerializer.Deserialize(json)
+ ?? new TemplateConfiguration();
+ }
+
+ public IEnumerable 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;
+}
diff --git a/Inspectron.Epson.Templates/Configuration/TemplateResolver.cs b/Inspectron.Epson.Templates/Configuration/TemplateResolver.cs
new file mode 100644
index 0000000..087653c
--- /dev/null
+++ b/Inspectron.Epson.Templates/Configuration/TemplateResolver.cs
@@ -0,0 +1,45 @@
+namespace Inspectron.Epson.Templates.Configuration;
+
+public class TemplateResolver
+{
+ private readonly List _assignments;
+ private readonly string _fallbackTemplate;
+
+ public TemplateResolver(TemplateConfiguration configuration)
+ {
+ _assignments = configuration.GetAssignments().ToList();
+ _fallbackTemplate = configuration.FallbackTemplate;
+ }
+
+ public TemplateResolver(IEnumerable 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);
+ }
+}
diff --git a/Inspectron.Epson.Templates/Inspectron.Epson.Templates.csproj b/Inspectron.Epson.Templates/Inspectron.Epson.Templates.csproj
new file mode 100644
index 0000000..75983f3
--- /dev/null
+++ b/Inspectron.Epson.Templates/Inspectron.Epson.Templates.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net8.0
+ enable
+ enable
+ Inspectron.Epson.Templates
+
+
+
+
+
+
+
diff --git a/Inspectron.Epson.Templates/Interpreter/DataContext.cs b/Inspectron.Epson.Templates/Interpreter/DataContext.cs
new file mode 100644
index 0000000..0e4c7de
--- /dev/null
+++ b/Inspectron.Epson.Templates/Interpreter/DataContext.cs
@@ -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 _localVariables = new();
+ private readonly Dictionary _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 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()
+ };
+ }
+}
diff --git a/Inspectron.Epson.Templates/Interpreter/ExpressionEvaluator.cs b/Inspectron.Epson.Templates/Interpreter/ExpressionEvaluator.cs
new file mode 100644
index 0000000..8070836
--- /dev/null
+++ b/Inspectron.Epson.Templates/Interpreter/ExpressionEvaluator.cs
@@ -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;
+ }
+}
diff --git a/Inspectron.Epson.Templates/Interpreter/FormatResolver.cs b/Inspectron.Epson.Templates/Interpreter/FormatResolver.cs
new file mode 100644
index 0000000..ffbeb04
--- /dev/null
+++ b/Inspectron.Epson.Templates/Interpreter/FormatResolver.cs
@@ -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()
+ };
+ }
+}
diff --git a/Inspectron.Epson.Templates/Interpreter/InterpreterException.cs b/Inspectron.Epson.Templates/Interpreter/InterpreterException.cs
new file mode 100644
index 0000000..841fe55
--- /dev/null
+++ b/Inspectron.Epson.Templates/Interpreter/InterpreterException.cs
@@ -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;
+ }
+}
diff --git a/Inspectron.Epson.Templates/Interpreter/TemplateInterpreter.cs b/Inspectron.Epson.Templates/Interpreter/TemplateInterpreter.cs
new file mode 100644
index 0000000..85cc0b6
--- /dev/null
+++ b/Inspectron.Epson.Templates/Interpreter/TemplateInterpreter.cs
@@ -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 Interpret(TemplateNode template, string jsonData)
+ {
+ var document = JsonDocument.Parse(jsonData);
+ var context = new DataContext(document.RootElement);
+ return Interpret(template, context);
+ }
+
+ public List Interpret(TemplateNode template, DataContext context)
+ {
+ var commands = new List();
+ InterpretNodes(template.ChildNodes, context, commands, new StyleContext());
+ return commands;
+ }
+
+ private void InterpretNodes(
+ IReadOnlyList nodes,
+ DataContext context,
+ List commands,
+ StyleContext style)
+ {
+ foreach (var node in nodes)
+ {
+ InterpretNode(node, context, commands, style);
+ }
+ }
+
+ private void InterpretNode(
+ ITemplateNode node,
+ DataContext context,
+ List 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 commands,
+ StyleContext style)
+ {
+ var text = ApplyAlignment(node.Text, style);
+ commands.Add(CreateCommand(text, style));
+ }
+
+ private void InterpretBinding(
+ BindingNode node,
+ DataContext context,
+ List 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 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 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 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 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 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 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 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
+ };
+ }
+}
diff --git a/Inspectron.Epson.Templates/Language/Lexer.cs b/Inspectron.Epson.Templates/Language/Lexer.cs
new file mode 100644
index 0000000..582f59e
--- /dev/null
+++ b/Inspectron.Epson.Templates/Language/Lexer.cs
@@ -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 _lines;
+ private int _lineIndex;
+ private int _columnIndex;
+ private readonly List _tokens = new();
+ private readonly List _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 Tokens, List Errors)
+{
+ public bool HasErrors => Errors.Count > 0;
+}
+
+public record LexerError(string Message, SourcePosition Position);
diff --git a/Inspectron.Epson.Templates/Language/Nodes/BindingNode.cs b/Inspectron.Epson.Templates/Language/Nodes/BindingNode.cs
new file mode 100644
index 0000000..c0ae31d
--- /dev/null
+++ b/Inspectron.Epson.Templates/Language/Nodes/BindingNode.cs
@@ -0,0 +1,14 @@
+namespace Inspectron.Epson.Templates.Language.Nodes;
+
+public record BindingNode(string Path, string? Format, SourcePosition Position) : ITemplateNode
+{
+ public IReadOnlyList Children => Array.Empty();
+
+ 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);
+ }
+}
diff --git a/Inspectron.Epson.Templates/Language/Nodes/ColumnNode.cs b/Inspectron.Epson.Templates/Language/Nodes/ColumnNode.cs
new file mode 100644
index 0000000..d813c38
--- /dev/null
+++ b/Inspectron.Epson.Templates/Language/Nodes/ColumnNode.cs
@@ -0,0 +1,27 @@
+namespace Inspectron.Epson.Templates.Language.Nodes;
+
+public enum ColumnAlignment
+{
+ Left,
+ Right,
+ Center
+}
+
+public record ColumnNode(
+ int Width,
+ ColumnAlignment Alignment,
+ List ContentNodes,
+ SourcePosition Position) : ITemplateNode
+{
+ public IReadOnlyList 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(parts[1], true, out var a) ? a : ColumnAlignment.Left
+ : ColumnAlignment.Left;
+ return (width, align);
+ }
+}
diff --git a/Inspectron.Epson.Templates/Language/Nodes/EmptyLineNode.cs b/Inspectron.Epson.Templates/Language/Nodes/EmptyLineNode.cs
new file mode 100644
index 0000000..13565c1
--- /dev/null
+++ b/Inspectron.Epson.Templates/Language/Nodes/EmptyLineNode.cs
@@ -0,0 +1,6 @@
+namespace Inspectron.Epson.Templates.Language.Nodes;
+
+public record EmptyLineNode(SourcePosition Position) : ITemplateNode
+{
+ public IReadOnlyList Children => Array.Empty();
+}
diff --git a/Inspectron.Epson.Templates/Language/Nodes/ForeachNode.cs b/Inspectron.Epson.Templates/Language/Nodes/ForeachNode.cs
new file mode 100644
index 0000000..e8d7e6e
--- /dev/null
+++ b/Inspectron.Epson.Templates/Language/Nodes/ForeachNode.cs
@@ -0,0 +1,21 @@
+namespace Inspectron.Epson.Templates.Language.Nodes;
+
+public record ForeachNode(
+ string ItemVariable,
+ string CollectionPath,
+ List Body,
+ SourcePosition Position) : ITemplateNode
+{
+ public IReadOnlyList 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]);
+ }
+}
diff --git a/Inspectron.Epson.Templates/Language/Nodes/ITemplateNode.cs b/Inspectron.Epson.Templates/Language/Nodes/ITemplateNode.cs
new file mode 100644
index 0000000..1227139
--- /dev/null
+++ b/Inspectron.Epson.Templates/Language/Nodes/ITemplateNode.cs
@@ -0,0 +1,7 @@
+namespace Inspectron.Epson.Templates.Language.Nodes;
+
+public interface ITemplateNode
+{
+ SourcePosition Position { get; }
+ IReadOnlyList Children { get; }
+}
diff --git a/Inspectron.Epson.Templates/Language/Nodes/IfNode.cs b/Inspectron.Epson.Templates/Language/Nodes/IfNode.cs
new file mode 100644
index 0000000..ded9d1b
--- /dev/null
+++ b/Inspectron.Epson.Templates/Language/Nodes/IfNode.cs
@@ -0,0 +1,31 @@
+namespace Inspectron.Epson.Templates.Language.Nodes;
+
+public record ConditionBranch(
+ string Condition,
+ List Body,
+ SourcePosition Position);
+
+public record IfNode(
+ ConditionBranch IfBranch,
+ IReadOnlyList ElseIfBranches,
+ List? ElseBranch,
+ SourcePosition Position) : ITemplateNode
+{
+ public IReadOnlyList Children
+ {
+ get
+ {
+ var children = new List();
+ children.AddRange(IfBranch.Body);
+ foreach (var branch in ElseIfBranches)
+ {
+ children.AddRange(branch.Body);
+ }
+ if (ElseBranch != null)
+ {
+ children.AddRange(ElseBranch);
+ }
+ return children;
+ }
+ }
+}
diff --git a/Inspectron.Epson.Templates/Language/Nodes/RowNode.cs b/Inspectron.Epson.Templates/Language/Nodes/RowNode.cs
new file mode 100644
index 0000000..88812c0
--- /dev/null
+++ b/Inspectron.Epson.Templates/Language/Nodes/RowNode.cs
@@ -0,0 +1,6 @@
+namespace Inspectron.Epson.Templates.Language.Nodes;
+
+public record RowNode(List Columns, SourcePosition Position) : ITemplateNode
+{
+ public IReadOnlyList Children => Columns;
+}
diff --git a/Inspectron.Epson.Templates/Language/Nodes/SeparatorNode.cs b/Inspectron.Epson.Templates/Language/Nodes/SeparatorNode.cs
new file mode 100644
index 0000000..abff506
--- /dev/null
+++ b/Inspectron.Epson.Templates/Language/Nodes/SeparatorNode.cs
@@ -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 Children => Array.Empty();
+}
diff --git a/Inspectron.Epson.Templates/Language/Nodes/StyledTextNode.cs b/Inspectron.Epson.Templates/Language/Nodes/StyledTextNode.cs
new file mode 100644
index 0000000..e033d6c
--- /dev/null
+++ b/Inspectron.Epson.Templates/Language/Nodes/StyledTextNode.cs
@@ -0,0 +1,9 @@
+namespace Inspectron.Epson.Templates.Language.Nodes;
+
+public record StyledTextNode(
+ IReadOnlyList Styles,
+ List ContentNodes,
+ SourcePosition Position) : ITemplateNode
+{
+ public IReadOnlyList Children => ContentNodes;
+}
diff --git a/Inspectron.Epson.Templates/Language/Nodes/TemplateNode.cs b/Inspectron.Epson.Templates/Language/Nodes/TemplateNode.cs
new file mode 100644
index 0000000..9b9ba31
--- /dev/null
+++ b/Inspectron.Epson.Templates/Language/Nodes/TemplateNode.cs
@@ -0,0 +1,7 @@
+namespace Inspectron.Epson.Templates.Language.Nodes;
+
+public record TemplateNode(List ChildNodes) : ITemplateNode
+{
+ public SourcePosition Position => new(1, 1);
+ public IReadOnlyList Children => ChildNodes;
+}
diff --git a/Inspectron.Epson.Templates/Language/Nodes/TextNode.cs b/Inspectron.Epson.Templates/Language/Nodes/TextNode.cs
new file mode 100644
index 0000000..775ef1b
--- /dev/null
+++ b/Inspectron.Epson.Templates/Language/Nodes/TextNode.cs
@@ -0,0 +1,6 @@
+namespace Inspectron.Epson.Templates.Language.Nodes;
+
+public record TextNode(string Text, SourcePosition Position) : ITemplateNode
+{
+ public IReadOnlyList Children => Array.Empty();
+}
diff --git a/Inspectron.Epson.Templates/Language/Parser.cs b/Inspectron.Epson.Templates/Language/Parser.cs
new file mode 100644
index 0000000..40b56a4
--- /dev/null
+++ b/Inspectron.Epson.Templates/Language/Parser.cs
@@ -0,0 +1,377 @@
+using Inspectron.Epson.Templates.Language.Nodes;
+
+namespace Inspectron.Epson.Templates.Language;
+
+public class Parser
+{
+ private readonly List _tokens;
+ private int _position;
+ private readonly List _errors = new();
+
+ public Parser(List 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 ParseNodeList(params TokenType[] terminators)
+ {
+ var nodes = new List();
+
+ 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();
+
+ // 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();
+ List? 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();
+
+ // 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();
+ 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();
+
+ // 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();
+
+ 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 Errors)
+{
+ public bool HasErrors => Errors.Count > 0;
+}
+
+public record ParseError(string Message, SourcePosition Position);
diff --git a/Inspectron.Epson.Templates/Language/Token.cs b/Inspectron.Epson.Templates/Language/Token.cs
new file mode 100644
index 0000000..47147bc
--- /dev/null
+++ b/Inspectron.Epson.Templates/Language/Token.cs
@@ -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));
+}
diff --git a/Inspectron.Epson.Templates/Storage/FileTemplateStorage.cs b/Inspectron.Epson.Templates/Storage/FileTemplateStorage.cs
new file mode 100644
index 0000000..e7e99d5
--- /dev/null
+++ b/Inspectron.Epson.Templates/Storage/FileTemplateStorage.cs
@@ -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 _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 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;
+ }
+}
diff --git a/Inspectron.Epson.Templates/Storage/ITemplateStorage.cs b/Inspectron.Epson.Templates/Storage/ITemplateStorage.cs
new file mode 100644
index 0000000..5220701
--- /dev/null
+++ b/Inspectron.Epson.Templates/Storage/ITemplateStorage.cs
@@ -0,0 +1,9 @@
+namespace Inspectron.Epson.Templates.Storage;
+
+public interface ITemplateStorage
+{
+ string? Load(string templatePath);
+ bool Exists(string templatePath);
+ void Reload();
+ IEnumerable ListTemplates();
+}
diff --git a/Inspectron.Epson.Templates/TemplateEngine.cs b/Inspectron.Epson.Templates/TemplateEngine.cs
new file mode 100644
index 0000000..00e9c53
--- /dev/null
+++ b/Inspectron.Epson.Templates/TemplateEngine.cs
@@ -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 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 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 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 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;
+ }
+}
diff --git a/Inspectron.Epson.Templates/TemplateReceiptConverter.cs b/Inspectron.Epson.Templates/TemplateReceiptConverter.cs
new file mode 100644
index 0000000..fe8df2b
--- /dev/null
+++ b/Inspectron.Epson.Templates/TemplateReceiptConverter.cs
@@ -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 Convert(string jsonContent)
+ {
+ return _engine.Render(_receiptType, _profile.Id, jsonContent);
+ }
+}
diff --git a/Inspectron.Epson.Templates/TemplateReceiptConverterFactory.cs b/Inspectron.Epson.Templates/TemplateReceiptConverterFactory.cs
new file mode 100644
index 0000000..7976468
--- /dev/null
+++ b/Inspectron.Epson.Templates/TemplateReceiptConverterFactory.cs
@@ -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);
+ }
+}
diff --git a/Inspectron.Epson.Templates/Templates/assignments.json b/Inspectron.Epson.Templates/Templates/assignments.json
new file mode 100644
index 0000000..d4a0850
--- /dev/null
+++ b/Inspectron.Epson.Templates/Templates/assignments.json
@@ -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"
+}
diff --git a/Inspectron.Epson.Templates/Templates/bar-default.template b/Inspectron.Epson.Templates/Templates/bar-default.template
new file mode 100644
index 0000000..89488f1
--- /dev/null
+++ b/Inspectron.Epson.Templates/Templates/bar-default.template
@@ -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
diff --git a/Inspectron.Epson.Templates/Templates/fallback.template b/Inspectron.Epson.Templates/Templates/fallback.template
new file mode 100644
index 0000000..c543f18
--- /dev/null
+++ b/Inspectron.Epson.Templates/Templates/fallback.template
@@ -0,0 +1,9 @@
+#center# Receipt #
+---
+@if Title
+{Title}
+@end
+@if TransactionDateTime
+{TransactionDateTime:dd-MMM-yy HH:mm}
+@end
+---
diff --git a/Inspectron.Epson.Templates/Templates/kitchen-default.template b/Inspectron.Epson.Templates/Templates/kitchen-default.template
new file mode 100644
index 0000000..d096ebc
--- /dev/null
+++ b/Inspectron.Epson.Templates/Templates/kitchen-default.template
@@ -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
diff --git a/Inspectron.Epson.Templates/Templates/kitchen-u220.template b/Inspectron.Epson.Templates/Templates/kitchen-u220.template
new file mode 100644
index 0000000..d096ebc
--- /dev/null
+++ b/Inspectron.Epson.Templates/Templates/kitchen-u220.template
@@ -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
diff --git a/Inspectron.Epson.Templates/Validation/TemplateError.cs b/Inspectron.Epson.Templates/Validation/TemplateError.cs
new file mode 100644
index 0000000..deebc6d
--- /dev/null
+++ b/Inspectron.Epson.Templates/Validation/TemplateError.cs
@@ -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}";
+}
diff --git a/Inspectron.Epson.Templates/Validation/TemplateValidationResult.cs b/Inspectron.Epson.Templates/Validation/TemplateValidationResult.cs
new file mode 100644
index 0000000..8539693
--- /dev/null
+++ b/Inspectron.Epson.Templates/Validation/TemplateValidationResult.cs
@@ -0,0 +1,35 @@
+namespace Inspectron.Epson.Templates.Validation;
+
+public record TemplateValidationResult(
+ List Errors,
+ List Warnings)
+{
+ public bool IsValid => Errors.Count == 0;
+ public bool HasWarnings => Warnings.Count > 0;
+
+ public static TemplateValidationResult Valid() => new(new List(), new List());
+
+ public static TemplateValidationResult WithErrors(params TemplateError[] errors) =>
+ new(errors.ToList(), new List());
+
+ public override string ToString()
+ {
+ if (IsValid && !HasWarnings)
+ {
+ return "Template is valid.";
+ }
+
+ var lines = new List();
+ 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);
+ }
+}
diff --git a/Inspectron.Epson.Templates/Validation/TemplateValidator.cs b/Inspectron.Epson.Templates/Validation/TemplateValidator.cs
new file mode 100644
index 0000000..0ebed22
--- /dev/null
+++ b/Inspectron.Epson.Templates/Validation/TemplateValidator.cs
@@ -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 _errors = new();
+ private readonly List _warnings = new();
+ private readonly HashSet _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 styles, SourcePosition position)
+ {
+ var validStyles = new HashSet(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;
+ }
+}
diff --git a/Inspectron.Epson.Templates/Validation/TemplateWarning.cs b/Inspectron.Epson.Templates/Validation/TemplateWarning.cs
new file mode 100644
index 0000000..1d55ec4
--- /dev/null
+++ b/Inspectron.Epson.Templates/Validation/TemplateWarning.cs
@@ -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}";
+}
diff --git a/Inspectron.Epson.slnx b/Inspectron.Epson.slnx
index 8b399f7..e8c9668 100644
--- a/Inspectron.Epson.slnx
+++ b/Inspectron.Epson.slnx
@@ -1,12 +1,12 @@
-
+
-
-
-
-
+
+
+
+
diff --git a/Inspectron.Epson/HtmlPrinter.cs b/Inspectron.Epson/HtmlPrinter.cs
index 914d3a3..517db9f 100644
--- a/Inspectron.Epson/HtmlPrinter.cs
+++ b/Inspectron.Epson/HtmlPrinter.cs
@@ -486,8 +486,8 @@ public class HtmlPrinter : IEpsonPrinter
var (baseWidth, baseHeight) = FontSizes[_currentFont];
// Apply multipliers
- int effectiveWidthMultiplier = _fontWidthMultiplier;
- int effectiveHeightMultiplier = _fontHeightMultiplier;
+ float effectiveWidthMultiplier = _fontWidthMultiplier;
+ float effectiveHeightMultiplier = _fontHeightMultiplier;
if (_isQuadrupleMode)
{
@@ -497,21 +497,21 @@ public class HtmlPrinter : IEpsonPrinter
if (_isBiggerFontWidthTM220)
{
- effectiveWidthMultiplier *= 2;
+ effectiveWidthMultiplier *= 2f;
}
if (_isBiggerFontHeightTM220)
{
- effectiveHeightMultiplier *= 2;
+ effectiveHeightMultiplier *= 1.2f;
}
- int fontSize = baseHeight * effectiveHeightMultiplier;
+ int fontSize = (int)(baseHeight * effectiveHeightMultiplier);
styles.Add($"font-size: {fontSize}px");
// Letter spacing for width scaling (approximate)
if (effectiveWidthMultiplier > 1)
{
- int letterSpacing = (effectiveWidthMultiplier) * baseWidth / 2;
+ int letterSpacing = (int)((effectiveWidthMultiplier) * baseWidth / 2);
styles.Add($"letter-spacing: {letterSpacing}px");
}
diff --git a/template_syntax.md b/template_syntax.md
new file mode 100644
index 0000000..b339a67
--- /dev/null
+++ b/template_syntax.md
@@ -0,0 +1,897 @@
+# Receipt Template Language (RTL) Syntax Documentation
+
+This document describes the syntax for creating receipt templates used by the Epson thermal printer template engine.
+
+## Table of Contents
+
+1. [Overview](#overview)
+2. [Basic Text](#basic-text)
+3. [Data Bindings](#data-bindings)
+4. [Styled Text](#styled-text)
+5. [Separators](#separators)
+6. [Comments](#comments)
+7. [Conditionals](#conditionals)
+8. [Loops](#loops)
+9. [Rows and Columns](#rows-and-columns)
+10. [Complete Example](#complete-example)
+11. [Printer Profiles](#printer-profiles)
+
+---
+
+## Overview
+
+RTL is a line-based template language designed for thermal receipt printers. Templates are plain text files with embedded directives, bindings, and style markers that get transformed into printer commands.
+
+**File Extension:** `.template`
+
+**Key Concepts:**
+- Templates are processed line by line
+- Data is provided as JSON and accessed via bindings `{path}`
+- Styles are applied using `#style# text #` syntax
+- Control flow uses `@directive` syntax
+
+---
+
+## Basic Text
+
+Plain text is output directly to the printer.
+
+```
+Hello World
+This is plain text
+```
+
+**Output:**
+```
+Hello World
+This is plain text
+```
+
+### Empty Lines
+
+Empty lines in templates produce empty lines on the receipt.
+
+```
+Line 1
+
+Line 3
+```
+
+---
+
+## Data Bindings
+
+Bindings insert values from the JSON data into the output.
+
+### Simple Binding
+
+```
+{PropertyName}
+```
+
+**JSON:**
+```json
+{"PropertyName": "Hello"}
+```
+
+**Output:**
+```
+Hello
+```
+
+### Nested Properties
+
+Use dot notation to access nested objects.
+
+```
+{Customer.Name}
+{Order.Items.0.Name}
+```
+
+**JSON:**
+```json
+{
+ "Customer": {"Name": "John Doe"},
+ "Order": {"Items": [{"Name": "Coffee"}]}
+}
+```
+
+**Output:**
+```
+John Doe
+Coffee
+```
+
+### Format Specifiers
+
+Add a format string after a colon to format numbers and dates.
+
+#### Numeric Formats
+
+```
+Total: {Amount:F2}
+Order #: {OrderNumber:D4}
+```
+
+**JSON:**
+```json
+{"Amount": 19.5, "OrderNumber": 42}
+```
+
+**Output:**
+```
+Total: 19.50
+Order #: 0042
+```
+
+| Format | Description | Example Input | Output |
+|--------|-------------|---------------|--------|
+| `F2` | Fixed-point, 2 decimals | 19.5 | 19.50 |
+| `F0` | Fixed-point, no decimals | 19.5 | 20 |
+| `D4` | Decimal, padded to 4 digits | 42 | 0042 |
+| `N2` | Number with grouping | 1234.5 | 1,234.50 |
+
+#### Date/Time Formats
+
+```
+Date: {TransactionDateTime:dd-MMM-yy}
+Time: {TransactionDateTime:HH:mm}
+Full: {TransactionDateTime:yyyy-MM-dd HH:mm:ss}
+```
+
+**JSON:**
+```json
+{"TransactionDateTime": "2024-03-15T14:30:00"}
+```
+
+**Output:**
+```
+Date: 15-Mar-24
+Time: 14:30
+Full: 2024-03-15 14:30:00
+```
+
+| Format | Description | Example Output |
+|--------|-------------|----------------|
+| `dd-MMM-yy` | Day-Month-Year | 15-Mar-24 |
+| `dd/MM/yyyy` | European date | 15/03/2024 |
+| `MM/dd/yyyy` | US date | 03/15/2024 |
+| `HH:mm` | 24-hour time | 14:30 |
+| `hh:mm tt` | 12-hour time | 02:30 PM |
+
+### Array Count
+
+Access the count/length of an array.
+
+```
+Items: {Items.count}
+```
+
+**JSON:**
+```json
+{"Items": ["A", "B", "C"]}
+```
+
+**Output:**
+```
+Items: 3
+```
+
+### Missing Values
+
+If a binding path doesn't exist or is null, an empty string is output.
+
+```
+Name: {MissingProperty}
+```
+
+**Output:**
+```
+Name:
+```
+
+---
+
+## Styled Text
+
+Apply formatting styles to text using the `#styles# content #` syntax.
+
+### Basic Syntax
+
+```
+#style1,style2# text content #
+```
+
+The styles are comma-separated and applied to all content between the markers.
+
+### Available Styles
+
+| Style | Description | PrintCommand Property |
+|-------|-------------|----------------------|
+| `bold` | Bold text | `IsBold = true` |
+| `big` | Double-width and double-height | `IsBig = true` |
+| `tall` | Double-height only | `IsTall = true` |
+| `red` | Red color (if printer supports) | `IsRed = true` |
+| `center` | Center-align text | Text padded with spaces |
+| `right` | Right-align text | Text padded with spaces |
+| `left` | Left-align text (default) | No padding |
+| `spacing:N` | Set line spacing to N | `SetLineSpacing = N` |
+
+### Examples
+
+#### Bold Text
+```
+#bold# Important Notice #
+```
+
+#### Centered Title
+```
+#center# RECEIPT #
+```
+
+#### Combined Styles
+```
+#bold,big,center# RESTAURANT NAME #
+```
+
+#### Red Text (Impact Printers)
+```
+#red,bold# WARNING #
+```
+
+#### With Bindings
+```
+#bold,center# {Title} #
+#big# Table: {TableNumber} #
+```
+
+#### Line Spacing
+```
+#spacing:50# Spaced text #
+```
+
+### Style Scope
+
+Styles apply only to content within the markers on the same line.
+
+```
+#bold# This is bold # but this is not
+```
+
+---
+
+## Separators
+
+Create horizontal lines using repeated characters.
+
+### Dash Separator
+```
+---
+```
+Output: `------------------------------------------------` (full line width)
+
+### Equals Separator
+```
+===
+```
+Output: `================================================`
+
+### Star Separator
+```
+***
+```
+Output: `************************************************`
+
+### Tilde Separator
+```
+~~~
+```
+Output: `~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~`
+
+**Note:** Separator width automatically matches the printer's line width (48 chars for TM-T30III, 33 chars for TM-U220II).
+
+---
+
+## Comments
+
+Add comments to templates for documentation purposes. Comments are stripped during parsing and do not appear in the output.
+
+### Single-Line Comment
+
+Use `@*` at the start of a line to comment out the entire line.
+
+```
+@* This is a single-line comment
+Regular text here
+```
+
+**Output:**
+```
+Regular text here
+```
+
+### Inline Comment
+
+Use `@* ... *@` to create a comment that can have content after it on the same line.
+
+```
+@* This is a comment *@
+@* Header section *@
+#bold,center# Title #
+```
+
+**Output:**
+```
+
+Title
+```
+
+### Multi-Line Comment
+
+Use `@*` on its own line to start a multi-line comment block, and `*@` to close it.
+
+```
+@*
+This is a multi-line comment.
+All these lines are ignored.
+Directives like @if are also ignored here.
+*@
+This text appears in output
+```
+
+**Output:**
+```
+This text appears in output
+```
+
+### Use Cases
+
+#### Document Template Sections
+```
+@* === HEADER SECTION === *@
+#bold,center# {Title} #
+---
+
+@* === ITEMS SECTION === *@
+@foreach item in Items
+{item.Name}
+@end
+```
+
+#### Temporarily Disable Code
+```
+@*
+@if DebugMode
+Debug info: {DebugData}
+@end
+*@
+```
+
+#### Add Notes for Maintainers
+```
+@* Note: This section only shows for orders over $100 *@
+@if Total > 100
+#bold# Large Order #
+@end
+```
+
+### Important Notes
+
+- Comments must start at the beginning of a line (after optional whitespace)
+- `@*` mid-line in content is treated as regular text, not a comment
+- Star separators (`***`) are not confused with comments
+- Unclosed multi-line comments produce a lexer error
+
+---
+
+## Conditionals
+
+Control which content is rendered based on data values.
+
+### Basic If
+
+```
+@if Condition
+Content shown when true
+@end
+```
+
+### If-Else
+
+```
+@if HasDiscount
+Discount Applied!
+@else
+No Discount
+@end
+```
+
+### If-ElseIf-Else
+
+```
+@if Status == "pending"
+Order Pending
+@elseif Status == "complete"
+Order Complete
+@else
+Unknown Status
+@end
+```
+
+### Truthy/Falsy Values
+
+The following are considered **falsy**:
+- `null` or missing property
+- `false`
+- Empty string `""`
+- Number `0`
+- Empty array `[]`
+
+Everything else is **truthy**.
+
+### Comparison Operators
+
+| Operator | Description | Example |
+|----------|-------------|---------|
+| `==` | Equal | `Status == "active"` |
+| `!=` | Not equal | `Type != "void"` |
+| `>` | Greater than | `Amount > 100` |
+| `<` | Less than | `Count < 5` |
+| `>=` | Greater or equal | `Total >= 50` |
+| `<=` | Less or equal | `Qty <= 10` |
+
+### Negation
+
+```
+@if !IsVoided
+Valid Order
+@end
+```
+
+### String Literals
+
+Use quotes for string comparisons.
+
+```
+@if Status == "active"
+Active
+@end
+```
+
+### Numeric Comparisons
+
+```
+@if Amount > 100
+Large Order
+@end
+
+@if Items.count > 0
+Has Items
+@end
+```
+
+### Examples
+
+#### Check for Optional Field
+```
+@if SpecialInstruction
+#bold# Note: {SpecialInstruction} #
+@end
+```
+
+#### Check Array Has Items
+```
+@if Modifications.count > 0
+Modifications:
+@foreach mod in Modifications
+ - {mod}
+@end
+@end
+```
+
+#### Conditional Separator
+```
+@if Items.count > 0
+---
+@end
+```
+
+---
+
+## Loops
+
+Iterate over arrays in the data.
+
+### Basic Foreach
+
+```
+@foreach item in Items
+{item}
+@end
+```
+
+**JSON:**
+```json
+{"Items": ["Apple", "Banana", "Cherry"]}
+```
+
+**Output:**
+```
+Apple
+Banana
+Cherry
+```
+
+### Object Properties
+
+```
+@foreach product in Products
+{product.Name} - {product.Price:F2}
+@end
+```
+
+**JSON:**
+```json
+{
+ "Products": [
+ {"Name": "Coffee", "Price": 3.50},
+ {"Name": "Tea", "Price": 2.50}
+ ]
+}
+```
+
+**Output:**
+```
+Coffee - 3.50
+Tea - 2.50
+```
+
+### Nested Loops
+
+```
+@foreach category in Categories
+#bold# {category.Name} #
+@foreach item in category.Items
+ {item.Name}
+@end
+@end
+```
+
+### Loop Metadata Variables
+
+Inside a loop, these special variables are available:
+
+| Variable | Description | Example Value |
+|----------|-------------|---------------|
+| `_index` | Zero-based index | 0, 1, 2, ... |
+| `_number` | One-based number | 1, 2, 3, ... |
+| `_first` | True if first item | true/false |
+| `_last` | True if last item | true/false |
+| `_count` | Total items in collection | 5 |
+
+#### Examples
+
+```
+@foreach item in Items
+{_number}. {item.Name}
+@end
+```
+
+**Output:**
+```
+1. Apple
+2. Banana
+3. Cherry
+```
+
+```
+@foreach item in Items
+{item}
+@if !_last
+---
+@end
+@end
+```
+
+**Output:**
+```
+Apple
+---
+Banana
+---
+Cherry
+```
+
+### Nested Path Collections
+
+```
+@foreach dish in Order.Kitchen.Dishes
+{dish.Name}
+@end
+```
+
+---
+
+## Rows and Columns
+
+Create tabular layouts with fixed-width columns.
+
+### Basic Row
+
+```
+@row
+|width|content|width|content
+@endrow
+```
+
+### Column Syntax
+
+```
+|width|content
+|width,alignment|content
+```
+
+- **width**: Number of characters for the column
+- **alignment**: `left` (default), `right`, or `center`
+
+### Examples
+
+#### Two Columns
+```
+@row
+|30|{Name}|10,right|{Price:F2}
+@endrow
+```
+
+**JSON:**
+```json
+{"Name": "Coffee", "Price": 3.50}
+```
+
+**Output:**
+```
+Coffee 3.50
+```
+
+#### Three Columns
+```
+@row
+|5,right|{Qty}|25|{Name}|10,right|{Total:F2}
+@endrow
+```
+
+**Output:**
+```
+ 2 Espresso 7.00
+```
+
+#### Header Row
+```
+@row
+|5|Qty|25|Item|10,right|Price
+@endrow
+===
+```
+
+### Columns in Loops
+
+```
+@foreach item in Items
+@row
+|5,right|{item.Quantity}|25|{item.Name}|10,right|{item.Price:F2}
+@endrow
+@end
+```
+
+### Text Truncation
+
+If content exceeds the column width, it is truncated.
+
+```
+@row
+|10|VeryLongProductNameHere
+@endrow
+```
+
+**Output:**
+```
+VeryLongPr
+```
+
+---
+
+## Complete Example
+
+Here's a complete kitchen receipt template:
+
+```
+#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
+```
+
+**Sample JSON:**
+```json
+{
+ "Title": "Restaurant Kitchen",
+ "TransactionDateTime": "2024-03-15T14:30:00",
+ "ReceiptNumber": "12345",
+ "WaiterName": "John Doe",
+ "WaiterId": "W001",
+ "TableNumber": "5",
+ "SpecialInstruction": "Rush Order",
+ "Gangs": [
+ {
+ "Id": 1,
+ "Name": "Starters",
+ "Dishes": [
+ {
+ "Number": 2,
+ "Name": "Caesar Salad",
+ "Modifications": {
+ "Removed": ["Croutons"],
+ "Added": ["Extra Dressing"]
+ },
+ "Comment": "No anchovies"
+ }
+ ]
+ }
+ ],
+ "Dishes": []
+}
+```
+
+---
+
+## Printer Profiles
+
+Templates adapt to different printer capabilities using profiles.
+
+### Built-in Profiles
+
+| Printer | Profile ID | Line Width | Big Width | Red Support |
+|---------|------------|------------|-----------|-------------|
+| TM-T30III | `tm-t30iii` | 48 | 24 | No |
+| TM-U220II | `tm-u220ii` | 33 | 20 | Yes |
+
+### How Profiles Affect Output
+
+1. **Line Width**: Separators and centered text use the profile's line width
+2. **Big Width**: When `big` style is applied, centering uses the reduced width
+3. **Red Support**: The `red` style only produces red output on supported printers
+
+### Template Assignment
+
+Templates are assigned to receipt types and printer profiles in `assignments.json`:
+
+```json
+{
+ "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"
+ }
+ ],
+ "fallbackTemplate": "fallback.template"
+}
+```
+
+**Resolution Priority:**
+1. Exact match (receiptType + profileId)
+2. Type-only match (receiptType, no profileId)
+3. Fallback template
+
+---
+
+## Quick Reference
+
+### Syntax Summary
+
+| Syntax | Description |
+|--------|-------------|
+| `{path}` | Data binding |
+| `{path:format}` | Formatted binding |
+| `#style# text #` | Styled text |
+| `---` | Dash separator |
+| `===` | Equals separator |
+| `***` | Star separator |
+| `~~~` | Tilde separator |
+| `@* comment` | Single-line comment |
+| `@* comment *@` | Inline comment |
+| `@*` ... `*@` | Multi-line comment block |
+| `@if condition` | Start conditional |
+| `@elseif condition` | Else-if branch |
+| `@else` | Else branch |
+| `@end` | End block |
+| `@foreach var in collection` | Start loop |
+| `@row` | Start row |
+| `@endrow` | End row |
+| `\|width\|content` | Column definition |
+| `\|width,align\|content` | Column with alignment |
+
+### Style Reference
+
+| Style | Effect |
+|-------|--------|
+| `bold` | Bold text |
+| `big` | Double size |
+| `tall` | Double height |
+| `red` | Red color |
+| `center` | Center align |
+| `right` | Right align |
+| `spacing:N` | Line spacing |
+
+### Loop Variables
+
+| Variable | Value |
+|----------|-------|
+| `_index` | 0, 1, 2, ... |
+| `_number` | 1, 2, 3, ... |
+| `_first` | true/false |
+| `_last` | true/false |
+| `_count` | total count |