diff --git a/EpsonTest.Templates/EpsonTest.Templates.csproj b/EpsonTest.Templates/EpsonTest.Templates.csproj
new file mode 100644
index 0000000..9d2ed32
--- /dev/null
+++ b/EpsonTest.Templates/EpsonTest.Templates.csproj
@@ -0,0 +1,27 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+
+
diff --git a/EpsonTest.Templates/Program.cs b/EpsonTest.Templates/Program.cs
new file mode 100644
index 0000000..b684ac9
--- /dev/null
+++ b/EpsonTest.Templates/Program.cs
@@ -0,0 +1,118 @@
+using EpsonTest.Templates;
+using Inspectron.Epson.TemplateEngine;
+
+var engine = new ReceiptTemplateEngine();
+var templatesDir = Path.Combine(AppContext.BaseDirectory, "Templates");
+
+// ============================================================================
+// FINAL RECEIPT - HTML PRINTER
+// ============================================================================
+var json = TestHelpers.BuildFinalReceiptJson(includeTax: true);
+var template = File.ReadAllText(Path.Combine(templatesDir, "FinalReceipt.xml"));
+var printCommands = engine.Render(template, json, lineWidth: 48, bigFontLineWidth: 24);
+
+TestHelpers.PrintCommandsToConsole(printCommands);
+
+var printer = await TestHelpers.CreateHtmlPrinterAsync();
+await TestHelpers.PrintCommandsToHtmlPrinterAsync(
+ printer,
+ printCommands,
+ logoPath: "ristorante-klinglers.ch-logo_white_bg.png");
+
+
+// ============================================================================
+// FINAL RECEIPT - EPSON PRINTER
+// ============================================================================
+//var json = TestHelpers.BuildFinalReceiptJson();
+//var template = File.ReadAllText(Path.Combine(templatesDir, "FinalReceipt.xml"));
+//var printCommands = engine.Render(template, json, lineWidth: 48, bigFontLineWidth: 24);
+
+//TestHelpers.PrintCommandsToConsole(printCommands);
+
+//var printer = await TestHelpers.CreateEpsonPrinterAsync();
+//await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands);
+
+
+// ============================================================================
+// INVOICE RECEIPT - HTML PRINTER
+// ============================================================================
+//var json = TestHelpers.BuildInvoiceReceiptJson();
+//var template = File.ReadAllText(Path.Combine(templatesDir, "InvoiceReceipt.xml"));
+//var printCommands = engine.Render(template, json, lineWidth: 48, bigFontLineWidth: 24);
+
+//TestHelpers.PrintCommandsToConsole(printCommands);
+
+//var printer = await TestHelpers.CreateHtmlPrinterAsync();
+//await TestHelpers.PrintCommandsToHtmlPrinterAsync(
+// printer,
+// printCommands,
+// logoPath: "ristorante-klinglers.ch-logo_white_bg.png");
+
+
+// ============================================================================
+// INVOICE RECEIPT - EPSON PRINTER
+// ============================================================================
+//var json = TestHelpers.BuildInvoiceReceiptJson();
+//var template = File.ReadAllText(Path.Combine(templatesDir, "InvoiceReceipt.xml"));
+//var printCommands = engine.Render(template, json, lineWidth: 48, bigFontLineWidth: 24);
+
+//TestHelpers.PrintCommandsToConsole(printCommands);
+
+//var printer = await TestHelpers.CreateEpsonPrinterAsync();
+//await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands);
+
+
+// ============================================================================
+// KITCHEN RECEIPT - HTML PRINTER
+// ============================================================================
+//var json = TestHelpers.BuildKitchenReceiptJson();
+//var template = File.ReadAllText(Path.Combine(templatesDir, "KitchenReceipt.xml"));
+//var printCommands = engine.Render(template, json, lineWidth: 33, bigFontLineWidth: 20);
+
+//TestHelpers.PrintCommandsToConsole(printCommands);
+
+//var printer = await TestHelpers.CreateHtmlPrinterAsync(
+// path: @".\kitchen_test.html",
+// paperWidth: TestHelpers.KitchenPaperWidth);
+//await TestHelpers.PrintKitchenCommandsToHtmlPrinterAsync(printer, printCommands);
+
+
+// ============================================================================
+// KITCHEN RECEIPT (NEXT DISH) - HTML PRINTER
+// ============================================================================
+//var json = TestHelpers.BuildKitchenReceiptNextDishJson();
+//var template = File.ReadAllText(Path.Combine(templatesDir, "KitchenReceipt.xml"));
+//var printCommands = engine.Render(template, json, lineWidth: 33, bigFontLineWidth: 20);
+
+//TestHelpers.PrintCommandsToConsole(printCommands);
+
+//var printer = await TestHelpers.CreateHtmlPrinterAsync(
+// path: @".\kitchen_next_dish.html",
+// paperWidth: TestHelpers.KitchenPaperWidth);
+//await TestHelpers.PrintKitchenCommandsToHtmlPrinterAsync(printer, printCommands);
+
+
+// ============================================================================
+// KITCHEN RECEIPT - EPSON PRINTER
+// ============================================================================
+//var json = TestHelpers.BuildKitchenReceiptJson();
+//var template = File.ReadAllText(Path.Combine(templatesDir, "KitchenReceipt.xml"));
+//var printCommands = engine.Render(template, json, lineWidth: 33, bigFontLineWidth: 20);
+
+//TestHelpers.PrintCommandsToConsole(printCommands);
+
+//var printer = await TestHelpers.CreateEpsonPrinterAsync(paperWidth: TestHelpers.KitchenPaperWidth);
+//await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands, useDelay: true);
+
+
+// ============================================================================
+// ORDER ITEMS RECEIPT - HTML PRINTER
+// ============================================================================
+//var json = TestHelpers.BuildOrderItemsJson();
+//var template = File.ReadAllText(Path.Combine(templatesDir, "OrderItems.xml"));
+//var printCommands = engine.Render(template, json, lineWidth: 48, bigFontLineWidth: 24);
+
+//TestHelpers.PrintCommandsToConsole(printCommands);
+
+//var printer = await TestHelpers.CreateHtmlPrinterAsync(path: @".\order_items.html");
+//await TestHelpers.PrintCommandsToHtmlPrinterAsync(printer, printCommands);
diff --git a/EpsonTest.Templates/Templates/FinalReceipt.xml b/EpsonTest.Templates/Templates/FinalReceipt.xml
new file mode 100644
index 0000000..fdeabc0
--- /dev/null
+++ b/EpsonTest.Templates/Templates/FinalReceipt.xml
@@ -0,0 +1,115 @@
+
+ {{CompanyName}}
+ {{Address1}}
+ {{Address2}}
+ {{Phone}}
+
+
+
+
+ Debitorenrechnung
+
+
+
+ Guests: {{Guests}}
+
+
+
+
+
+ - {{sub}}
+
+
+
+
+ ---------
+
+
+ Summe: {{Total:F2}} {{Currency}}
+
+
+
+ {{TotalInAlternateCurrency:F2}} {{AlternateCurrency}}
+
+
+
+
+
+
+
+
+
+ {{sp.PaymentMethod}}: {{sp.Amount:F2}} {{sp.Currency}}
+
+
+
+
+
+
+
+
+
+
+
+ MwSt %
+ Brutto
+ Netto
+ MwSt
+
+
+
+ {{tax.Category}}:{{tax.Rate}}%
+ {{tax.Gross:F2}} {{tax.Currency}}
+ {{tax.Net:F2}} {{tax.Currency}}
+ {{tax.TaxAmount:F2}} {{tax.Currency}}
+
+
+
+
+ Nicht mehrwertsteuerpflichtig
+
+
+
+
+
+
+
+
+
+
+
+
+ {{VatNumber}}
+
+
+
+ {{tr.ReceiptType}}
+ {{tr.BookingType}}
+ {{tr.PaymentSystem}}
+ {{tr.TransactionNumber}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ThankYouMessage}}
+ {{GoodbyeMessageLine1}}
+ {{GoodbyeMessageLine2}}
+
+
+
+
+
+
+ Unterschrift
+
+
diff --git a/EpsonTest.Templates/Templates/InvoiceReceipt.xml b/EpsonTest.Templates/Templates/InvoiceReceipt.xml
new file mode 100644
index 0000000..df4224b
--- /dev/null
+++ b/EpsonTest.Templates/Templates/InvoiceReceipt.xml
@@ -0,0 +1,29 @@
+
+ {{CompanyName}}
+ {{Address1}}
+ {{Address2}}
+ {{Phone}}
+
+
+
+
+
+ Guests: {{Guests}}
+
+
+
+
+
+
+ - {{sub}}
+
+
+
+
+ ---------
+
+
+ Summe: {{Total:F2}} {{Currency}}
+ Tisch: {{TableNumber}}
+ {{ThankYouMessage}}
+
diff --git a/EpsonTest.Templates/Templates/KitchenReceipt.xml b/EpsonTest.Templates/Templates/KitchenReceipt.xml
new file mode 100644
index 0000000..201933d
--- /dev/null
+++ b/EpsonTest.Templates/Templates/KitchenReceipt.xml
@@ -0,0 +1,65 @@
+
+ {{Title}}
+
+
+
+ {{TransactionDateTime:dd-MMM-yy HH:mm}} Nr.:{{ReceiptNumber}}
+ {{WaiterName}}
+ Tisch: {{TableNumber}}
+
+
+
+ {{SpecialInstruction}}
+
+
+
+
+
+
+ {{gang.Id}}. {{gang.Name}}
+
+ {{dish.GuestPrefix}}{{dish.Number}}x {{dish.Name}}
+
+
+ - {{removed}}
+
+
+ + {{added}}
+
+
+
+ Comment: {{dish.Comment}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{dish.GuestPrefix}}{{dish.Number}}x {{dish.Name}}
+
+
+ - {{removed}}
+
+
+ + {{added}}
+
+
+
+ Comment: {{dish.Comment}}
+
+
+
+
+
+
+
+
+
diff --git a/EpsonTest.Templates/Templates/OrderItems.xml b/EpsonTest.Templates/Templates/OrderItems.xml
new file mode 100644
index 0000000..39f5cf8
--- /dev/null
+++ b/EpsonTest.Templates/Templates/OrderItems.xml
@@ -0,0 +1,34 @@
+
+
+ Order:
+ QR:
+ {{DateTime:dd.MM.yyyy}}
+
+
+
+ |Client Notes: {{ClientNotes}}
+
+
+
+
+
+
+
+
+ - {{sub}}
+
+
+ Change of
+ Ingredients:
+
+ - {{removed}}
+
+
+ + {{added}}
+
+
+
+ SpecialInstruction: {{item.Comment}}
+
+
+
diff --git a/EpsonTest.Templates/TestHelpers.cs b/EpsonTest.Templates/TestHelpers.cs
new file mode 100644
index 0000000..96e1d9e
--- /dev/null
+++ b/EpsonTest.Templates/TestHelpers.cs
@@ -0,0 +1,579 @@
+using Inspectron.Epson;
+using Inspectron.Epson.PrintServer.Printers.Utils;
+using Inspectron.Epson.PrintServer.Printers.Utils.FinalRecipe;
+using Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt;
+using Inspectron.Epson.PrintServer.Printers.Utils.KitchenReceipt;
+using Inspectron.Epson.PrintServer.Printers.Utils.OrderItems;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace EpsonTest.Templates;
+
+public static class TestHelpers
+{
+ // Default connection settings
+ public const string DefaultEpsonIp = "127.0.0.1";
+ public const int DefaultEpsonPort = 8888;
+ public const string DefaultHtmlPath = @".\test.html";
+ public const int DefaultPaperWidth = 384;
+ public const int KitchenPaperWidth = 258;
+
+ #region Printer Creation
+
+ public static async Task CreateEpsonPrinterAsync(
+ string ip = DefaultEpsonIp,
+ int port = DefaultEpsonPort, int paperWidth = DefaultPaperWidth)
+ {
+ var printer = new EpsonPrinter();
+ await printer.ConnectAsync(ip, port);
+ return printer;
+ }
+
+ public static async Task CreateHtmlPrinterAsync(
+ string path = DefaultHtmlPath,
+ int paperWidth = DefaultPaperWidth)
+ {
+ var printer = new HtmlPrinter(path, paperWidth: paperWidth);
+ await printer.ConnectAsync("");
+ return printer;
+ }
+
+ #endregion
+
+ #region Print Helpers
+
+ public static async Task PrintCommandsToHtmlPrinterAsync(
+ HtmlPrinter printer,
+ IEnumerable commands,
+ string? logoPath = null,
+ int logoWidth = 300,
+ int logoXPosition = 42)
+ {
+ if (logoPath != null)
+ {
+ await printer.SetAbsolutePrintPosition(logoXPosition);
+ await printer.LoadImageAsync(logoPath, logoWidth);
+ await printer.PrintLoadedImage();
+ await printer.SetAbsolutePrintPosition(0);
+ }
+
+ await printer.SetCustomLineSpacing(22);
+
+ foreach (var command in commands)
+ {
+ if (command.IsBig)
+ await printer.SetFontSizeAsync(2, 1);
+ else
+ await printer.SetFontSizeAsync(1, 1);
+
+ await printer.SetEmphasized(command.IsBold);
+ await printer.PrintTextAsync(command.Text + "\n");
+ }
+ }
+
+ public static async Task PrintCommandsToEpsonPrinterAsync(
+ EpsonPrinter printer,
+ IEnumerable commands,
+ bool useDelay = true,
+ int delayMs = 200)
+ {
+ await printer.FeedLinesAsync(1);
+ await printer.SetCustomLineSpacing(22);
+
+ foreach (var command in commands)
+ {
+ if (command.IsCut)
+ {
+ await printer.FeedLinesAsync(10);
+ if (useDelay) await Task.Delay(delayMs * 5);
+ await printer.CutAsync(false);
+ if (useDelay) await Task.Delay(delayMs);
+ continue;
+
+ }
+
+ await printer.SetBiggerFontTM220(command.IsBig, command.IsTall | command.IsBig, secondaryFont: false);
+ if (useDelay) await Task.Delay(delayMs);
+
+ await printer.SetRedColor(command.IsRed);
+ if (useDelay) await Task.Delay(delayMs);
+
+ await printer.SetEmphasized(command.IsBold);
+ if (useDelay) await Task.Delay(delayMs);
+
+ await printer.PrintTextAsync(command.Text + "\n");
+ if (useDelay) await Task.Delay(delayMs);
+ }
+
+ await printer.FeedLinesAsync(10);
+ if (useDelay) await Task.Delay(delayMs * 5);
+ await printer.CutAsync();
+ }
+
+ public static async Task PrintKitchenCommandsToHtmlPrinterAsync(
+ HtmlPrinter printer,
+ IEnumerable commands)
+ {
+ await printer.FeedLinesAsync(1);
+ await printer.SetCustomLineSpacing(22);
+
+ foreach (var command in commands)
+ {
+ if (command.IsCut)
+ {
+ await printer.CutAsync(false);
+ continue;
+ }
+ if (command.IsBig)
+ await printer.SetFontSizeAsync(2, 1);
+ else
+ await printer.SetFontSizeAsync(1, 1);
+
+ await printer.SetRedColor(command.IsRed);
+ await printer.SetEmphasized(command.IsBold);
+ await printer.PrintTextAsync(command.Text + "\n");
+ }
+ }
+
+ #endregion
+
+ #region Console Output
+
+ public static void PrintCommandsToConsole(IEnumerable commands)
+ {
+ Console.WriteLine("=== PRINT COMMANDS ===\n");
+ foreach (var command in commands)
+ {
+ string attributes = "";
+ if (command.IsBig) attributes += "[BIG] ";
+ if (command.IsBold) attributes += "[BOLD] ";
+ if (command.IsRed) attributes += "[RED] ";
+
+ Console.WriteLine($"{attributes}{command.Text}");
+ }
+ }
+
+ #endregion
+
+ #region JSON Data Builders
+
+ public static string BuildKitchenReceiptJson()
+ {
+ 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"
+ };
+
+ receipt.Gangs.Add(new Gang(1, "Gang"));
+ 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",
+ GuestId = 1
+ });
+ receipt.Gangs[0].Dishes.Add(new Dish(1, "Baby Spinach Salad with Truffl") { GuestId = 2 });
+
+ receipt.Gangs[1].Dishes.Add(new Dish(1, "Avocado Sashimi") { GuestId = 1 });
+ receipt.Gangs[1].Dishes.Add(new Dish(1, "Baby Spinach Salad with Truffl") { GuestId = 2 });
+
+ return SerializeWithComputedProps(receipt);
+ }
+
+ public static string BuildKitchenReceiptNextDishJson()
+ {
+ 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 = "Gang schicken"
+ };
+
+ receipt.Gangs.Add(new Gang(2, "Gang"));
+
+ return SerializeWithComputedProps(receipt);
+ }
+
+ public static string BuildFinalReceiptJson(bool includeTax = true)
+ {
+ var receipt = new FinalReceipt
+ {
+ CompanyName = "Klingler Gastro AG",
+ Address1 = "Münzplatz 3",
+ Address2 = "CH-8001 Zürich",
+ Phone = "043 321 22 22",
+ ReceiptNumber = null,
+ DateTime = new DateTime(2025, 12, 16, 14, 58, 0),
+ Guests = 2,
+ Total = 160.00m,
+ Currency = "CHF",
+ TotalInAlternateCurrency = 172.80m,
+ AlternateCurrency = "EUR",
+ PaymentMethod = "MASTER",
+ PaymentAmount = 160.00m,
+ WaiterName = "Yves",
+ Terminal = "Hauptkasse ZH",
+ TableNumber = "12",
+ VatNumber = "CHE-449.635.880 MWST",
+ ThankYouMessage = "Das Team bedankt sich herzlich für Ihren",
+ GoodbyeMessageLine1 = "Besuch.",
+ GoodbyeMessageLine2 = "Auf Wiedersehen.",
+ IsDebtor = true,
+ SplitPayments = new List
+ {
+ new() { PaymentMethod = "Cash", Amount = 50.00m, Currency = "CHF" },
+ new() { PaymentMethod = "Card", Amount = 110.00m, Currency = "CHF" }
+ },
+ TerminalReceipts = new List
+ {
+ new()
+ {
+ ReceiptType = "*** Kundenbeleg ***",
+ BookingType = "Buchung",
+ PaymentSystem = "TWINT",
+ TransactionNumber = "XXXXXXXXXXXXXXX1494",
+ TransactionDateTime = new DateTime(2025, 11, 11, 12, 34, 49),
+ TerminalId = "31108834",
+ AID = "A0000015749E",
+ TransactionSeqCount = "6127",
+ TransactionRefNo = "99036644599",
+ AuthCode = "0d3396",
+ AcquirerId = "2",
+ EftAmount = 67.00m,
+ TipAmount = 6.70m,
+ TotalEftAmount = 73.70m,
+ Currency = "CHF"
+ },
+ new()
+ {
+ ReceiptType = "*** Kundenbeleg ***",
+ BookingType = "Buchung",
+ PaymentSystem = "MASTER",
+ TransactionNumber = "XXXXXXXXXXXXXXX2587",
+ TransactionDateTime = new DateTime(2025, 11, 11, 12, 36, 12),
+ TerminalId = "31108834",
+ AID = "A0000000041010",
+ TransactionSeqCount = "6128",
+ TransactionRefNo = "99036644600",
+ AuthCode = "1a4b2c",
+ AcquirerId = "1",
+ EftAmount = 93.00m,
+ TipAmount = 0.00m,
+ TotalEftAmount = 93.00m,
+ Currency = "CHF"
+ }
+ },
+ DiscountInfo = new DiscountInfo()
+ {
+ Amount = -50.00m,
+ Currency = "CHF",
+ Description = "Fine Dine",
+ }
+ };
+
+ receipt.Items.Add(new FinalReceiptItem
+ {
+ Quantity = 1, Description = "San Pellegrino 50cl",
+ UnitPrice = 6.50m, TotalPrice = 6.50m, TaxCategory = "A"
+ });
+ receipt.Items.Add(new FinalReceiptItem
+ {
+ Quantity = 1, Description = "Panna 50cl",
+ UnitPrice = 6.50m, TotalPrice = 6.50m, TaxCategory = "A"
+ });
+ receipt.Items.Add(new FinalReceiptItem
+ {
+ Quantity = 1, Description = "An item with really long description that should wrap",
+ UnitPrice = 5.50m, TotalPrice = 5.50m, TaxCategory = "A"
+ });
+ receipt.Items.Add(new FinalReceiptItem
+ {
+ Quantity = 2, Description = "Business Lunch Menu Seco",
+ UnitPrice = 44.00m, TotalPrice = 88.00m, TaxCategory = "A",
+ SubItems = new List { "Tomato Soup", "Grilled Salmon", "Tiramisu" }
+ });
+ receipt.Items.Add(new FinalReceiptItem
+ {
+ Quantity = 2, Description = "Brunello di Montalcino 1",
+ UnitPrice = 16.00m, TotalPrice = 32.00m, TaxCategory = "A"
+ });
+ receipt.Items.Add(new FinalReceiptItem
+ {
+ Quantity = 2, Description = "Espresso",
+ UnitPrice = 5.50m, TotalPrice = 11.00m, TaxCategory = "A"
+ });
+ receipt.Items.Add(new FinalReceiptItem
+ {
+ Quantity = 1, Description = "Tip",
+ UnitPrice = 10.50m, TotalPrice = 10.50m, TaxCategory = "B"
+ });
+
+ if (includeTax)
+ {
+ receipt.TaxBreakdown.Add(new TaxInfo
+ {
+ Category = "A", Rate = 8.1m, Gross = 149.50m,
+ Net = 138.30m, TaxAmount = 11.20m, Currency = "CHF"
+ });
+ receipt.TaxBreakdown.Add(new TaxInfo
+ {
+ Category = "B", Rate = 0m, Gross = 10.50m,
+ Net = 10.50m, TaxAmount = 0.00m, Currency = "CHF"
+ });
+ }
+
+ return SerializeWithComputedProps(receipt);
+ }
+
+ public static string BuildInvoiceReceiptJson()
+ {
+ var receipt = new InvoiceReceipt
+ {
+ CompanyName = "Klingler Gastro AG",
+ Address1 = "Münzplatz 3",
+ Address2 = "CH-8001 Zürich",
+ Phone = "043 321 22 22",
+ ReceiptNumber = null,
+ DateTime = new DateTime(2025, 12, 16, 14, 58, 0),
+ Guests = 2,
+ Total = 160.00m,
+ Currency = "CHF",
+ TableNumber = "12",
+ ThankYouMessage = "Thank you!"
+ };
+
+ receipt.Items.Add(new Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt.ReceiptItem
+ {
+ Quantity = 1, Description = "San Pellegrino 50cl",
+ UnitPrice = 6.50m, TotalPrice = 6.50m, TaxCategory = "A"
+ });
+ receipt.Items.Add(new Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt.ReceiptItem
+ {
+ Quantity = 1, Description = "Panna 50cl",
+ UnitPrice = 6.50m, TotalPrice = 6.50m, TaxCategory = "A"
+ });
+ receipt.Items.Add(new Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt.ReceiptItem
+ {
+ Quantity = 1, Description = "Granini Tomatensaft",
+ UnitPrice = 5.50m, TotalPrice = 5.50m, TaxCategory = "A"
+ });
+ receipt.Items.Add(new Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt.ReceiptItem
+ {
+ Quantity = 2, Description = "Business Lunch Menu Seco",
+ UnitPrice = 44.00m, TotalPrice = 88.00m, TaxCategory = "A",
+ SubItems = new List { "Tomato Soup", "Grilled Salmon", "Tiramisu" }
+ });
+ receipt.Items.Add(new Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt.ReceiptItem
+ {
+ Quantity = 2, Description = "Brunello di Montalcino 1",
+ UnitPrice = 16.00m, TotalPrice = 32.00m, TaxCategory = "A"
+ });
+ receipt.Items.Add(new Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt.ReceiptItem
+ {
+ Quantity = 2, Description = "Espresso",
+ UnitPrice = 5.50m, TotalPrice = 11.00m, TaxCategory = "A"
+ });
+ receipt.Items.Add(new Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt.ReceiptItem
+ {
+ Quantity = 1, Description = "Tip",
+ UnitPrice = 10.50m, TotalPrice = 10.50m, TaxCategory = "B"
+ });
+
+ return SerializeWithComputedProps(receipt);
+ }
+
+ public static string BuildOrderItemsJson()
+ {
+ var receipt = new OrderItemsReceipt
+ {
+ OrderNumber = "1",
+ QRInfo = "Margherita Pizza Classic longer text",
+ DateTime = new DateTime(2026, 1, 14, 14, 35, 47),
+ ClientNotes = "Some main note",
+ Items = new List
+ {
+ new()
+ {
+ Number = 1, Name = "pappara", Quantity = 1,
+ SubItems = new List { "pizza", "cola" },
+ Comment = "Cola zero please"
+ },
+ new()
+ {
+ Number = 2, Name = "Classic Beef Burger", Quantity = 1,
+ Modifications = new ItemModifications
+ {
+ Removed = new List { "Onion" },
+ Added = new List { "butter" }
+ },
+ Comment = "Medium rare please"
+ },
+ new()
+ {
+ Number = 3, Name = "Margherita Pizza Classic",
+ Quantity = 2, Size = "L"
+ }
+ }
+ };
+
+ return SerializeWithComputedProps(receipt);
+ }
+
+ #endregion
+
+ #region Computed Properties
+
+ private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true };
+
+ private static string SerializeWithComputedProps(object receipt)
+ {
+ var json = JsonSerializer.Serialize(receipt, SerializerOptions);
+ var node = JsonNode.Parse(json)!;
+
+ switch (receipt)
+ {
+ case KitchenReceipt kr:
+ AddKitchenComputedProps(node, kr);
+ break;
+ case FinalReceipt fr:
+ AddFinalReceiptComputedProps(node, fr);
+ break;
+ case InvoiceReceipt ir:
+ AddInvoiceComputedProps(node, ir);
+ break;
+ case OrderItemsReceipt or:
+ AddOrderItemsComputedProps(node, or);
+ break;
+ }
+
+ return node.ToJsonString(SerializerOptions);
+ }
+
+ private static void AddKitchenComputedProps(JsonNode node, KitchenReceipt receipt)
+ {
+ node["HasGangs"] = receipt.Gangs.Count > 0;
+ node["HasDishes"] = receipt.Dishes.Count > 0;
+
+ // Add GuestPrefix and HasModifications to dishes in gangs
+ var gangsArray = node["Gangs"]?.AsArray();
+ if (gangsArray != null)
+ {
+ for (int g = 0; g < receipt.Gangs.Count; g++)
+ {
+ var gangDishes = gangsArray[g]?["Dishes"]?.AsArray();
+ if (gangDishes != null)
+ {
+ for (int d = 0; d < receipt.Gangs[g].Dishes.Count; d++)
+ {
+ var dish = receipt.Gangs[g].Dishes[d];
+ gangDishes[d]!["GuestPrefix"] = dish.GuestId.HasValue ? $"{dish.GuestId} " : "";
+
+ var mods = dish.Modifications;
+ bool hasMods = mods != null && (mods.Removed.Count > 0 || mods.Added.Count > 0);
+ if (gangDishes[d]!["Modifications"] == null)
+ gangDishes[d]!["Modifications"] = new JsonObject();
+ gangDishes[d]!["Modifications"]!["HasModifications"] = hasMods;
+ }
+ }
+ }
+ }
+
+ // Add GuestPrefix and HasModifications to top-level dishes
+ var dishesArray = node["Dishes"]?.AsArray();
+ if (dishesArray != null)
+ {
+ for (int d = 0; d < receipt.Dishes.Count; d++)
+ {
+ var dish = receipt.Dishes[d];
+ dishesArray[d]!["GuestPrefix"] = dish.GuestId.HasValue ? $"{dish.GuestId} " : "";
+
+ var mods = dish.Modifications;
+ bool hasMods = mods != null && (mods.Removed.Count > 0 || mods.Added.Count > 0);
+ if (dishesArray[d]!["Modifications"] == null)
+ dishesArray[d]!["Modifications"] = new JsonObject();
+ dishesArray[d]!["Modifications"]!["HasModifications"] = hasMods;
+ }
+ }
+ }
+
+ private static void AddFinalReceiptComputedProps(JsonNode node, FinalReceipt receipt)
+ {
+ node["ReceiptLabel"] = receipt.ReceiptNumber != null
+ ? $"Rechnung Nr. {receipt.ReceiptNumber}"
+ : "";
+
+ node["HasSplitPayments"] = receipt.SplitPayments.Count > 0;
+ node["HasTaxableCategories"] = receipt.TaxBreakdown.Any(t =>
+ !string.Equals(t.Category, "d", StringComparison.OrdinalIgnoreCase));
+
+ var itemsArray = node["Items"]?.AsArray();
+ if (itemsArray != null)
+ {
+ for (int i = 0; i < receipt.Items.Count; i++)
+ {
+ var item = receipt.Items[i];
+ itemsArray[i]!["PriceDisplay"] =
+ $"{item.UnitPrice:F2}" +
+ $"{item.TotalPrice:F2}".PadLeft(7) +
+ $" {item.TaxCategory}";
+ }
+ }
+ }
+
+ private static void AddInvoiceComputedProps(JsonNode node, InvoiceReceipt receipt)
+ {
+ node["ReceiptLabel"] = receipt.ReceiptNumber != null
+ ? $"Rechnung Nr. {receipt.ReceiptNumber}"
+ : "";
+
+ var itemsArray = node["Items"]?.AsArray();
+ if (itemsArray != null)
+ {
+ for (int i = 0; i < receipt.Items.Count; i++)
+ {
+ var item = receipt.Items[i];
+ itemsArray[i]!["PriceDisplay"] =
+ $"{item.UnitPrice:F2}" +
+ $"{item.TotalPrice:F2}".PadLeft(7) +
+ $" {item.TaxCategory}";
+ }
+ }
+ }
+
+ private static void AddOrderItemsComputedProps(JsonNode node, OrderItemsReceipt receipt)
+ {
+ var itemsArray = node["Items"]?.AsArray();
+ if (itemsArray != null)
+ {
+ for (int i = 0; i < receipt.Items.Count; i++)
+ {
+ var item = receipt.Items[i];
+ itemsArray[i]!["SizePart"] = !string.IsNullOrEmpty(item.Size)
+ ? item.Size + " "
+ : "";
+
+ var mods = item.Modifications;
+ bool hasMods = mods != null && (mods.Removed.Count > 0 || mods.Added.Count > 0);
+ if (itemsArray[i]!["Modifications"] == null)
+ itemsArray[i]!["Modifications"] = new JsonObject();
+ itemsArray[i]!["Modifications"]!["HasModifications"] = hasMods;
+ }
+ }
+ }
+
+ #endregion
+}
diff --git a/EpsonTest.Templates/ristorante-klinglers.ch-logo.png b/EpsonTest.Templates/ristorante-klinglers.ch-logo.png
new file mode 100644
index 0000000..3ff91e9
Binary files /dev/null and b/EpsonTest.Templates/ristorante-klinglers.ch-logo.png differ
diff --git a/EpsonTest.Templates/ristorante-klinglers.ch-logo_white_bg.png b/EpsonTest.Templates/ristorante-klinglers.ch-logo_white_bg.png
new file mode 100644
index 0000000..604d6c4
Binary files /dev/null and b/EpsonTest.Templates/ristorante-klinglers.ch-logo_white_bg.png differ
diff --git a/Inspectron.Epson.TemplateEngine.Tests/ExpressionEvaluatorTests.cs b/Inspectron.Epson.TemplateEngine.Tests/ExpressionEvaluatorTests.cs
new file mode 100644
index 0000000..9024595
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine.Tests/ExpressionEvaluatorTests.cs
@@ -0,0 +1,290 @@
+using System.Text.Json;
+using Inspectron.Epson.TemplateEngine.DataBinding;
+
+namespace Inspectron.Epson.TemplateEngine.Tests;
+
+public class ExpressionEvaluatorTests
+{
+ private static DataContext CreateContext(string json)
+ {
+ var root = JsonDocument.Parse(json).RootElement;
+ return new DataContext(root);
+ }
+
+ [Fact]
+ public void Evaluate_SimpleProperty()
+ {
+ var ctx = CreateContext("""{"Name":"John"}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.Equal("Hello John", eval.Evaluate("Hello {{Name}}"));
+ }
+
+ [Fact]
+ public void Evaluate_NestedProperty()
+ {
+ var ctx = CreateContext("""{"Person":{"Name":"Alice"}}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.Equal("Hi Alice", eval.Evaluate("Hi {{Person.Name}}"));
+ }
+
+ [Fact]
+ public void Evaluate_MissingProperty_ReturnsEmpty()
+ {
+ var ctx = CreateContext("""{"Name":"John"}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.Equal("Hi ", eval.Evaluate("Hi {{Missing}}"));
+ }
+
+ [Fact]
+ public void Evaluate_NumberProperty()
+ {
+ var ctx = CreateContext("""{"Count":42}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.Equal("Count: 42", eval.Evaluate("Count: {{Count}}"));
+ }
+
+ [Fact]
+ public void Evaluate_FormatString_Decimal()
+ {
+ var ctx = CreateContext("""{"Price":9.5}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.Equal("9.50", eval.Evaluate("{{Price:F2}}"));
+ }
+
+ [Fact]
+ public void Evaluate_FormatString_DateTime()
+ {
+ var ctx = CreateContext("""{"Date":"2024-03-15T14:30:00Z"}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.Equal("15-Mar-24 14:30", eval.Evaluate("{{Date:dd-MMM-yy HH:mm}}"));
+ }
+
+ [Fact]
+ public void Evaluate_MultipleExpressions()
+ {
+ var ctx = CreateContext("""{"First":"John","Last":"Doe"}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.Equal("John Doe", eval.Evaluate("{{First}} {{Last}}"));
+ }
+
+ [Fact]
+ public void Evaluate_CaseInsensitivePropertyLookup()
+ {
+ var ctx = CreateContext("""{"name":"Alice"}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.Equal("Alice", eval.Evaluate("{{Name}}"));
+ }
+
+ [Fact]
+ public void Evaluate_EmptyTemplate()
+ {
+ var ctx = CreateContext("""{}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.Equal("", eval.Evaluate(""));
+ }
+
+ [Fact]
+ public void Evaluate_NoExpressions()
+ {
+ var ctx = CreateContext("""{}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.Equal("plain text", eval.Evaluate("plain text"));
+ }
+
+ [Fact]
+ public void Evaluate_LoopVariable_Index()
+ {
+ var ctx = CreateContext("""{}""");
+ ctx.SetLoopVariable("$index", 3);
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.Equal("Index: 3", eval.Evaluate("Index: {{$index}}"));
+ }
+
+ [Fact]
+ public void EvaluateCondition_TruthyString()
+ {
+ var ctx = CreateContext("""{"Name":"John"}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.True(eval.EvaluateCondition("Name"));
+ }
+
+ [Fact]
+ public void EvaluateCondition_EmptyString_IsFalsy()
+ {
+ var ctx = CreateContext("""{"Name":""}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.False(eval.EvaluateCondition("Name"));
+ }
+
+ [Fact]
+ public void EvaluateCondition_MissingProperty_IsFalsy()
+ {
+ var ctx = CreateContext("""{}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.False(eval.EvaluateCondition("Name"));
+ }
+
+ [Fact]
+ public void EvaluateCondition_Negation()
+ {
+ var ctx = CreateContext("""{"Name":"John"}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.False(eval.EvaluateCondition("!Name"));
+ }
+
+ [Fact]
+ public void EvaluateCondition_Negation_Missing()
+ {
+ var ctx = CreateContext("""{}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.True(eval.EvaluateCondition("!Name"));
+ }
+
+ [Fact]
+ public void EvaluateCondition_NullValue_IsFalsy()
+ {
+ var ctx = CreateContext("""{"Name":null}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.False(eval.EvaluateCondition("Name"));
+ }
+
+ [Fact]
+ public void EvaluateCondition_Comparison_Equal()
+ {
+ var ctx = CreateContext("""{"Count":5}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.True(eval.EvaluateCondition("Count==5"));
+ }
+
+ [Fact]
+ public void EvaluateCondition_Comparison_NotEqual()
+ {
+ var ctx = CreateContext("""{"Count":5}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.True(eval.EvaluateCondition("Count!=3"));
+ }
+
+ [Fact]
+ public void EvaluateCondition_Comparison_GreaterThan()
+ {
+ var ctx = CreateContext("""{"Count":5}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.True(eval.EvaluateCondition("Count>3"));
+ }
+
+ [Fact]
+ public void EvaluateCondition_Comparison_StringEqual()
+ {
+ var ctx = CreateContext("""{"Status":"active"}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.True(eval.EvaluateCondition("Status=='active'"));
+ }
+
+ [Fact]
+ public void EvaluateCondition_LoopVariable_First()
+ {
+ var ctx = CreateContext("""{}""");
+ ctx.SetLoopVariable("$first", true);
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.True(eval.EvaluateCondition("$first"));
+ }
+
+ [Fact]
+ public void EvaluateCondition_LoopVariable_NotLast()
+ {
+ var ctx = CreateContext("""{}""");
+ ctx.SetLoopVariable("$last", false);
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.True(eval.EvaluateCondition("!$last"));
+ }
+
+ [Fact]
+ public void EvaluateCondition_NonEmptyArray_IsTruthy()
+ {
+ var ctx = CreateContext("""{"Items":[1,2,3]}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.True(eval.EvaluateCondition("Items"));
+ }
+
+ [Fact]
+ public void EvaluateCondition_EmptyArray_IsFalsy()
+ {
+ var ctx = CreateContext("""{"Items":[]}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.False(eval.EvaluateCondition("Items"));
+ }
+
+ [Fact]
+ public void EvaluateCondition_BooleanTrue_IsTruthy()
+ {
+ var ctx = CreateContext("""{"Active":true}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.True(eval.EvaluateCondition("Active"));
+ }
+
+ [Fact]
+ public void EvaluateCondition_BooleanFalse_IsFalsy()
+ {
+ var ctx = CreateContext("""{"Active":false}""");
+ var eval = new ExpressionEvaluator(ctx);
+
+ Assert.False(eval.EvaluateCondition("Active"));
+ }
+
+ [Fact]
+ public void DataContext_ChildScope_InheritsParentVariables()
+ {
+ var ctx = CreateContext("""{}""");
+ var item = JsonDocument.Parse("""{"Name":"Pizza"}""").RootElement;
+ ctx.SetVariable("item", item);
+
+ var child = ctx.CreateChildScope();
+ var childEval = new ExpressionEvaluator(child);
+
+ Assert.Equal("Pizza", childEval.Evaluate("{{item.Name}}"));
+ }
+
+ [Fact]
+ public void DataContext_ChildScope_OverridesParentVariable()
+ {
+ var ctx = CreateContext("""{}""");
+ var item1 = JsonDocument.Parse("""{"Name":"Pizza"}""").RootElement;
+ ctx.SetVariable("item", item1);
+
+ var child = ctx.CreateChildScope();
+ var item2 = JsonDocument.Parse("""{"Name":"Pasta"}""").RootElement;
+ child.SetVariable("item", item2);
+
+ var childEval = new ExpressionEvaluator(child);
+ Assert.Equal("Pasta", childEval.Evaluate("{{item.Name}}"));
+
+ var parentEval = new ExpressionEvaluator(ctx);
+ Assert.Equal("Pizza", parentEval.Evaluate("{{item.Name}}"));
+ }
+}
diff --git a/Inspectron.Epson.TemplateEngine.Tests/GlobalUsings.cs b/Inspectron.Epson.TemplateEngine.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..c802f44
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/Inspectron.Epson.TemplateEngine.Tests/Inspectron.Epson.TemplateEngine.Tests.csproj b/Inspectron.Epson.TemplateEngine.Tests/Inspectron.Epson.TemplateEngine.Tests.csproj
new file mode 100644
index 0000000..6737919
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine.Tests/Inspectron.Epson.TemplateEngine.Tests.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+ Major
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/Inspectron.Epson.TemplateEngine.Tests/KitchenReceiptTemplateTests.cs b/Inspectron.Epson.TemplateEngine.Tests/KitchenReceiptTemplateTests.cs
new file mode 100644
index 0000000..0aa1339
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine.Tests/KitchenReceiptTemplateTests.cs
@@ -0,0 +1,298 @@
+using Inspectron.Epson.PrintServer.Printers.Utils;
+using Inspectron.Epson.PrintServer.Printers.Utils.KitchenReceipt;
+using System.Text.Json;
+
+namespace Inspectron.Epson.TemplateEngine.Tests;
+
+public class KitchenReceiptTemplateTests
+{
+ private const int LineWidth = 42;
+ private const int BigLineWidth = 22;
+
+ private readonly ReceiptTemplateEngine _engine = new();
+
+ private static string GetTemplatePath()
+ {
+ return Path.Combine(AppContext.BaseDirectory, "Templates", "KitchenReceipt.xml");
+ }
+
+ [Fact]
+ public void RenderKitchenReceipt_BasicStructure()
+ {
+ var template = File.ReadAllText(GetTemplatePath());
+
+ var json = """
+ {
+ "Title": "Test Restaurant",
+ "TransactionDateTime": "2024-03-15T14:30:00Z",
+ "ReceiptNumber": "42",
+ "WaiterName": "Max",
+ "TableNumber": "5",
+ "SpecialInstruction": null,
+ "HasGangs": false,
+ "HasDishes": false,
+ "Gangs": [],
+ "Dishes": []
+ }
+ """;
+
+ var commands = _engine.Render(template, json, LineWidth, BigLineWidth);
+
+ Assert.True(commands.Count > 0);
+
+ // First line: title centered in big font, red
+ Assert.True(commands[0].IsBig);
+ Assert.True(commands[0].IsTall);
+ Assert.True(commands[0].IsRed);
+ Assert.Contains("Test Restaurant", commands[0].Text);
+
+ // Second line: separator
+ Assert.Equal(new string('-', LineWidth), commands[1].Text);
+
+ // Empty line
+ Assert.Equal("", commands[2].Text);
+ }
+
+ [Fact]
+ public void RenderKitchenReceipt_WithSpecialInstruction()
+ {
+ var template = File.ReadAllText(GetTemplatePath());
+
+ var json = """
+ {
+ "Title": "Test Restaurant",
+ "TransactionDateTime": "2024-03-15T14:30:00Z",
+ "ReceiptNumber": "42",
+ "WaiterName": "Max",
+ "TableNumber": "5",
+ "SpecialInstruction": "RUSH ORDER",
+ "HasGangs": false,
+ "HasDishes": false,
+ "Gangs": [],
+ "Dishes": []
+ }
+ """;
+
+ var commands = _engine.Render(template, json, LineWidth, BigLineWidth);
+
+ // Should contain the special instruction
+ var specialCommands = commands.Where(c => c.Text.Contains("RUSH ORDER")).ToList();
+ Assert.NotEmpty(specialCommands);
+ Assert.True(specialCommands[0].IsBig);
+ Assert.True(specialCommands[0].IsBold);
+ }
+
+ [Fact]
+ public void RenderKitchenReceipt_WithGangs()
+ {
+ var template = File.ReadAllText(GetTemplatePath());
+
+ var json = """
+ {
+ "Title": "Test Restaurant",
+ "TransactionDateTime": "2024-03-15T14:30:00Z",
+ "ReceiptNumber": "42",
+ "WaiterName": "Max",
+ "TableNumber": "5",
+ "SpecialInstruction": null,
+ "HasGangs": true,
+ "HasDishes": false,
+ "Gangs": [
+ {
+ "Id": 1,
+ "Name": "Vorspeise",
+ "Dishes": [
+ {
+ "Number": 2,
+ "Name": "Caesar Salad",
+ "GuestPrefix": "",
+ "GuestId": null,
+ "Modifications": { "Removed": [], "Added": [], "HasModifications": false },
+ "Comment": null
+ }
+ ]
+ },
+ {
+ "Id": 2,
+ "Name": "Hauptgang",
+ "Dishes": [
+ {
+ "Number": 1,
+ "Name": "Wiener Schnitzel",
+ "GuestPrefix": "",
+ "GuestId": null,
+ "Modifications": { "Removed": ["Pommes"], "Added": ["Reis"], "HasModifications": true },
+ "Comment": "Well done"
+ }
+ ]
+ }
+ ],
+ "Dishes": []
+ }
+ """;
+
+ var commands = _engine.Render(template, json, LineWidth, BigLineWidth);
+
+ // Should contain gang names
+ Assert.Contains(commands, c => c.Text.Contains("1. Vorspeise"));
+ Assert.Contains(commands, c => c.Text.Contains("2. Hauptgang"));
+
+ // Should contain dish names
+ Assert.Contains(commands, c => c.Text.Contains("2x Caesar Salad"));
+ Assert.Contains(commands, c => c.Text.Contains("1x Wiener Schnitzel"));
+
+ // Should contain modifications
+ Assert.Contains(commands, c => c.Text.Contains("- Pommes") && c.IsBold);
+ Assert.Contains(commands, c => c.Text.Contains("+ Reis") && c.IsBold);
+
+ // Should contain comment
+ Assert.Contains(commands, c => c.Text.Contains("Comment: Well done") && c.IsBold);
+
+ // Should have cut between gangs (not after last)
+ var cutCommands = commands.Where(c => c.IsCut).ToList();
+ Assert.Single(cutCommands);
+
+ // Gang headers should be red and big
+ var gangHeaders = commands.Where(c => c.Text.Contains("Vorspeise") || c.Text.Contains("Hauptgang")).ToList();
+ Assert.All(gangHeaders, c =>
+ {
+ Assert.True(c.IsRed);
+ Assert.True(c.IsBig);
+ Assert.True(c.IsTall);
+ });
+ }
+
+ [Fact]
+ public void RenderKitchenReceipt_DishesAreTall()
+ {
+ var template = File.ReadAllText(GetTemplatePath());
+
+ var json = """
+ {
+ "Title": "Test",
+ "TransactionDateTime": "2024-01-01T00:00:00Z",
+ "ReceiptNumber": "1",
+ "WaiterName": "W",
+ "TableNumber": "1",
+ "SpecialInstruction": null,
+ "HasGangs": true,
+ "HasDishes": false,
+ "Gangs": [
+ {
+ "Id": 1,
+ "Name": "Gang1",
+ "Dishes": [
+ {
+ "Number": 1,
+ "Name": "Item",
+ "GuestPrefix": "",
+ "GuestId": null,
+ "Modifications": { "Removed": [], "Added": [], "HasModifications": false },
+ "Comment": null
+ }
+ ]
+ }
+ ],
+ "Dishes": []
+ }
+ """;
+
+ var commands = _engine.Render(template, json, LineWidth, BigLineWidth);
+
+ // Dish line should be tall
+ var dishCmd = commands.First(c => c.Text.Contains("1x Item"));
+ Assert.True(dishCmd.IsTall);
+ }
+
+ [Fact]
+ public void RenderKitchenReceipt_TableLineCenteredBigBold()
+ {
+ var template = File.ReadAllText(GetTemplatePath());
+
+ var json = """
+ {
+ "Title": "Test",
+ "TransactionDateTime": "2024-01-01T00:00:00Z",
+ "ReceiptNumber": "1",
+ "WaiterName": "W",
+ "TableNumber": "12",
+ "SpecialInstruction": null,
+ "HasGangs": false,
+ "HasDishes": false,
+ "Gangs": [],
+ "Dishes": []
+ }
+ """;
+
+ var commands = _engine.Render(template, json, LineWidth, BigLineWidth);
+
+ var tableCmd = commands.First(c => c.Text.Contains("Tisch: 12"));
+ Assert.True(tableCmd.IsBig);
+ Assert.True(tableCmd.IsBold);
+ // Should be centered in big font width
+ Assert.True(tableCmd.Text.StartsWith(" "));
+ }
+
+ [Fact]
+ public void CompareWithExistingConverter_BasicReceipt()
+ {
+ // Create a KitchenReceipt via the existing converter
+ var receipt = new KitchenReceipt
+ {
+ Title = "Test Restaurant",
+ TransactionDateTime = new DateTime(2024, 3, 15, 14, 30, 0),
+ ReceiptNumber = "42",
+ WaiterName = "Max Mustermann",
+ WaiterId = "W001",
+ TableNumber = "5",
+ SpecialInstruction = null,
+ Gangs = new List(),
+ Dishes = new List()
+ };
+
+ var existingConverter = new KitchenReceiptConverter(LineWidth, BigLineWidth);
+ var existingCommands = existingConverter.ConvertToPrintCommands(receipt);
+
+ // Render with template engine
+ var template = File.ReadAllText(GetTemplatePath());
+ var jsonData = JsonSerializer.Serialize(new
+ {
+ receipt.Title,
+ TransactionDateTime = receipt.TransactionDateTime.ToString("o"),
+ receipt.ReceiptNumber,
+ receipt.WaiterName,
+ receipt.TableNumber,
+ receipt.SpecialInstruction,
+ HasGangs = receipt.Gangs.Count > 0,
+ HasDishes = receipt.Dishes != null && receipt.Dishes.Any(),
+ receipt.Gangs,
+ receipt.Dishes
+ });
+
+ var templateCommands = _engine.Render(template, jsonData, LineWidth, BigLineWidth);
+
+ // Compare key structural elements
+ // Title should match
+ Assert.Equal(existingCommands[0].Text, templateCommands[0].Text);
+ Assert.Equal(existingCommands[0].IsRed, templateCommands[0].IsRed);
+ Assert.Equal(existingCommands[0].IsBig, templateCommands[0].IsBig);
+ Assert.Equal(existingCommands[0].IsTall, templateCommands[0].IsTall);
+
+ // Separator should match
+ Assert.Equal(existingCommands[1].Text, templateCommands[1].Text);
+
+ // Empty line
+ Assert.Equal(existingCommands[2].Text, templateCommands[2].Text);
+
+ // Date/receipt number line should match formatting
+ Assert.Equal(existingCommands[3].Text, templateCommands[3].Text);
+
+ // Waiter name
+ Assert.Equal(existingCommands[4].Text, templateCommands[4].Text);
+
+ // Table number
+ Assert.Equal(existingCommands[5].Text, templateCommands[5].Text);
+ Assert.Equal(existingCommands[5].IsBig, templateCommands[5].IsBig);
+ Assert.Equal(existingCommands[5].IsBold, templateCommands[5].IsBold);
+ }
+}
diff --git a/Inspectron.Epson.TemplateEngine.Tests/LayoutEngineTests.cs b/Inspectron.Epson.TemplateEngine.Tests/LayoutEngineTests.cs
new file mode 100644
index 0000000..5636a36
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine.Tests/LayoutEngineTests.cs
@@ -0,0 +1,154 @@
+using Inspectron.Epson.TemplateEngine.Rendering;
+
+namespace Inspectron.Epson.TemplateEngine.Tests;
+
+public class LayoutEngineTests
+{
+ private readonly LayoutEngine _engine = new();
+
+ [Fact]
+ public void AlignText_Center()
+ {
+ var result = _engine.AlignText("Hello", "center", 20);
+ Assert.Equal(" Hello", result);
+ }
+
+ [Fact]
+ public void AlignText_Right()
+ {
+ var result = _engine.AlignText("Hello", "right", 20);
+ Assert.Equal(" Hello", result);
+ }
+
+ [Fact]
+ public void AlignText_Left()
+ {
+ var result = _engine.AlignText("Hello", "left", 20);
+ Assert.Equal("Hello", result);
+ }
+
+ [Fact]
+ public void AlignText_TextExceedsWidth_NoChange()
+ {
+ var result = _engine.AlignText("Very long text", "center", 5);
+ Assert.Equal("Very long text", result);
+ }
+
+ [Fact]
+ public void FormatTwoColumns_BasicLayout()
+ {
+ var result = _engine.FormatTwoColumns("Left", "Right", 20);
+ Assert.Equal(20, result.Length);
+ Assert.StartsWith("Left", result);
+ Assert.EndsWith("Right", result);
+ }
+
+ [Fact]
+ public void FormatTwoColumnsWithWrap_ShortText_SingleLine()
+ {
+ var result = _engine.FormatTwoColumnsWithWrap("Left", "Right", 20);
+ Assert.Single(result);
+ Assert.Contains("Left", result[0]);
+ Assert.Contains("Right", result[0]);
+ }
+
+ [Fact]
+ public void FormatTwoColumnsWithWrap_LongLeft_MultipleLines()
+ {
+ var result = _engine.FormatTwoColumnsWithWrap(
+ "This is a very long left column text that needs wrapping",
+ "9.50",
+ 30);
+ Assert.True(result.Count >= 1);
+ Assert.Contains("9.50", result[0]);
+ }
+
+ [Fact]
+ public void WrapText_ShortText_SingleLine()
+ {
+ var result = _engine.WrapText("Hello world", 20);
+ Assert.Single(result);
+ Assert.Equal("Hello world", result[0]);
+ }
+
+ [Fact]
+ public void WrapText_LongText_MultipleLines()
+ {
+ var result = _engine.WrapText("The quick brown fox jumps over the lazy dog", 20);
+ Assert.True(result.Count > 1);
+ foreach (var line in result)
+ {
+ Assert.True(line.TrimStart().Length <= 20);
+ }
+ }
+
+ [Fact]
+ public void WrapText_WithIndent()
+ {
+ var result = _engine.WrapText("The quick brown fox jumps over the lazy dog", 20, indent: 4);
+ Assert.True(result.Count > 1);
+ // First line no indent
+ Assert.False(result[0].StartsWith(" "));
+ // Subsequent lines indented
+ for (int i = 1; i < result.Count; i++)
+ {
+ Assert.StartsWith(" ", result[i]);
+ }
+ }
+
+ [Fact]
+ public void CreateSeparator()
+ {
+ var result = _engine.CreateSeparator('-', 42);
+ Assert.Equal(new string('-', 42), result);
+ }
+
+ [Fact]
+ public void CreateSeparator_CustomChar()
+ {
+ var result = _engine.CreateSeparator('*', 20);
+ Assert.Equal(new string('*', 20), result);
+ }
+
+ [Fact]
+ public void FormatMultiColumn_BasicLayout()
+ {
+ var columns = new List<(string text, int width, string align)>
+ {
+ ("Col1", 10, "left"),
+ ("Col2", 10, "right"),
+ ("Col3", 10, "center")
+ };
+
+ var result = _engine.FormatMultiColumn(columns, 30);
+ Assert.Equal(30, result.Length);
+ }
+
+ [Fact]
+ public void FormatTable_BasicTable()
+ {
+ var columns = new List<(string text, int width, string align)>
+ {
+ ("Name", 10, "left"),
+ ("Value", 10, "right")
+ };
+
+ var headers = new List> { new() { "Name", "Value" } };
+ var data = new List>
+ {
+ new() { "Item1", "100" },
+ new() { "Item2", "200" }
+ };
+
+ var result = _engine.FormatTable(columns, headers, data, 23);
+
+ // Should have: top border, header row, separator, 2 data rows, bottom border
+ Assert.Equal(6, result.Count);
+ Assert.StartsWith("+", result[0]);
+ Assert.StartsWith("|", result[1]);
+ Assert.StartsWith("+", result[2]);
+ Assert.StartsWith("|", result[3]);
+ Assert.StartsWith("|", result[4]);
+ Assert.StartsWith("+", result[5]);
+ }
+}
diff --git a/Inspectron.Epson.TemplateEngine.Tests/TemplateParserTests.cs b/Inspectron.Epson.TemplateEngine.Tests/TemplateParserTests.cs
new file mode 100644
index 0000000..892038b
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine.Tests/TemplateParserTests.cs
@@ -0,0 +1,294 @@
+using Inspectron.Epson.TemplateEngine.Parsing;
+
+namespace Inspectron.Epson.TemplateEngine.Tests;
+
+public class TemplateParserTests
+{
+ private readonly TemplateParser _parser = new();
+
+ [Fact]
+ public void Parse_EmptyReceipt()
+ {
+ var result = _parser.Parse("");
+ Assert.NotNull(result);
+ Assert.Empty(result.Children);
+ }
+
+ [Fact]
+ public void Parse_InvalidXml_Throws()
+ {
+ Assert.Throws(() => _parser.Parse("not xml"));
+ }
+
+ [Fact]
+ public void Parse_WrongRoot_Throws()
+ {
+ Assert.Throws(() => _parser.Parse(""));
+ }
+
+ [Fact]
+ public void Parse_UnknownElement_Throws()
+ {
+ Assert.Throws(() =>
+ _parser.Parse(""));
+ }
+
+ [Fact]
+ public void Parse_Line_BasicText()
+ {
+ var result = _parser.Parse("Hello");
+ Assert.Single(result.Children);
+ var line = Assert.IsType(result.Children[0]);
+ Assert.Equal("Hello", line.Text);
+ }
+
+ [Fact]
+ public void Parse_Line_WithFormatting()
+ {
+ var result = _parser.Parse(
+ """Text""");
+ var line = Assert.IsType(result.Children[0]);
+ Assert.True(line.Bold);
+ Assert.True(line.Big);
+ Assert.True(line.Tall);
+ Assert.True(line.Red);
+ Assert.Equal("center", line.Align);
+ }
+
+ [Fact]
+ public void Parse_Line_WithWrap()
+ {
+ var result = _parser.Parse(
+ """Long text""");
+ var line = Assert.IsType(result.Children[0]);
+ Assert.True(line.Wrap);
+ Assert.Equal(4, line.WrapIndent);
+ }
+
+ [Fact]
+ public void Parse_Line_WithLineSpacing()
+ {
+ var result = _parser.Parse(
+ """Text""");
+ var line = Assert.IsType(result.Children[0]);
+ Assert.Equal(50, line.LineSpacing);
+ }
+
+ [Fact]
+ public void Parse_EmptyLine()
+ {
+ var result = _parser.Parse("");
+ var line = Assert.IsType(result.Children[0]);
+ Assert.Equal("", line.Text);
+ }
+
+ [Fact]
+ public void Parse_Columns()
+ {
+ var result = _parser.Parse(
+ """""");
+ var col = Assert.IsType(result.Children[0]);
+ Assert.Equal("Name", col.Left);
+ Assert.Equal("Price", col.Right);
+ Assert.True(col.Bold);
+ }
+
+ [Fact]
+ public void Parse_Row_WithColumns()
+ {
+ var result = _parser.Parse("""
+
+
+ Name
+ Value
+
+
+ """);
+
+ var row = Assert.IsType(result.Children[0]);
+ Assert.True(row.Bold);
+ Assert.Equal(2, row.Columns.Count);
+ Assert.Equal("Name", row.Columns[0].Text);
+ Assert.Equal(10, row.Columns[0].Width);
+ Assert.Equal("left", row.Columns[0].Align);
+ Assert.Equal("Value", row.Columns[1].Text);
+ Assert.Equal(10, row.Columns[1].Width);
+ Assert.Equal("right", row.Columns[1].Align);
+ }
+
+ [Fact]
+ public void Parse_Separator()
+ {
+ var result = _parser.Parse("");
+ var sep = Assert.IsType(result.Children[0]);
+ Assert.Equal('-', sep.Character);
+ }
+
+ [Fact]
+ public void Parse_Separator_CustomChar()
+ {
+ var result = _parser.Parse("""""");
+ var sep = Assert.IsType(result.Children[0]);
+ Assert.Equal('*', sep.Character);
+ }
+
+ [Fact]
+ public void Parse_Cut()
+ {
+ var result = _parser.Parse("");
+ Assert.IsType(result.Children[0]);
+ }
+
+ [Fact]
+ public void Parse_Feed()
+ {
+ var result = _parser.Parse("""""");
+ var feed = Assert.IsType(result.Children[0]);
+ Assert.Equal(3, feed.Lines);
+ }
+
+ [Fact]
+ public void Parse_Feed_Default()
+ {
+ var result = _parser.Parse("");
+ var feed = Assert.IsType(result.Children[0]);
+ Assert.Equal(1, feed.Lines);
+ }
+
+ [Fact]
+ public void Parse_Foreach()
+ {
+ var result = _parser.Parse("""
+
+
+ {{item.Name}}
+
+
+ """);
+
+ var fe = Assert.IsType(result.Children[0]);
+ Assert.Equal("Items", fe.Items);
+ Assert.Equal("item", fe.Var);
+ Assert.Single(fe.Children);
+ Assert.IsType(fe.Children[0]);
+ }
+
+ [Fact]
+ public void Parse_Foreach_MissingItems_Throws()
+ {
+ Assert.Throws(() =>
+ _parser.Parse(""""""));
+ }
+
+ [Fact]
+ public void Parse_Foreach_MissingVar_Throws()
+ {
+ Assert.Throws(() =>
+ _parser.Parse(""""""));
+ }
+
+ [Fact]
+ public void Parse_If()
+ {
+ var result = _parser.Parse("""
+
+
+ Has name
+
+
+ """);
+
+ var ifNode = Assert.IsType(result.Children[0]);
+ Assert.Equal("Name", ifNode.Test);
+ Assert.Single(ifNode.Children);
+ }
+
+ [Fact]
+ public void Parse_If_MissingTest_Throws()
+ {
+ Assert.Throws(() =>
+ _parser.Parse(""));
+ }
+
+ [Fact]
+ public void Parse_IfElse()
+ {
+ var result = _parser.Parse("""
+
+
+ Yes
+
+
+ No
+
+
+ """);
+
+ Assert.Equal(2, result.Children.Count);
+ Assert.IsType(result.Children[0]);
+ Assert.IsType(result.Children[1]);
+ }
+
+ [Fact]
+ public void Parse_NestedForeach()
+ {
+ var result = _parser.Parse("""
+
+
+
+ {{dish.Name}}
+
+
+
+ """);
+
+ var outer = Assert.IsType(result.Children[0]);
+ Assert.Equal("Gangs", outer.Items);
+ var inner = Assert.IsType(outer.Children[0]);
+ Assert.Equal("gang.Dishes", inner.Items);
+ }
+
+ [Fact]
+ public void Parse_Table()
+ {
+ var result = _parser.Parse("""
+
+
+
+ """);
+
+ var table = Assert.IsType(result.Children[0]);
+ Assert.Equal("Data", table.Items);
+ Assert.Equal("row", table.Var);
+ Assert.Equal(2, table.Columns.Count);
+ }
+
+ [Fact]
+ public void Parse_ComplexTemplate()
+ {
+ var result = _parser.Parse("""
+
+ Title
+
+
+
+ {{item.Name}}
+
+ DISCOUNT
+
+
+
+
+ """);
+
+ Assert.Equal(5, result.Children.Count);
+ Assert.IsType(result.Children[0]);
+ Assert.IsType(result.Children[1]);
+ Assert.IsType(result.Children[2]);
+ Assert.IsType(result.Children[3]);
+ Assert.IsType(result.Children[4]);
+ }
+}
diff --git a/Inspectron.Epson.TemplateEngine.Tests/TemplateRendererTests.cs b/Inspectron.Epson.TemplateEngine.Tests/TemplateRendererTests.cs
new file mode 100644
index 0000000..9315573
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine.Tests/TemplateRendererTests.cs
@@ -0,0 +1,445 @@
+namespace Inspectron.Epson.TemplateEngine.Tests;
+
+public class TemplateRendererTests
+{
+ private readonly ReceiptTemplateEngine _engine = new();
+
+ [Fact]
+ public void Render_EmptyReceipt()
+ {
+ var commands = _engine.Render("", "{}", 42, 22);
+ Assert.Empty(commands);
+ }
+
+ [Fact]
+ public void Render_SimpleLine()
+ {
+ var commands = _engine.Render(
+ "Hello World",
+ "{}", 42, 22);
+
+ Assert.Single(commands);
+ Assert.Equal("Hello World", commands[0].Text);
+ }
+
+ [Fact]
+ public void Render_LineWithDataBinding()
+ {
+ var commands = _engine.Render(
+ "Hello {{Name}}",
+ """{"Name":"John"}""", 42, 22);
+
+ Assert.Single(commands);
+ Assert.Equal("Hello John", commands[0].Text);
+ }
+
+ [Fact]
+ public void Render_LineWithFormatting()
+ {
+ var commands = _engine.Render(
+ """Text""",
+ "{}", 42, 22);
+
+ var cmd = commands[0];
+ Assert.True(cmd.IsBold);
+ Assert.True(cmd.IsBig);
+ Assert.True(cmd.IsTall);
+ Assert.True(cmd.IsRed);
+ }
+
+ [Fact]
+ public void Render_LineWithCenterAlignment()
+ {
+ var commands = _engine.Render(
+ """Test""",
+ "{}", 42, 22);
+
+ Assert.Equal(" Test", commands[0].Text);
+ }
+
+ [Fact]
+ public void Render_LineWithCenterAlignment_BigFont()
+ {
+ var commands = _engine.Render(
+ """Test""",
+ "{}", 42, 22);
+
+ Assert.Equal(" Test", commands[0].Text);
+ }
+
+ [Fact]
+ public void Render_EmptyLine()
+ {
+ var commands = _engine.Render(
+ "",
+ "{}", 42, 22);
+
+ Assert.Single(commands);
+ Assert.Equal("", commands[0].Text);
+ }
+
+ [Fact]
+ public void Render_Separator()
+ {
+ var commands = _engine.Render(
+ "",
+ "{}", 42, 22);
+
+ Assert.Single(commands);
+ Assert.Equal(new string('-', 42), commands[0].Text);
+ }
+
+ [Fact]
+ public void Render_Cut()
+ {
+ var commands = _engine.Render(
+ "",
+ "{}", 42, 22);
+
+ Assert.Single(commands);
+ Assert.True(commands[0].IsCut);
+ }
+
+ [Fact]
+ public void Render_Feed()
+ {
+ var commands = _engine.Render(
+ """""",
+ "{}", 42, 22);
+
+ Assert.Equal(3, commands.Count);
+ Assert.All(commands, c => Assert.Equal("", c.Text));
+ }
+
+ [Fact]
+ public void Render_Columns()
+ {
+ var commands = _engine.Render(
+ """""",
+ "{}", 42, 22);
+
+ Assert.Single(commands);
+ Assert.Equal(42, commands[0].Text.Length);
+ Assert.StartsWith("Name", commands[0].Text);
+ Assert.EndsWith("Price", commands[0].Text);
+ }
+
+ [Fact]
+ public void Render_ColumnsWithDataBinding()
+ {
+ var commands = _engine.Render(
+ """""",
+ """{"Label":"Total","Value":"100.00"}""", 42, 22);
+
+ Assert.Single(commands);
+ Assert.Contains("Total", commands[0].Text);
+ Assert.Contains("100.00", commands[0].Text);
+ }
+
+ [Fact]
+ public void Render_Foreach()
+ {
+ var commands = _engine.Render("""
+
+
+ {{item.Name}}
+
+
+ """,
+ """{"Items":[{"Name":"Pizza"},{"Name":"Pasta"},{"Name":"Salad"}]}""",
+ 42, 22);
+
+ Assert.Equal(3, commands.Count);
+ Assert.Equal("Pizza", commands[0].Text);
+ Assert.Equal("Pasta", commands[1].Text);
+ Assert.Equal("Salad", commands[2].Text);
+ }
+
+ [Fact]
+ public void Render_Foreach_EmptyArray()
+ {
+ var commands = _engine.Render("""
+
+
+ {{item.Name}}
+
+
+ """,
+ """{"Items":[]}""", 42, 22);
+
+ Assert.Empty(commands);
+ }
+
+ [Fact]
+ public void Render_Foreach_WithLoopVariables()
+ {
+ var commands = _engine.Render("""
+
+
+ {{$index}}: {{item.Name}}
+
+
+ """,
+ """{"Items":[{"Name":"A"},{"Name":"B"},{"Name":"C"}]}""",
+ 42, 22);
+
+ Assert.Equal(3, commands.Count);
+ Assert.Equal("0: A", commands[0].Text);
+ Assert.Equal("1: B", commands[1].Text);
+ Assert.Equal("2: C", commands[2].Text);
+ }
+
+ [Fact]
+ public void Render_NestedForeach()
+ {
+ var commands = _engine.Render("""
+
+
+ {{group.Name}}
+
+ {{item.Name}}
+
+
+
+ """,
+ """{"Groups":[{"Name":"G1","Items":[{"Name":"A"},{"Name":"B"}]},{"Name":"G2","Items":[{"Name":"C"}]}]}""",
+ 42, 22);
+
+ Assert.Equal(5, commands.Count);
+ Assert.Equal("G1", commands[0].Text);
+ Assert.Equal(" A", commands[1].Text);
+ Assert.Equal(" B", commands[2].Text);
+ Assert.Equal("G2", commands[3].Text);
+ Assert.Equal(" C", commands[4].Text);
+ }
+
+ [Fact]
+ public void Render_If_True()
+ {
+ var commands = _engine.Render("""
+
+
+ Has name: {{Name}}
+
+
+ """,
+ """{"Name":"John"}""", 42, 22);
+
+ Assert.Single(commands);
+ Assert.Equal("Has name: John", commands[0].Text);
+ }
+
+ [Fact]
+ public void Render_If_False()
+ {
+ var commands = _engine.Render("""
+
+
+ Has name
+
+
+ """,
+ """{}""", 42, 22);
+
+ Assert.Empty(commands);
+ }
+
+ [Fact]
+ public void Render_IfElse_True()
+ {
+ var commands = _engine.Render("""
+
+
+ Yes
+
+
+ No
+
+
+ """,
+ """{"Name":"John"}""", 42, 22);
+
+ Assert.Single(commands);
+ Assert.Equal("Yes", commands[0].Text);
+ }
+
+ [Fact]
+ public void Render_IfElse_False()
+ {
+ var commands = _engine.Render("""
+
+
+ Yes
+
+
+ No
+
+
+ """,
+ """{}""", 42, 22);
+
+ Assert.Single(commands);
+ Assert.Equal("No", commands[0].Text);
+ }
+
+ [Fact]
+ public void Render_If_NegatedCondition()
+ {
+ var commands = _engine.Render("""
+
+
+
+
+
+ """,
+ """{}""", 42, 22);
+
+ // $last is not set so not a boolean false, but the variable doesn't exist
+ // When there's no foreach context, $last is null, so !null = true
+ Assert.Single(commands);
+ Assert.True(commands[0].IsCut);
+ }
+
+ [Fact]
+ public void Render_Foreach_WithIfNotLast()
+ {
+ var commands = _engine.Render("""
+
+
+ {{item.Name}}
+
+
+
+
+
+ """,
+ """{"Items":[{"Name":"A"},{"Name":"B"},{"Name":"C"}]}""",
+ 42, 22);
+
+ // A, sep, B, sep, C = 5
+ Assert.Equal(5, commands.Count);
+ Assert.Equal("A", commands[0].Text);
+ Assert.Equal(new string('-', 42), commands[1].Text);
+ Assert.Equal("B", commands[2].Text);
+ Assert.Equal(new string('-', 42), commands[3].Text);
+ Assert.Equal("C", commands[4].Text);
+ }
+
+ [Fact]
+ public void Render_LineWithWrap()
+ {
+ var commands = _engine.Render(
+ """The quick brown fox jumps over the lazy dog and more text here""",
+ "{}", 30, 15);
+
+ Assert.True(commands.Count > 1);
+ }
+
+ [Fact]
+ public void Render_LineSpacing()
+ {
+ var commands = _engine.Render(
+ """Text""",
+ "{}", 42, 22);
+
+ Assert.Equal(50, commands[0].SetLineSpacing);
+ }
+
+ [Fact]
+ public void Render_FormatString()
+ {
+ var commands = _engine.Render(
+ "{{Price:F2}}",
+ """{"Price":9.5}""", 42, 22);
+
+ Assert.Equal("9.50", commands[0].Text);
+ }
+
+ [Fact]
+ public void Render_DateTimeFormat()
+ {
+ var commands = _engine.Render(
+ "{{Date:dd.MM.yyyy}}",
+ """{"Date":"2024-03-15T14:30:00Z"}""", 42, 22);
+
+ Assert.Equal("15.03.2024", commands[0].Text);
+ }
+
+ [Fact]
+ public void Render_Row()
+ {
+ var commands = _engine.Render("""
+
+
+ Left
+ Center
+ Right
+
+
+ """,
+ "{}", 42, 22);
+
+ Assert.Single(commands);
+ Assert.Equal(42, commands[0].Text.Length);
+ }
+
+ [Fact]
+ public void Render_InvalidXml_ThrowsParsingException()
+ {
+ Assert.Throws(() =>
+ _engine.Render("not xml", "{}", 42, 22));
+ }
+
+ [Fact]
+ public void Render_InvalidJson_ThrowsRenderingException()
+ {
+ Assert.Throws(() =>
+ _engine.Render("", "not json", 42, 22));
+ }
+
+ [Fact]
+ public void Render_ComplexTemplate()
+ {
+ var template = """
+
+ Restaurant
+
+
+ {{DateTime:dd-MMM-yy HH:mm}} Nr.:{{Number}}
+ Tisch: {{Table}}
+
+
+ {{item.Qty}}x {{item.Name}}
+
+
+
+
+
+ """;
+
+ var json = """
+ {
+ "DateTime": "2024-03-15T14:30:00Z",
+ "Number": "42",
+ "Table": "5",
+ "Items": [
+ {"Qty": 2, "Name": "Margherita"},
+ {"Qty": 1, "Name": "Tiramisu"}
+ ],
+ "Total": 45.50
+ }
+ """;
+
+ var commands = _engine.Render(template, json, 42, 22);
+
+ Assert.True(commands.Count >= 10);
+
+ // Header
+ Assert.True(commands[0].IsRed);
+ Assert.True(commands[0].IsBig);
+ Assert.Contains("Restaurant", commands[0].Text);
+
+ // Last command is cut
+ Assert.True(commands[^1].IsCut);
+ }
+}
diff --git a/Inspectron.Epson.TemplateEngine.Tests/Templates/FinalReceipt.xml b/Inspectron.Epson.TemplateEngine.Tests/Templates/FinalReceipt.xml
new file mode 100644
index 0000000..980b8c7
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine.Tests/Templates/FinalReceipt.xml
@@ -0,0 +1,115 @@
+
+ {{CompanyName}}
+ {{Address1}}
+ {{Address2}}
+ {{Phone}}
+
+
+
+
+ Debitorenrechnung
+
+
+
+ Guests: {{Guests}}
+
+
+
+
+
+ - {{sub}}
+
+
+
+
+ ---------
+
+
+ Summe: {{Total:F2}} {{Currency}}
+
+
+
+ {{TotalInAlternateCurrency:F2}} {{AlternateCurrency}}
+
+
+
+
+
+
+
+
+
+ {{sp.PaymentMethod}}: {{sp.Amount:F2}} {{sp.Currency}}
+
+
+
+
+
+
+
+
+
+
+
+ MwSt %
+ Brutto
+ Netto
+ MwSt
+
+
+
+ {{tax.Category}}:{{tax.Rate}}%
+ {{tax.Gross:F2}} {{tax.Currency}}
+ {{tax.Net:F2}} {{tax.Currency}}
+ {{tax.TaxAmount:F2}} {{tax.Currency}}
+
+
+
+
+ Nicht mehrwertsteuerpflichtig
+
+
+
+
+
+
+
+
+
+
+
+
+ {{VatNumber}}
+
+
+
+ {{tr.ReceiptType}}
+ {{tr.BookingType}}
+ {{tr.PaymentSystem}}
+ {{tr.TransactionNumber}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ThankYouMessage}}
+ {{GoodbyeMessageLine1}}
+ {{GoodbyeMessageLine2}}
+
+
+
+
+
+
+ Unterschrift
+
+
diff --git a/Inspectron.Epson.TemplateEngine.Tests/Templates/KitchenReceipt.xml b/Inspectron.Epson.TemplateEngine.Tests/Templates/KitchenReceipt.xml
new file mode 100644
index 0000000..201933d
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine.Tests/Templates/KitchenReceipt.xml
@@ -0,0 +1,65 @@
+
+ {{Title}}
+
+
+
+ {{TransactionDateTime:dd-MMM-yy HH:mm}} Nr.:{{ReceiptNumber}}
+ {{WaiterName}}
+ Tisch: {{TableNumber}}
+
+
+
+ {{SpecialInstruction}}
+
+
+
+
+
+
+ {{gang.Id}}. {{gang.Name}}
+
+ {{dish.GuestPrefix}}{{dish.Number}}x {{dish.Name}}
+
+
+ - {{removed}}
+
+
+ + {{added}}
+
+
+
+ Comment: {{dish.Comment}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{dish.GuestPrefix}}{{dish.Number}}x {{dish.Name}}
+
+
+ - {{removed}}
+
+
+ + {{added}}
+
+
+
+ Comment: {{dish.Comment}}
+
+
+
+
+
+
+
+
+
diff --git a/Inspectron.Epson.TemplateEngine.Tests/Templates/OrderItems.xml b/Inspectron.Epson.TemplateEngine.Tests/Templates/OrderItems.xml
new file mode 100644
index 0000000..39f5cf8
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine.Tests/Templates/OrderItems.xml
@@ -0,0 +1,34 @@
+
+
+ Order:
+ QR:
+ {{DateTime:dd.MM.yyyy}}
+
+
+
+ |Client Notes: {{ClientNotes}}
+
+
+
+
+
+
+
+
+ - {{sub}}
+
+
+ Change of
+ Ingredients:
+
+ - {{removed}}
+
+
+ + {{added}}
+
+
+
+ SpecialInstruction: {{item.Comment}}
+
+
+
diff --git a/Inspectron.Epson.TemplateEngine/DataBinding/DataContext.cs b/Inspectron.Epson.TemplateEngine/DataBinding/DataContext.cs
new file mode 100644
index 0000000..00e742a
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine/DataBinding/DataContext.cs
@@ -0,0 +1,123 @@
+using System.Text.Json;
+
+namespace Inspectron.Epson.TemplateEngine.DataBinding;
+
+public class DataContext
+{
+ private readonly JsonElement _root;
+ private readonly DataContext? _parent;
+ private readonly Dictionary _variables = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary _loopVariables = new(StringComparer.OrdinalIgnoreCase);
+
+ public DataContext(JsonElement root)
+ {
+ _root = root;
+ }
+
+ private DataContext(JsonElement root, DataContext parent)
+ {
+ _root = root;
+ _parent = parent;
+ }
+
+ public DataContext CreateChildScope()
+ {
+ return new DataContext(_root, this);
+ }
+
+ public void SetVariable(string name, JsonElement value)
+ {
+ _variables[name] = value;
+ }
+
+ public void SetLoopVariable(string name, object value)
+ {
+ _loopVariables[name] = value;
+ }
+
+ public object? GetLoopVariable(string name)
+ {
+ if (_loopVariables.TryGetValue(name, out var value))
+ return value;
+ return _parent?.GetLoopVariable(name);
+ }
+
+ public JsonElement? Resolve(string expression)
+ {
+ if (string.IsNullOrWhiteSpace(expression))
+ return null;
+
+ var parts = expression.Split('.');
+ var firstPart = parts[0];
+
+ // Check local variables first
+ JsonElement? current = null;
+
+ if (_variables.TryGetValue(firstPart, out var varValue))
+ {
+ current = varValue;
+ }
+ else if (_parent != null)
+ {
+ // Walk up scope chain for variables
+ var ctx = _parent;
+ while (ctx != null)
+ {
+ if (ctx._variables.TryGetValue(firstPart, out var parentVar))
+ {
+ current = parentVar;
+ break;
+ }
+ ctx = ctx._parent;
+ }
+ }
+
+ // If not found in variables, try root
+ if (current == null)
+ {
+ current = GetProperty(_root, firstPart);
+ }
+
+ if (current == null)
+ return null;
+
+ // Navigate remaining parts
+ for (int i = 1; i < parts.Length; i++)
+ {
+ current = GetProperty(current.Value, parts[i]);
+ if (current == null)
+ return null;
+ }
+
+ return current;
+ }
+
+ private static JsonElement? GetProperty(JsonElement element, string propertyName)
+ {
+ if (element.ValueKind != JsonValueKind.Object)
+ return null;
+
+ // Case-insensitive property lookup
+ foreach (var prop in element.EnumerateObject())
+ {
+ if (string.Equals(prop.Name, propertyName, StringComparison.OrdinalIgnoreCase))
+ return prop.Value;
+ }
+
+ return null;
+ }
+
+ public static string GetStringValue(JsonElement element)
+ {
+ return element.ValueKind switch
+ {
+ JsonValueKind.String => element.GetString() ?? "",
+ JsonValueKind.Number => element.TryGetInt64(out var l) ? l.ToString() : element.GetDouble().ToString(),
+ JsonValueKind.True => "true",
+ JsonValueKind.False => "false",
+ JsonValueKind.Null => "",
+ JsonValueKind.Undefined => "",
+ _ => element.GetRawText()
+ };
+ }
+}
diff --git a/Inspectron.Epson.TemplateEngine/DataBinding/ExpressionEvaluator.cs b/Inspectron.Epson.TemplateEngine/DataBinding/ExpressionEvaluator.cs
new file mode 100644
index 0000000..3e44a40
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine/DataBinding/ExpressionEvaluator.cs
@@ -0,0 +1,218 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace Inspectron.Epson.TemplateEngine.DataBinding;
+
+public class ExpressionEvaluator
+{
+ private static readonly Regex ExpressionPattern = new(@"\{\{(.+?)\}\}", RegexOptions.Compiled);
+
+ private readonly DataContext _context;
+
+ public ExpressionEvaluator(DataContext context)
+ {
+ _context = context;
+ }
+
+ public string Evaluate(string template)
+ {
+ if (string.IsNullOrEmpty(template))
+ return "";
+
+ return ExpressionPattern.Replace(template, match =>
+ {
+ var expression = match.Groups[1].Value.Trim();
+ return ResolveExpression(expression);
+ });
+ }
+
+ private string ResolveExpression(string expression)
+ {
+ // Loop variables: $index, $first, $last
+ if (expression.StartsWith("$"))
+ {
+ var loopVar = _context.GetLoopVariable(expression);
+ return loopVar?.ToString() ?? "";
+ }
+
+ // Format string: PropertyName:format
+ string? format = null;
+ var colonIndex = expression.IndexOf(':');
+ if (colonIndex > 0)
+ {
+ format = expression[(colonIndex + 1)..];
+ expression = expression[..colonIndex];
+ }
+
+ var element = _context.Resolve(expression);
+ if (element == null)
+ return "";
+
+ if (format != null)
+ return FormatValue(element.Value, format);
+
+ return DataContext.GetStringValue(element.Value);
+ }
+
+ private static string FormatValue(JsonElement element, string format)
+ {
+ if (element.ValueKind == JsonValueKind.String)
+ {
+ var str = element.GetString();
+ if (str != null && DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt))
+ {
+ return dt.ToString(format, CultureInfo.InvariantCulture);
+ }
+ return str ?? "";
+ }
+
+ if (element.ValueKind == JsonValueKind.Number)
+ {
+ if (element.TryGetDecimal(out var d))
+ return d.ToString(format, CultureInfo.InvariantCulture);
+ }
+
+ return DataContext.GetStringValue(element);
+ }
+
+ public bool EvaluateCondition(string test)
+ {
+ if (string.IsNullOrWhiteSpace(test))
+ return false;
+
+ // Negation
+ bool negate = false;
+ var expr = test.Trim();
+ if (expr.StartsWith("!"))
+ {
+ negate = true;
+ expr = expr[1..].Trim();
+ }
+
+ // Loop variables: $first, $last
+ if (expr.StartsWith("$"))
+ {
+ var loopVar = _context.GetLoopVariable(expr);
+ bool loopResult = loopVar is bool b ? b : loopVar != null;
+ return negate ? !loopResult : loopResult;
+ }
+
+ // Comparison operators
+ var comparisonOps = new[] { "==", "!=", ">=", "<=", ">", "<" };
+ foreach (var op in comparisonOps)
+ {
+ var parts = SplitComparison(expr, op);
+ if (parts != null)
+ {
+ bool compResult = EvaluateComparison(parts.Value.left, op, parts.Value.right);
+ return negate ? !compResult : compResult;
+ }
+ }
+
+ // Truthiness check - property exists and has a value
+ var result = IsTruthy(expr);
+ return negate ? !result : result;
+ }
+
+ private (string left, string right)? SplitComparison(string expr, string op)
+ {
+ var idx = expr.IndexOf(op, StringComparison.Ordinal);
+ if (idx < 0) return null;
+
+ // Make sure we don't confuse == with = or != with !
+ if (op == "=" && idx > 0 && expr[idx - 1] == '!') return null;
+ if (op == ">" && idx > 0 && (expr[idx - 1] == '>' || expr[idx - 1] == '<')) return null;
+
+ var left = expr[..idx].Trim();
+ var right = expr[(idx + op.Length)..].Trim();
+
+ if (string.IsNullOrEmpty(left) || string.IsNullOrEmpty(right))
+ return null;
+
+ return (left, right);
+ }
+
+ private bool EvaluateComparison(string left, string op, string right)
+ {
+ var leftVal = ResolveComparisonValue(left);
+ var rightVal = ResolveComparisonValue(right);
+
+ // Try numeric comparison
+ if (decimal.TryParse(leftVal, CultureInfo.InvariantCulture, out var leftNum)
+ && decimal.TryParse(rightVal, CultureInfo.InvariantCulture, out var rightNum))
+ {
+ return op switch
+ {
+ "==" => leftNum == rightNum,
+ "!=" => leftNum != rightNum,
+ ">" => leftNum > rightNum,
+ "<" => leftNum < rightNum,
+ ">=" => leftNum >= rightNum,
+ "<=" => leftNum <= rightNum,
+ _ => false
+ };
+ }
+
+ // String comparison
+ int cmp = string.Compare(leftVal, rightVal, StringComparison.OrdinalIgnoreCase);
+ return op switch
+ {
+ "==" => cmp == 0,
+ "!=" => cmp != 0,
+ ">" => cmp > 0,
+ "<" => cmp < 0,
+ ">=" => cmp >= 0,
+ "<=" => cmp <= 0,
+ _ => false
+ };
+ }
+
+ private string ResolveComparisonValue(string value)
+ {
+ // Quoted string literal
+ if ((value.StartsWith("'") && value.EndsWith("'")) ||
+ (value.StartsWith("\"") && value.EndsWith("\"")))
+ {
+ return value[1..^1];
+ }
+
+ // Numeric literal
+ if (decimal.TryParse(value, CultureInfo.InvariantCulture, out _))
+ {
+ return value;
+ }
+
+ // Loop variable
+ if (value.StartsWith("$"))
+ {
+ var loopVar = _context.GetLoopVariable(value);
+ return loopVar?.ToString() ?? "";
+ }
+
+ // Property resolution
+ var element = _context.Resolve(value);
+ if (element == null)
+ return "";
+
+ return DataContext.GetStringValue(element.Value);
+ }
+
+ private bool IsTruthy(string expression)
+ {
+ var element = _context.Resolve(expression);
+ if (element == null)
+ return false;
+
+ return element.Value.ValueKind switch
+ {
+ JsonValueKind.Null => false,
+ JsonValueKind.Undefined => false,
+ JsonValueKind.False => false,
+ JsonValueKind.String => !string.IsNullOrEmpty(element.Value.GetString()),
+ JsonValueKind.Number => element.Value.GetDouble() != 0,
+ JsonValueKind.Array => element.Value.GetArrayLength() > 0,
+ _ => true
+ };
+ }
+}
diff --git a/Inspectron.Epson.TemplateEngine/Exceptions.cs b/Inspectron.Epson.TemplateEngine/Exceptions.cs
new file mode 100644
index 0000000..da95be5
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine/Exceptions.cs
@@ -0,0 +1,13 @@
+namespace Inspectron.Epson.TemplateEngine;
+
+public class TemplateParsingException : Exception
+{
+ public TemplateParsingException(string message) : base(message) { }
+ public TemplateParsingException(string message, Exception innerException) : base(message, innerException) { }
+}
+
+public class TemplateRenderingException : Exception
+{
+ public TemplateRenderingException(string message) : base(message) { }
+ public TemplateRenderingException(string message, Exception innerException) : base(message, innerException) { }
+}
diff --git a/Inspectron.Epson.TemplateEngine/Inspectron.Epson.TemplateEngine.csproj b/Inspectron.Epson.TemplateEngine/Inspectron.Epson.TemplateEngine.csproj
new file mode 100644
index 0000000..31892eb
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine/Inspectron.Epson.TemplateEngine.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/Inspectron.Epson.TemplateEngine/Parsing/TemplateNode.cs b/Inspectron.Epson.TemplateEngine/Parsing/TemplateNode.cs
new file mode 100644
index 0000000..c411268
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine/Parsing/TemplateNode.cs
@@ -0,0 +1,84 @@
+namespace Inspectron.Epson.TemplateEngine.Parsing;
+
+public abstract class TemplateNode
+{
+ public List Children { get; set; } = new();
+}
+
+public class ReceiptNode : TemplateNode { }
+
+public class LineNode : TemplateNode
+{
+ public string? Text { get; set; }
+ public bool Bold { get; set; }
+ public bool Big { get; set; }
+ public bool Tall { get; set; }
+ public bool Red { get; set; }
+ public string Align { get; set; } = "left";
+ public int? LineSpacing { get; set; }
+ public bool Wrap { get; set; }
+ public int WrapIndent { get; set; }
+}
+
+public class ColumnsNode : TemplateNode
+{
+ public string? Left { get; set; }
+ public string? Right { get; set; }
+ public bool Bold { get; set; }
+ public bool Big { get; set; }
+ public bool Tall { get; set; }
+ public bool Red { get; set; }
+ public int? LineSpacing { get; set; }
+ public bool Wrap { get; set; }
+ public int WrapIndent { get; set; }
+}
+
+public class RowNode : TemplateNode
+{
+ public List Columns { get; set; } = new();
+ public bool Bold { get; set; }
+ public bool Big { get; set; }
+ public bool Tall { get; set; }
+ public bool Red { get; set; }
+ public int? LineSpacing { get; set; }
+}
+
+public class ColumnDef
+{
+ public string? Text { get; set; }
+ public int? Width { get; set; }
+ public string Align { get; set; } = "left";
+}
+
+public class SeparatorNode : TemplateNode
+{
+ public char Character { get; set; } = '-';
+}
+
+public class CutNode : TemplateNode { }
+
+public class FeedNode : TemplateNode
+{
+ public int Lines { get; set; } = 1;
+}
+
+public class ForeachNode : TemplateNode
+{
+ public string Items { get; set; } = "";
+ public string Var { get; set; } = "";
+}
+
+public class IfNode : TemplateNode
+{
+ public string Test { get; set; } = "";
+}
+
+public class ElseNode : TemplateNode { }
+
+public class TableNode : TemplateNode
+{
+ public List Columns { get; set; } = new();
+ public string? HeaderItems { get; set; }
+ public string? Items { get; set; }
+ public string? Var { get; set; }
+}
diff --git a/Inspectron.Epson.TemplateEngine/Parsing/TemplateParser.cs b/Inspectron.Epson.TemplateEngine/Parsing/TemplateParser.cs
new file mode 100644
index 0000000..725924b
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine/Parsing/TemplateParser.cs
@@ -0,0 +1,250 @@
+using System.Xml.Linq;
+
+namespace Inspectron.Epson.TemplateEngine.Parsing;
+
+public class TemplateParser
+{
+ public ReceiptNode Parse(string xml)
+ {
+ XDocument doc;
+ try
+ {
+ doc = XDocument.Parse(xml);
+ }
+ catch (Exception ex)
+ {
+ throw new TemplateParsingException($"Invalid XML: {ex.Message}", ex);
+ }
+
+ var root = doc.Root;
+ if (root == null || root.Name.LocalName != "receipt")
+ throw new TemplateParsingException("Root element must be ");
+
+ var receiptNode = new ReceiptNode();
+ ParseChildren(root, receiptNode.Children);
+ return receiptNode;
+ }
+
+ private void ParseChildren(XElement parent, List children)
+ {
+ foreach (var element in parent.Elements())
+ {
+ var node = ParseElement(element);
+ if (node != null)
+ children.Add(node);
+ }
+ }
+
+ private TemplateNode? ParseElement(XElement element)
+ {
+ return element.Name.LocalName switch
+ {
+ "line" => ParseLine(element),
+ "columns" => ParseColumns(element),
+ "row" => ParseRow(element),
+ "separator" => ParseSeparator(element),
+ "cut" => new CutNode(),
+ "feed" => ParseFeed(element),
+ "foreach" => ParseForeach(element),
+ "if" => ParseIf(element),
+ "else" => ParseElse(element),
+ "table" => ParseTable(element),
+ _ => throw new TemplateParsingException($"Unknown element: <{element.Name.LocalName}>")
+ };
+ }
+
+ private LineNode ParseLine(XElement element)
+ {
+ var node = new LineNode();
+ ApplyFormatting(element, node);
+
+ // Text content: either inner text (with {{}} expressions) or empty line
+ var text = GetTextContent(element);
+ node.Text = text;
+
+ node.Align = GetAttr(element, "align", "left");
+ node.Wrap = GetBoolAttr(element, "wrap");
+ node.WrapIndent = GetIntAttr(element, "wrapIndent", 0);
+
+ return node;
+ }
+
+ private ColumnsNode ParseColumns(XElement element)
+ {
+ var node = new ColumnsNode();
+ ApplyFormatting(element, node);
+
+ node.Left = GetAttr(element, "left", "");
+ node.Right = GetAttr(element, "right", "");
+ node.Wrap = GetBoolAttr(element, "wrap");
+ node.WrapIndent = GetIntAttr(element, "wrapIndent", 0);
+
+ return node;
+ }
+
+ private RowNode ParseRow(XElement element)
+ {
+ var node = new RowNode();
+ ApplyFormatting(element, node);
+
+ foreach (var colElement in element.Elements("col"))
+ {
+ var col = new ColumnDef
+ {
+ Text = GetTextContent(colElement),
+ Width = GetNullableIntAttr(colElement, "width"),
+ Align = GetAttr(colElement, "align", "left")
+ };
+ node.Columns.Add(col);
+ }
+
+ return node;
+ }
+
+ private SeparatorNode ParseSeparator(XElement element)
+ {
+ var charAttr = GetAttr(element, "char", "-");
+ return new SeparatorNode
+ {
+ Character = string.IsNullOrEmpty(charAttr) ? '-' : charAttr[0]
+ };
+ }
+
+ private FeedNode ParseFeed(XElement element)
+ {
+ return new FeedNode
+ {
+ Lines = GetIntAttr(element, "lines", 1)
+ };
+ }
+
+ private ForeachNode ParseForeach(XElement element)
+ {
+ var items = GetRequiredAttr(element, "items", "foreach")!;
+ var var_ = GetRequiredAttr(element, "var", "foreach")!;
+
+ var node = new ForeachNode { Items = items, Var = var_ };
+ ParseChildren(element, node.Children);
+ return node;
+ }
+
+ private IfNode ParseIf(XElement element)
+ {
+ var test = GetRequiredAttr(element, "test", "if")!;
+
+ var node = new IfNode { Test = test };
+ ParseChildren(element, node.Children);
+ return node;
+ }
+
+ private ElseNode ParseElse(XElement element)
+ {
+ var node = new ElseNode();
+ ParseChildren(element, node.Children);
+ return node;
+ }
+
+ private TableNode ParseTable(XElement element)
+ {
+ var node = new TableNode
+ {
+ Items = GetAttr(element, "items", null),
+ Var = GetAttr(element, "var", null),
+ HeaderItems = GetAttr(element, "headerItems", null)
+ };
+
+ foreach (var colElement in element.Elements("col"))
+ {
+ var col = new ColumnDef
+ {
+ Text = GetTextContent(colElement),
+ Width = GetNullableIntAttr(colElement, "width"),
+ Align = GetAttr(colElement, "align", "left")
+ };
+ node.Columns.Add(col);
+ }
+
+ // Parse non-col children (col elements are consumed above as column definitions)
+ foreach (var child in element.Elements().Where(e => e.Name.LocalName != "col"))
+ {
+ var childNode = ParseElement(child);
+ if (childNode != null)
+ node.Children.Add(childNode);
+ }
+
+ return node;
+ }
+
+ private static void ApplyFormatting(XElement element, LineNode node)
+ {
+ node.Bold = GetBoolAttr(element, "bold");
+ node.Big = GetBoolAttr(element, "big");
+ node.Tall = GetBoolAttr(element, "tall");
+ node.Red = GetBoolAttr(element, "red");
+ node.LineSpacing = GetNullableIntAttr(element, "lineSpacing");
+ }
+
+ private static void ApplyFormatting(XElement element, ColumnsNode node)
+ {
+ node.Bold = GetBoolAttr(element, "bold");
+ node.Big = GetBoolAttr(element, "big");
+ node.Tall = GetBoolAttr(element, "tall");
+ node.Red = GetBoolAttr(element, "red");
+ node.LineSpacing = GetNullableIntAttr(element, "lineSpacing");
+ }
+
+ private static void ApplyFormatting(XElement element, RowNode node)
+ {
+ node.Bold = GetBoolAttr(element, "bold");
+ node.Big = GetBoolAttr(element, "big");
+ node.Tall = GetBoolAttr(element, "tall");
+ node.Red = GetBoolAttr(element, "red");
+ node.LineSpacing = GetNullableIntAttr(element, "lineSpacing");
+ }
+
+ private static string GetTextContent(XElement element)
+ {
+ // Get all inner text content (may include {{}} expressions)
+ // Use element.Value to get concatenated text of all text nodes
+ if (!element.HasElements)
+ return element.Value;
+
+ // If element has child elements, only get direct text nodes
+ return string.Concat(element.Nodes().OfType().Select(t => t.Value));
+ }
+
+ private static string? GetRequiredAttr(XElement element, string name, string elementName)
+ {
+ var attr = element.Attribute(name);
+ if (attr == null)
+ throw new TemplateParsingException($"<{elementName}> requires '{name}' attribute");
+ return attr.Value;
+ }
+
+ private static string GetAttr(XElement element, string name, string? defaultValue)
+ {
+ var attr = element.Attribute(name);
+ return attr?.Value ?? defaultValue ?? "";
+ }
+
+ private static bool GetBoolAttr(XElement element, string name)
+ {
+ var attr = element.Attribute(name);
+ if (attr == null) return false;
+ return attr.Value.Equals("true", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static int GetIntAttr(XElement element, string name, int defaultValue)
+ {
+ var attr = element.Attribute(name);
+ if (attr == null) return defaultValue;
+ return int.TryParse(attr.Value, out var v) ? v : defaultValue;
+ }
+
+ private static int? GetNullableIntAttr(XElement element, string name)
+ {
+ var attr = element.Attribute(name);
+ if (attr == null) return null;
+ return int.TryParse(attr.Value, out var v) ? v : null;
+ }
+}
diff --git a/Inspectron.Epson.TemplateEngine/ReceiptTemplateEngine.cs b/Inspectron.Epson.TemplateEngine/ReceiptTemplateEngine.cs
new file mode 100644
index 0000000..f314e5e
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine/ReceiptTemplateEngine.cs
@@ -0,0 +1,38 @@
+using System.Text.Json;
+using Inspectron.Epson.PrintServer.Printers.Utils;
+using Inspectron.Epson.TemplateEngine.DataBinding;
+using Inspectron.Epson.TemplateEngine.Parsing;
+using Inspectron.Epson.TemplateEngine.Rendering;
+
+namespace Inspectron.Epson.TemplateEngine;
+
+public class ReceiptTemplateEngine
+{
+ public List Render(
+ string xmlTemplate,
+ string jsonData,
+ int lineWidth = 48,
+ int bigFontLineWidth = 24)
+ {
+ // Parse XML template into node tree
+ var parser = new TemplateParser();
+ var receiptNode = parser.Parse(xmlTemplate);
+
+ // Parse JSON data into DataContext
+ JsonElement root;
+ try
+ {
+ root = JsonDocument.Parse(jsonData).RootElement;
+ }
+ catch (JsonException ex)
+ {
+ throw new TemplateRenderingException($"Invalid JSON data: {ex.Message}", ex);
+ }
+
+ var context = new DataContext(root);
+
+ // Render node tree to PrintCommands
+ var renderer = new TemplateRenderer(lineWidth, bigFontLineWidth);
+ return renderer.Render(receiptNode, context);
+ }
+}
diff --git a/Inspectron.Epson.TemplateEngine/Rendering/LayoutEngine.cs b/Inspectron.Epson.TemplateEngine/Rendering/LayoutEngine.cs
new file mode 100644
index 0000000..eee7d20
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine/Rendering/LayoutEngine.cs
@@ -0,0 +1,275 @@
+namespace Inspectron.Epson.TemplateEngine.Rendering;
+
+public class LayoutEngine
+{
+ public string AlignText(string text, string alignment, int lineWidth)
+ {
+ if (string.IsNullOrEmpty(text))
+ return text ?? "";
+
+ if (text.Length >= lineWidth)
+ return text;
+
+ return alignment.ToLowerInvariant() switch
+ {
+ "center" => CenterText(text, lineWidth),
+ "right" => text.PadLeft(lineWidth),
+ _ => text // left-aligned is default (no padding)
+ };
+ }
+
+ private static string CenterText(string text, int lineWidth)
+ {
+ int totalPadding = lineWidth - text.Length;
+ int leftPadding = totalPadding / 2;
+ return new string(' ', leftPadding) + text;
+ }
+
+ public string FormatTwoColumns(string left, string right, int lineWidth)
+ {
+ left ??= "";
+ right ??= "";
+
+ int halfWidth = lineWidth / 2;
+ return left.PadRight(halfWidth) + right.PadLeft(lineWidth - halfWidth);
+ }
+
+ public List FormatTwoColumnsWithWrap(string left, string right, int lineWidth)
+ {
+ left ??= "";
+ right ??= "";
+
+ int halfWidth = lineWidth / 2;
+
+ // Try simple format first
+ if (left.Length + right.Length <= lineWidth)
+ {
+ int spaces = lineWidth - left.Length - right.Length;
+ if (spaces < 1) spaces = 1;
+ return new List { left + new string(' ', spaces) + right };
+ }
+
+ // Wrap left side, right-align right on first line
+ var leftLines = WrapText(left, halfWidth);
+ var result = new List();
+
+ for (int i = 0; i < leftLines.Count; i++)
+ {
+ if (i == 0)
+ {
+ int spaces = lineWidth - leftLines[i].Length - right.Length;
+ if (spaces < 1)
+ {
+ result.Add(leftLines[i]);
+ result.Add(right.PadLeft(lineWidth));
+ }
+ else
+ {
+ result.Add(leftLines[i] + new string(' ', spaces) + right);
+ }
+ }
+ else
+ {
+ result.Add(leftLines[i]);
+ }
+ }
+
+ return result;
+ }
+
+ public string FormatMultiColumn(List<(string text, int width, string align)> columns, int lineWidth)
+ {
+ var parts = new List();
+
+ // Calculate widths: distribute remaining width among columns without explicit width
+ int totalExplicit = columns.Where(c => c.width > 0).Sum(c => c.width);
+ int unspecifiedCount = columns.Count(c => c.width <= 0);
+ int remaining = lineWidth - totalExplicit;
+ int defaultWidth = unspecifiedCount > 0 ? remaining / unspecifiedCount : 0;
+
+ foreach (var (text, width, align) in columns)
+ {
+ int colWidth = width > 0 ? width : defaultWidth;
+ if (colWidth <= 0) colWidth = 1;
+
+ string formatted = align.ToLowerInvariant() switch
+ {
+ "center" => CenterInWidth(text ?? "", colWidth),
+ "right" => (text ?? "").PadLeft(colWidth),
+ _ => (text ?? "").PadRight(colWidth)
+ };
+
+ // Truncate if too long
+ if (formatted.Length > colWidth)
+ formatted = formatted[..colWidth];
+
+ parts.Add(formatted);
+ }
+
+ return string.Concat(parts);
+ }
+
+ public List WrapText(string text, int maxWidth, int indent = 0)
+ {
+ var lines = new List();
+
+ if (string.IsNullOrEmpty(text) || maxWidth <= 0)
+ {
+ lines.Add(text ?? "");
+ return lines;
+ }
+
+ if (text.Length <= maxWidth)
+ {
+ lines.Add(text);
+ return lines;
+ }
+
+ var words = text.Split(' ');
+ string currentLine = "";
+ bool isFirst = true;
+
+ foreach (var word in words)
+ {
+ int available = isFirst ? maxWidth : maxWidth - indent;
+ if (available <= 0) available = 1;
+
+ if (currentLine.Length == 0)
+ {
+ currentLine = word;
+ }
+ else if (currentLine.Length + 1 + word.Length <= available)
+ {
+ currentLine += " " + word;
+ }
+ else
+ {
+ if (isFirst)
+ {
+ lines.Add(currentLine);
+ isFirst = false;
+ }
+ else
+ {
+ lines.Add(new string(' ', indent) + currentLine);
+ }
+ currentLine = word;
+ }
+ }
+
+ if (currentLine.Length > 0)
+ {
+ if (isFirst)
+ lines.Add(currentLine);
+ else
+ lines.Add(new string(' ', indent) + currentLine);
+ }
+
+ return lines;
+ }
+
+ public string CreateSeparator(char character, int lineWidth)
+ {
+ return new string(character, lineWidth);
+ }
+
+ public List FormatTable(
+ List<(string text, int width, string align)> columns,
+ List> headerRows,
+ List> dataRows,
+ int lineWidth)
+ {
+ var result = new List();
+
+ // Calculate column widths
+ var colWidths = CalculateTableColumnWidths(columns, lineWidth);
+
+ // Top border
+ result.Add(FormatTableBorder(colWidths));
+
+ // Header rows
+ foreach (var row in headerRows)
+ {
+ result.Add(FormatTableRow(row, colWidths, columns));
+ }
+
+ // Separator between header and data
+ if (headerRows.Count > 0 && dataRows.Count > 0)
+ {
+ result.Add(FormatTableBorder(colWidths));
+ }
+
+ // Data rows
+ foreach (var row in dataRows)
+ {
+ result.Add(FormatTableRow(row, colWidths, columns));
+ }
+
+ // Bottom border
+ result.Add(FormatTableBorder(colWidths));
+
+ return result;
+ }
+
+ private List CalculateTableColumnWidths(List<(string text, int width, string align)> columns, int lineWidth)
+ {
+ // Account for borders: |col1|col2|col3| = columns.Count + 1 border chars
+ int availableWidth = lineWidth - columns.Count - 1;
+ int totalExplicit = columns.Where(c => c.width > 0).Sum(c => c.width);
+ int unspecifiedCount = columns.Count(c => c.width <= 0);
+ int remaining = availableWidth - totalExplicit;
+ int defaultWidth = unspecifiedCount > 0 ? remaining / unspecifiedCount : 0;
+
+ var widths = new List();
+ foreach (var (_, width, _) in columns)
+ {
+ widths.Add(width > 0 ? width : Math.Max(defaultWidth, 1));
+ }
+ return widths;
+ }
+
+ private string FormatTableBorder(List colWidths)
+ {
+ return "+" + string.Join("+", colWidths.Select(w => new string('-', w))) + "+";
+ }
+
+ private string FormatTableRow(List cells, List colWidths, List<(string text, int width, string align)> columns)
+ {
+ var parts = new List();
+ for (int i = 0; i < colWidths.Count; i++)
+ {
+ string cell = i < cells.Count ? cells[i] : "";
+ string align = i < columns.Count ? columns[i].align : "left";
+ parts.Add(FormatCellContent(cell, colWidths[i], align));
+ }
+ return "|" + string.Join("|", parts) + "|";
+ }
+
+ private string FormatCellContent(string text, int width, string align)
+ {
+ if (text.Length > width)
+ text = text[..width];
+
+ return align.ToLowerInvariant() switch
+ {
+ "center" => CenterInWidth(text, width),
+ "right" => text.PadLeft(width),
+ _ => text.PadRight(width)
+ };
+ }
+
+ private static string CenterInWidth(string text, int width)
+ {
+ if (string.IsNullOrEmpty(text))
+ return new string(' ', width);
+
+ if (text.Length >= width)
+ return text[..width];
+
+ int totalPadding = width - text.Length;
+ int leftPadding = totalPadding / 2;
+ int rightPadding = totalPadding - leftPadding;
+
+ return new string(' ', leftPadding) + text + new string(' ', rightPadding);
+ }
+}
diff --git a/Inspectron.Epson.TemplateEngine/Rendering/TemplateRenderer.cs b/Inspectron.Epson.TemplateEngine/Rendering/TemplateRenderer.cs
new file mode 100644
index 0000000..eb3199a
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine/Rendering/TemplateRenderer.cs
@@ -0,0 +1,286 @@
+using System.Text.Json;
+using Inspectron.Epson.PrintServer.Printers.Utils;
+using Inspectron.Epson.TemplateEngine.DataBinding;
+using Inspectron.Epson.TemplateEngine.Parsing;
+
+namespace Inspectron.Epson.TemplateEngine.Rendering;
+
+public class TemplateRenderer
+{
+ private readonly int _lineWidth;
+ private readonly int _bigFontLineWidth;
+ private readonly LayoutEngine _layout = new();
+
+ public TemplateRenderer(int lineWidth, int bigFontLineWidth)
+ {
+ _lineWidth = lineWidth;
+ _bigFontLineWidth = bigFontLineWidth;
+ }
+
+ public List Render(ReceiptNode receipt, DataContext context)
+ {
+ var commands = new List();
+ RenderChildren(receipt.Children, context, commands);
+ return commands;
+ }
+
+ private void RenderChildren(List children, DataContext context, List commands)
+ {
+ for (int i = 0; i < children.Count; i++)
+ {
+ var node = children[i];
+
+ if (node is IfNode ifNode)
+ {
+ var evaluator = new ExpressionEvaluator(context);
+ bool condition = evaluator.EvaluateCondition(ifNode.Test);
+
+ if (condition)
+ {
+ RenderChildren(ifNode.Children, context, commands);
+ // Skip following else
+ if (i + 1 < children.Count && children[i + 1] is ElseNode)
+ i++;
+ }
+ else
+ {
+ // Check for following else
+ if (i + 1 < children.Count && children[i + 1] is ElseNode elseNode)
+ {
+ RenderChildren(elseNode.Children, context, commands);
+ i++;
+ }
+ }
+ }
+ else
+ {
+ RenderNode(node, context, commands);
+ }
+ }
+ }
+
+ private void RenderNode(TemplateNode node, DataContext context, List commands)
+ {
+ switch (node)
+ {
+ case LineNode line:
+ RenderLine(line, context, commands);
+ break;
+ case ColumnsNode columns:
+ RenderColumns(columns, context, commands);
+ break;
+ case RowNode row:
+ RenderRow(row, context, commands);
+ break;
+ case SeparatorNode separator:
+ RenderSeparator(separator, commands);
+ break;
+ case CutNode:
+ commands.Add(new PrintCommand("") { IsCut = true });
+ break;
+ case FeedNode feed:
+ RenderFeed(feed, commands);
+ break;
+ case ForeachNode foreachNode:
+ RenderForeach(foreachNode, context, commands);
+ break;
+ case TableNode table:
+ RenderTable(table, context, commands);
+ break;
+ case ElseNode:
+ // Handled by IfNode processing in RenderChildren
+ break;
+ }
+ }
+
+ private void RenderLine(LineNode line, DataContext context, List commands)
+ {
+ var evaluator = new ExpressionEvaluator(context);
+ string text = evaluator.Evaluate(line.Text ?? "");
+ int effectiveWidth = line.Big ? _bigFontLineWidth : _lineWidth;
+
+ if (line.Wrap && text.Length > effectiveWidth)
+ {
+ var wrappedLines = _layout.WrapText(text, effectiveWidth, line.WrapIndent);
+ foreach (var wrappedLine in wrappedLines)
+ {
+ var cmd = CreateCommand(wrappedLine, line.Bold, line.Big, line.Tall, line.Red, line.LineSpacing);
+ commands.Add(cmd);
+ }
+ }
+ else
+ {
+ text = _layout.AlignText(text, line.Align, effectiveWidth);
+ var cmd = CreateCommand(text, line.Bold, line.Big, line.Tall, line.Red, line.LineSpacing);
+ commands.Add(cmd);
+ }
+ }
+
+ private void RenderColumns(ColumnsNode columns, DataContext context, List commands)
+ {
+ var evaluator = new ExpressionEvaluator(context);
+ string left = evaluator.Evaluate(columns.Left ?? "");
+ string right = evaluator.Evaluate(columns.Right ?? "");
+ int effectiveWidth = columns.Big ? _bigFontLineWidth : _lineWidth;
+
+ if (columns.Wrap)
+ {
+ var lines = _layout.FormatTwoColumnsWithWrap(left, right, effectiveWidth);
+ foreach (var line in lines)
+ {
+ var cmd = CreateCommand(line, columns.Bold, columns.Big, columns.Tall, columns.Red, columns.LineSpacing);
+ commands.Add(cmd);
+ }
+ }
+ else
+ {
+ string text = _layout.FormatTwoColumns(left, right, effectiveWidth);
+ var cmd = CreateCommand(text, columns.Bold, columns.Big, columns.Tall, columns.Red, columns.LineSpacing);
+ commands.Add(cmd);
+ }
+ }
+
+ private void RenderRow(RowNode row, DataContext context, List commands)
+ {
+ var evaluator = new ExpressionEvaluator(context);
+ int effectiveWidth = row.Big ? _bigFontLineWidth : _lineWidth;
+
+ var columnData = row.Columns.Select(c => (
+ text: evaluator.Evaluate(c.Text ?? ""),
+ width: c.Width ?? 0,
+ align: c.Align
+ )).ToList();
+
+ string text = _layout.FormatMultiColumn(columnData, effectiveWidth);
+ var cmd = CreateCommand(text, row.Bold, row.Big, row.Tall, row.Red, row.LineSpacing);
+ commands.Add(cmd);
+ }
+
+ private void RenderSeparator(SeparatorNode separator, List commands)
+ {
+ string text = _layout.CreateSeparator(separator.Character, _lineWidth);
+ commands.Add(new PrintCommand(text));
+ }
+
+ private void RenderFeed(FeedNode feed, List commands)
+ {
+ for (int i = 0; i < feed.Lines; i++)
+ {
+ commands.Add(new PrintCommand(""));
+ }
+ }
+
+ private void RenderForeach(ForeachNode foreachNode, DataContext context, List commands)
+ {
+ var evaluator = new ExpressionEvaluator(context);
+
+ // Resolve the items collection
+ var itemsElement = context.Resolve(foreachNode.Items);
+ if (itemsElement == null || itemsElement.Value.ValueKind != JsonValueKind.Array)
+ return;
+
+ var array = itemsElement.Value;
+ int count = array.GetArrayLength();
+
+ for (int i = 0; i < count; i++)
+ {
+ var item = array[i];
+ var childContext = context.CreateChildScope();
+ childContext.SetVariable(foreachNode.Var, item);
+ childContext.SetLoopVariable("$index", i);
+ childContext.SetLoopVariable("$first", i == 0);
+ childContext.SetLoopVariable("$last", i == count - 1);
+
+ RenderChildren(foreachNode.Children, childContext, commands);
+ }
+ }
+
+ private void RenderTable(TableNode table, DataContext context, List commands)
+ {
+ var evaluator = new ExpressionEvaluator(context);
+
+ var columnDefs = table.Columns.Select(c => (
+ text: evaluator.Evaluate(c.Text ?? ""),
+ width: c.Width ?? 0,
+ align: c.Align
+ )).ToList();
+
+ // Header rows from headerItems or column text
+ var headerRows = new List>();
+ if (!string.IsNullOrEmpty(table.HeaderItems))
+ {
+ var headerArray = context.Resolve(table.HeaderItems);
+ if (headerArray != null && headerArray.Value.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var headerItem in headerArray.Value.EnumerateArray())
+ {
+ var row = new List();
+ foreach (var col in table.Columns)
+ {
+ var childContext = context.CreateChildScope();
+ childContext.SetVariable(table.Var ?? "item", headerItem);
+ var childEval = new ExpressionEvaluator(childContext);
+ row.Add(childEval.Evaluate(col.Text ?? ""));
+ }
+ headerRows.Add(row);
+ }
+ }
+ }
+ else if (columnDefs.Any(c => !string.IsNullOrEmpty(c.text)))
+ {
+ headerRows.Add(columnDefs.Select(c => c.text).ToList());
+ }
+
+ // Data rows
+ var dataRows = new List>();
+ if (!string.IsNullOrEmpty(table.Items))
+ {
+ var itemsArray = context.Resolve(table.Items);
+ if (itemsArray != null && itemsArray.Value.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var dataItem in itemsArray.Value.EnumerateArray())
+ {
+ var childContext = context.CreateChildScope();
+ childContext.SetVariable(table.Var ?? "item", dataItem);
+ var childEval = new ExpressionEvaluator(childContext);
+
+ var row = new List();
+ foreach (var child in table.Children)
+ {
+ if (child is LineNode lineChild)
+ {
+ row.Add(childEval.Evaluate(lineChild.Text ?? ""));
+ }
+ }
+
+ // If no line children, use column defs
+ if (row.Count == 0)
+ {
+ foreach (var col in table.Columns)
+ {
+ row.Add(childEval.Evaluate(col.Text ?? ""));
+ }
+ }
+
+ dataRows.Add(row);
+ }
+ }
+ }
+
+ var lines = _layout.FormatTable(columnDefs, headerRows, dataRows, _lineWidth);
+ foreach (var line in lines)
+ {
+ commands.Add(new PrintCommand(line));
+ }
+ }
+
+ private static PrintCommand CreateCommand(string text, bool bold, bool big, bool tall, bool red, int? lineSpacing)
+ {
+ return new PrintCommand(text, isBig: big, isBold: bold)
+ {
+ IsTall = tall,
+ IsRed = red,
+ SetLineSpacing = lineSpacing
+ };
+ }
+}
diff --git a/Inspectron.Epson.TemplateEngine/template_syntax.md b/Inspectron.Epson.TemplateEngine/template_syntax.md
new file mode 100644
index 0000000..ec77bde
--- /dev/null
+++ b/Inspectron.Epson.TemplateEngine/template_syntax.md
@@ -0,0 +1,642 @@
+# XML Receipt Template Syntax
+
+## Overview
+
+Templates are XML documents that combine static text, data binding expressions, and control flow to produce a list of `PrintCommand` objects for Epson thermal receipt printers.
+
+```csharp
+var engine = new ReceiptTemplateEngine();
+List commands = engine.Render(
+ xmlTemplate, // XML template string
+ jsonData, // JSON data string
+ lineWidth: 42, // characters per line (normal font)
+ bigFontLineWidth: 22 // characters per line (big font)
+);
+```
+
+---
+
+## Root Element
+
+### ``
+
+Required root element. All other elements must be nested inside it.
+
+```xml
+
+
+
+```
+
+---
+
+## Content Elements
+
+### ``
+
+Outputs a single line of text. The most common element.
+
+**Attributes:**
+
+| Attribute | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `align` | `left` / `center` / `right` | `left` | Text alignment within the line width |
+| `bold` | `true` / `false` | `false` | Bold text |
+| `big` | `true` / `false` | `false` | Double-width font (uses `bigFontLineWidth` for alignment) |
+| `tall` | `true` / `false` | `false` | Double-height font |
+| `red` | `true` / `false` | `false` | Red text (on supported printers) |
+| `lineSpacing` | int | _(none)_ | Override line spacing in dots |
+| `wrap` | `true` / `false` | `false` | Word-wrap text that exceeds line width |
+| `wrapIndent` | int | `0` | Indent (in characters) for continuation lines when wrapping |
+
+**Examples:**
+
+```xml
+
+Hello World
+
+
+
+
+
+RECEIPT
+
+
+{{Title}}
+
+
+---------
+
+
+{{dish.Number}}x {{dish.Name}}
+
+
+1x Very Long Dish Name That Will Wrap To Next Line
+
+
+
+Spaced out text
+```
+
+When `big="true"`, alignment uses `bigFontLineWidth` instead of `lineWidth`.
+
+---
+
+### ``
+
+Two-column layout: left-aligned left text, right-aligned right text. The line width is split in half.
+
+**Attributes:**
+
+| Attribute | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `left` | string | `""` | Left column content (supports `{{}}` expressions) |
+| `right` | string | `""` | Right column content (supports `{{}}` expressions) |
+| `bold` | `true` / `false` | `false` | Bold text |
+| `big` | `true` / `false` | `false` | Double-width font |
+| `tall` | `true` / `false` | `false` | Double-height font |
+| `red` | `true` / `false` | `false` | Red text |
+| `lineSpacing` | int | _(none)_ | Override line spacing |
+| `wrap` | `true` / `false` | `false` | Wrap left column if combined text exceeds line width |
+| `wrapIndent` | int | `0` | Indent for wrapped continuation lines |
+
+**Examples:**
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+```
+
+Without `wrap`, the left column is padded to half the line width and the right column is right-padded to fill the remaining space. With `wrap="true"`, if the combined text exceeds the line width, the left column wraps and the right column appears right-aligned on the first line.
+
+---
+
+### ``
+
+Multi-column layout with explicit column definitions. Each column is defined by a nested `` element.
+
+**Row attributes:**
+
+| Attribute | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `bold` | `true` / `false` | `false` | Bold text |
+| `big` | `true` / `false` | `false` | Double-width font |
+| `tall` | `true` / `false` | `false` | Double-height font |
+| `red` | `true` / `false` | `false` | Red text |
+| `lineSpacing` | int | _(none)_ | Override line spacing |
+
+**`` attributes:**
+
+| Attribute | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `width` | int | _(auto)_ | Column width in characters. Unspecified columns share remaining space equally. |
+| `align` | `left` / `center` / `right` | `left` | Text alignment within the column |
+
+**Examples:**
+
+```xml
+
+
+ MwSt %
+ Brutto
+ Netto
+ MwSt
+
+
+
+
+ {{tax.Category}}:{{tax.Rate}}%
+ {{tax.Gross:F2}} {{tax.Currency}}
+ {{tax.Net:F2}} {{tax.Currency}}
+ {{tax.TaxAmount:F2}} {{tax.Currency}}
+
+```
+
+In this example, the first three columns are 10 characters wide. The fourth column gets all remaining space (`lineWidth - 30`).
+
+---
+
+### ``
+
+Outputs a line of repeated characters spanning the full line width.
+
+**Attributes:**
+
+| Attribute | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `char` | single char | `-` | Character to repeat |
+
+**Examples:**
+
+```xml
+
+
+
+
+
+
+
+
+```
+
+Always uses `lineWidth` (not `bigFontLineWidth`).
+
+---
+
+### ``
+
+Triggers a paper cut. Produces a `PrintCommand` with `IsCut = true`.
+
+```xml
+
+```
+
+---
+
+### ``
+
+Outputs one or more empty lines.
+
+**Attributes:**
+
+| Attribute | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `lines` | int | `1` | Number of empty lines |
+
+**Examples:**
+
+```xml
+
+
+
+
+
+```
+
+---
+
+### ``
+
+Renders an ASCII box table with borders (`+`, `-`, `|`). Column definitions are provided via nested `` elements.
+
+**Attributes:**
+
+| Attribute | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `items` | string | _(none)_ | JSON array path for data rows |
+| `var` | string | `"item"` | Loop variable name for each data row |
+| `headerItems` | string | _(none)_ | JSON array path for header rows (optional) |
+
+If `headerItems` is not set, the text content of `` elements is used as the header row. Columns without text produce no header.
+
+**`` attributes:**
+
+| Attribute | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `width` | int | _(auto)_ | Column width in characters (excluding border characters) |
+| `align` | `left` / `center` / `right` | `left` | Cell content alignment |
+
+Column widths exclude border characters. With 3 columns, 4 border characters (`|`) are used, so the available content width is `lineWidth - 4`.
+
+**Examples:**
+
+```xml
+
+
+ Order:
+ QR Info
+ Date
+
+
+
+
+
+
+```
+
+---
+
+## Control Flow
+
+### ``
+
+Iterates over a JSON array. For each item, the child elements are rendered with the loop variable available in the data context.
+
+**Attributes (both required):**
+
+| Attribute | Type | Description |
+|-----------|------|-------------|
+| `items` | string | Path to the JSON array (supports dot notation for nested arrays) |
+| `var` | string | Variable name to bind each array element to |
+
+**Loop variables** (available inside ``):
+
+| Variable | Type | Description |
+|----------|------|-------------|
+| `{{$index}}` | int | Zero-based index of the current item |
+| `{{$first}}` | bool | `true` for the first item |
+| `{{$last}}` | bool | `true` for the last item |
+
+**Examples:**
+
+```xml
+
+
+ {{item.Name}}: {{item.Price:F2}}
+
+
+
+
+ {{gang.Id}}. {{gang.Name}}
+
+ {{dish.Number}}x {{dish.Name}}
+
+
+
+
+
+ {{$index}}: {{item.Name}}
+
+
+
+
+
+ {{gang.Name}}
+
+
+
+
+```
+
+If `items` resolves to `null`, an empty array, or a non-array value, the loop body is skipped entirely.
+
+---
+
+### `` / ``
+
+Conditionally renders child elements. An `` block is optional and must immediately follow its corresponding ``.
+
+**Attributes:**
+
+| Attribute | Type | Description |
+|-----------|------|-------------|
+| `test` | string | Condition expression (see Condition Expressions below) |
+
+**Examples:**
+
+```xml
+
+
+ {{SpecialInstruction}}
+
+
+
+
+ Discount: {{DiscountInfo.Description}}
+
+
+ No discount applied
+
+
+
+
+
+
+
+
+
+ Large order!
+
+
+
+
+ Active order
+
+
+
+
+ Modified
+
+
+
+
+ First item header
+
+```
+
+---
+
+## Data Binding
+
+### Expression Syntax `{{}}`
+
+Expressions are enclosed in double curly braces and can appear in text content, `left`/`right` attributes of ``, and `` text content.
+
+**Property access:**
+
+```xml
+
+{{Name}}
+
+
+{{Person.Address.City}}
+
+
+{{FirstName}} {{LastName}}
+
+
+Order #{{OrderNumber}} - Table {{TableNumber}}
+
+
+
+ {{item.Name}} - {{item.Price}}
+
+```
+
+Property lookup is **case-insensitive**. Both `{{name}}` and `{{Name}}` resolve the same JSON property.
+
+If a property is missing or `null`, the expression resolves to an empty string.
+
+### Format Strings
+
+Use a colon after the property name to apply a format string:
+
+```
+{{PropertyName:format}}
+```
+
+**DateTime formatting** (the JSON value must be an ISO 8601 date string):
+
+```xml
+{{DateTime:dd.MM.yyyy}}
+{{DateTime:HH:mm:ss}}
+{{DateTime:dd-MMM-yy HH:mm}}
+```
+
+**Number formatting** (standard .NET format strings):
+
+```xml
+{{Price:F2}}
+{{Amount:N0}}
+```
+
+### Loop Variables
+
+Available only inside `` blocks:
+
+```xml
+
+ {{$index}}: {{item.Name}}
+
+```
+
+| Variable | Type | Description |
+|----------|------|-------------|
+| `{{$index}}` | int | Zero-based index |
+| `{{$first}}` | bool | `true` on first iteration |
+| `{{$last}}` | bool | `true` on last iteration |
+
+`$first` and `$last` are primarily useful in `` conditions rather than text output.
+
+---
+
+## Condition Expressions
+
+Used in the `test` attribute of ``. The following forms are supported:
+
+### Truthiness
+
+A property path by itself checks whether the value is "truthy":
+
+```xml
+
+```
+
+| JSON value | Truthy? |
+|------------|---------|
+| `"hello"` | yes |
+| `""` | no |
+| `42` | yes |
+| `0` | no |
+| `true` | yes |
+| `false` | no |
+| `[1, 2]` | yes |
+| `[]` | no |
+| `null` | no |
+| _(missing)_ | no |
+| `{ ... }` | yes |
+
+### Negation
+
+Prefix with `!` to negate:
+
+```xml
+
+
+```
+
+### Comparisons
+
+Six comparison operators are supported. Operands can be property paths, numeric literals, or quoted string literals:
+
+```xml
+
+
+
+
+
+
+
+```
+
+If both operands parse as numbers, numeric comparison is used. Otherwise, case-insensitive string comparison is used.
+
+Comparisons can also be negated:
+
+```xml
+
+```
+
+### Loop Variables in Conditions
+
+```xml
+
+
+
+```
+
+---
+
+## Complete Example
+
+### Kitchen Receipt Template
+
+```xml
+
+ {{Title}}
+
+
+
+ {{TransactionDateTime:dd-MMM-yy HH:mm}} Nr.:{{ReceiptNumber}}
+ {{WaiterName}}
+ Tisch: {{TableNumber}}
+
+
+
+ {{SpecialInstruction}}
+
+
+
+
+
+
+ {{gang.Id}}. {{gang.Name}}
+
+ {{dish.Number}}x {{dish.Name}}
+
+ Comment: {{dish.Comment}}
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Sample JSON Data
+
+```json
+{
+ "Title": "Ristorante Bella",
+ "TransactionDateTime": "2024-03-15T14:30:00Z",
+ "ReceiptNumber": "42",
+ "WaiterName": "Max Mustermann",
+ "TableNumber": "5",
+ "SpecialInstruction": null,
+ "Gangs": [
+ {
+ "Id": 1,
+ "Name": "Vorspeise",
+ "Dishes": [
+ { "Number": 2, "Name": "Caesar Salad", "Comment": null }
+ ]
+ },
+ {
+ "Id": 2,
+ "Name": "Hauptgang",
+ "Dishes": [
+ { "Number": 1, "Name": "Wiener Schnitzel", "Comment": "Well done" }
+ ]
+ }
+ ]
+}
+```
+
+### Printed Output (42 char width)
+
+```
+ Ristorante Bella
+------------------------------------------
+
+ 15-Mar-24 14:30 Nr.:42
+ Max Mustermann
+ Tisch: 5
+------------------------------------------
+ 1. Vorspeise
+2x Caesar Salad
+
+------------------------------------------
+ 2. Hauptgang
+1x Wiener Schnitzel
+Comment: Well done
+------------------------------------------
+```
+
+---
+
+## Quick Reference
+
+| Element | Produces | Key Attributes |
+|---------|----------|----------------|
+| `` | _(root)_ | - |
+| `` | 1 PrintCommand (or N if wrapping) | `align`, `bold`, `big`, `tall`, `red`, `wrap`, `wrapIndent`, `lineSpacing` |
+| `` | 1 PrintCommand (or N if wrapping) | `left`, `right`, `bold`, `big`, `tall`, `red`, `wrap`, `wrapIndent`, `lineSpacing` |
+| `` | 1 PrintCommand | `bold`, `big`, `tall`, `red`, `lineSpacing` + nested `` |
+| `` | 1 PrintCommand | `char` |
+| `` | 1 PrintCommand (IsCut=true) | - |
+| `` | N PrintCommands (empty lines) | `lines` |
+| `` | N x children | `items` (required), `var` (required) |
+| `` | 0 or children | `test` (required) |
+| `` | 0 or children | _(must follow ``)_ |
+| `` | Multiple PrintCommands (bordered) | `items`, `var`, `headerItems` + nested `` |
diff --git a/Inspectron.Epson.slnx b/Inspectron.Epson.slnx
index 70e8924..d44e2eb 100644
--- a/Inspectron.Epson.slnx
+++ b/Inspectron.Epson.slnx
@@ -1,7 +1,10 @@
+
+
+
diff --git a/template_syntax.md b/template_syntax.md
deleted file mode 100644
index a4a82d3..0000000
--- a/template_syntax.md
+++ /dev/null
@@ -1,946 +0,0 @@
-# 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
-```
-
-### Row Styles
-
-Apply styles to an entire row by adding style names after `@row`:
-
-```
-@row bold
-|width|content|width|content
-@endrow
-```
-
-Multiple styles can be combined with commas:
-
-```
-@row bold, big
-|width|content|width|content
-@endrow
-```
-
-#### Available Row Styles
-
-| Style | Description |
-|-------|-------------|
-| `bold` | Bold text for entire row |
-| `big` | Double-width and double-height |
-| `tall` | Double-height only |
-| `red` | Red color (if printer supports) |
-| `spacing:N` | Set line spacing to N |
-
-#### Row Style Examples
-
-```
-@row bold
-|30|{Name}|10,right|{Price:F2}
-@endrow
-```
-
-```
-@row bold, big
-|15|TOTAL|9,right|{Total:F2}
-@endrow
-```
-
-```
-@row red, bold
-|40|*** WARNING ***
-@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 |
-| `@row style1, style2` | Start row with styles |
-| `@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 |