From ec5decb12ed67a2fd6f4d4650b165e8cb7ec2acb Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Tue, 3 Feb 2026 14:27:13 +0100 Subject: [PATCH] template engine --- .../EpsonTest.Templates.csproj | 27 + EpsonTest.Templates/Program.cs | 118 +++ .../Templates/FinalReceipt.xml | 115 +++ .../Templates/InvoiceReceipt.xml | 29 + .../Templates/KitchenReceipt.xml | 65 ++ EpsonTest.Templates/Templates/OrderItems.xml | 34 + EpsonTest.Templates/TestHelpers.cs | 579 +++++++++++ .../ristorante-klinglers.ch-logo.png | Bin 0 -> 12476 bytes .../ristorante-klinglers.ch-logo_white_bg.png | Bin 0 -> 13119 bytes .../ExpressionEvaluatorTests.cs | 290 ++++++ .../GlobalUsings.cs | 1 + ...spectron.Epson.TemplateEngine.Tests.csproj | 32 + .../KitchenReceiptTemplateTests.cs | 298 ++++++ .../LayoutEngineTests.cs | 154 +++ .../TemplateParserTests.cs | 294 ++++++ .../TemplateRendererTests.cs | 445 ++++++++ .../Templates/FinalReceipt.xml | 115 +++ .../Templates/KitchenReceipt.xml | 65 ++ .../Templates/OrderItems.xml | 34 + .../DataBinding/DataContext.cs | 123 +++ .../DataBinding/ExpressionEvaluator.cs | 218 ++++ Inspectron.Epson.TemplateEngine/Exceptions.cs | 13 + .../Inspectron.Epson.TemplateEngine.csproj | 13 + .../Parsing/TemplateNode.cs | 84 ++ .../Parsing/TemplateParser.cs | 250 +++++ .../ReceiptTemplateEngine.cs | 38 + .../Rendering/LayoutEngine.cs | 275 +++++ .../Rendering/TemplateRenderer.cs | 286 ++++++ .../template_syntax.md | 642 ++++++++++++ Inspectron.Epson.slnx | 3 + template_syntax.md | 946 ------------------ 31 files changed, 4640 insertions(+), 946 deletions(-) create mode 100644 EpsonTest.Templates/EpsonTest.Templates.csproj create mode 100644 EpsonTest.Templates/Program.cs create mode 100644 EpsonTest.Templates/Templates/FinalReceipt.xml create mode 100644 EpsonTest.Templates/Templates/InvoiceReceipt.xml create mode 100644 EpsonTest.Templates/Templates/KitchenReceipt.xml create mode 100644 EpsonTest.Templates/Templates/OrderItems.xml create mode 100644 EpsonTest.Templates/TestHelpers.cs create mode 100644 EpsonTest.Templates/ristorante-klinglers.ch-logo.png create mode 100644 EpsonTest.Templates/ristorante-klinglers.ch-logo_white_bg.png create mode 100644 Inspectron.Epson.TemplateEngine.Tests/ExpressionEvaluatorTests.cs create mode 100644 Inspectron.Epson.TemplateEngine.Tests/GlobalUsings.cs create mode 100644 Inspectron.Epson.TemplateEngine.Tests/Inspectron.Epson.TemplateEngine.Tests.csproj create mode 100644 Inspectron.Epson.TemplateEngine.Tests/KitchenReceiptTemplateTests.cs create mode 100644 Inspectron.Epson.TemplateEngine.Tests/LayoutEngineTests.cs create mode 100644 Inspectron.Epson.TemplateEngine.Tests/TemplateParserTests.cs create mode 100644 Inspectron.Epson.TemplateEngine.Tests/TemplateRendererTests.cs create mode 100644 Inspectron.Epson.TemplateEngine.Tests/Templates/FinalReceipt.xml create mode 100644 Inspectron.Epson.TemplateEngine.Tests/Templates/KitchenReceipt.xml create mode 100644 Inspectron.Epson.TemplateEngine.Tests/Templates/OrderItems.xml create mode 100644 Inspectron.Epson.TemplateEngine/DataBinding/DataContext.cs create mode 100644 Inspectron.Epson.TemplateEngine/DataBinding/ExpressionEvaluator.cs create mode 100644 Inspectron.Epson.TemplateEngine/Exceptions.cs create mode 100644 Inspectron.Epson.TemplateEngine/Inspectron.Epson.TemplateEngine.csproj create mode 100644 Inspectron.Epson.TemplateEngine/Parsing/TemplateNode.cs create mode 100644 Inspectron.Epson.TemplateEngine/Parsing/TemplateParser.cs create mode 100644 Inspectron.Epson.TemplateEngine/ReceiptTemplateEngine.cs create mode 100644 Inspectron.Epson.TemplateEngine/Rendering/LayoutEngine.cs create mode 100644 Inspectron.Epson.TemplateEngine/Rendering/TemplateRenderer.cs create mode 100644 Inspectron.Epson.TemplateEngine/template_syntax.md delete mode 100644 template_syntax.md 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 0000000000000000000000000000000000000000..3ff91e9852fc50afd160f2b461e5f190d373ceda GIT binary patch literal 12476 zcmZX5Wmua{uy%kT!QCnD4#l0~?oM$jP~3~VyL)k$7WY7l7hc>UxEFW(((n8`XLCiK z-RzUy+1Z_$`<_IrsmP)s6C(ov05o|yDGdMsh6VaL0|^28YH8Rq1AT*W(~y;fSsrBx zgkAt`B$OopfTl#07c+S1HHC$ohB5%)O9uc1g#iFh(5j$A0KkI-05~=U00grD00QT{ zPIX~u1A@7ttQ6q=-=nCfA{AN#a+cF~0|3~l|2<*6%fvjPm5A>0%F>8OsMsJN4yzw) zHS|XS@=_97-YaMRK3O!$)SxC23p2B( z8F70N)vl+Z{#mZSg*ESZ?Qheqkt{6M9btPu8~ez|)M2%(AyLR}8O4VLc3kwA%^{H~LZA%Apgtg6FP+q6c*tQu$pYH<3bqnojP2vF3)$N!nt2>83P z8yUzNIYxGNbdLD|3M#<2^w3uRLENG_HDJCyQin{=X)~E8Y31!6sYF~NESB#vH7G1{ zn!!%l_$^nLo%Q$h_l#QTL-}z^;$y z>-B%`UzOuiH{_iu4PtdFB3-|#S8{_L`PYwA+k#uzxVuHF0>2KU0-Huwfmrf zoo`!_6)t_xvGY|=!l@C40ZuvVG}&>X%(#ahJ~L>f&6^e8TdIn*z@{QIJUqQ|D3&Y1 zVP^XvvP({ae-`cQn{Sr`d>i?lj{ahBp(TvVVhZ!EDTt)1zV^mVen! z8yTD3q;__4N?bj%G&2L$;E`gI@NMN*RfYj+a%tL1)j|E<`>XxHp0|N?rWFg*eZAdk03=>54X$|!4U+wEt><4I8 z$U)db{wyrDVLVt4D{14tOPppt1#J;=IYKKb>5yWMggXQleSJN>ft<=9T4m75qej(@ z9lh1f{cS@n_=s;dx_Nca`LlrUqk%h0uKfYW2wrzKxu zeI?RaYdy8bp+RrLePAqAS>#%d9|p%xlQy#k3{{rM}DK6Y0IWM|{45 zrY!h+V-fP%{K9^f8-JXGS`{QR^$iC=pX>dg2B#0RX-i!%k}=TDM-;tM4#W{0UVJ?> zQKa6&@nljalkxs2pdu1T%OV@tC}P1`0vi9awT14ZJYde+ab&Yvneony+(n~4bNAU) zw9tg4U(_S94)`BDRPQ2+y8HfK&Nn7>;&bOWQmmEg|0ilS_X$9H_9RB>>^*KC^qjR* zZ^n2!!rU7LN>U;zljBvjU1w9d#K$qU7oM|8LyAYck~JF*ONfSg);EI6-eYw6g~ zfaLS_y~O>Kb@SpKY)ZxcSh8d$<_Sjr4ADLog+$4PON<{q;^+n=Q(5NZr*#KEbG2n1 zVJ`?z%mNp`Qu)?>YNcZaWQNN(F9HeRKmwf!H=m=&l3PqX%$~7KU557Es?LaGiP+8J z+o^K_$}D=nj_YWdjY#RXwz7&S+aNw&NDt+m!(wo1t(vxWmA7X@9=YHd=m38}2e?Da z^4&pjv`<(_X<(2eT9cl-`mMAnCG{rK^U2*z7@WxWSLgvR2y0acpSz$D;J|gMO<9q- zZOciA^+CqI$E7ESv41ag?`rexCYxAz$`Ih37=b1Ut`5boK{5mJ<}aP_*QH6(tF#K1 zyBhT%D||=en}z3hMwfooW(3DVjfL5_VJZ~v$p~q8B|X?=i$TU;KafLA>r3~GE)lpp zYSO~)SE^>?{|u9&FWj=ZFb2(eCj5@eY(x$pAjP!zgr0%2TuxhlrZB^^yG*C&&T3N>nHQ>tyPWJ4A5z zmW=XmmO{hkLyCHEk1v9yFO_vElvmkoNqDfEn;88oI}2G5)S7yCGZsXICyCR1usOlX zMpM>h7*EnvgQ5E5SHM9VWlc#jjo?obkC>tj_2%81+dP{JB?6kgt?5rl-nYi}8n-C_ zq-a3Pa)ZZP$#5+Md(ylBcNxtPJ$WXRFCprUNU<8X`SLgm27@VutU4Qsg2>0ceZ5@T zANc-t)EO8%xV9&u@9ALG2Yo==xmf#q8$+sT0?#bGs*+h%vU%MKyv!KlEaho6oG=1G zkE!`|@E^dcO&Mh;zRaS>dP~sd9Uj&TFDc_Pg}<>lvWM+Z$0!o1k>j^va~TD_*Vk9L zkKHi4BtmnXA5K4n)~J?DW+#8VBUZ`m%nW(uM!0z5Ks9Mvhbj$Ct$^D;u1ut?6W!%P zf|w)|hI`f1halOw14&_mq09JGN@H$wUhL#<_5Dl!1e2VzR95FuV+sAie|ixX)&jU- z8l+ytT4{h~N0w~?96l<3Gr_TpD#w^e;t1$(p= z%>Rscl^c4%8>H!;WRs4~meK1;u4w=~OoHlQw!zgDbLsHf zC!Fe3>l3My^K){*f6mj{{d;QaU5GOfR>XG|m?tXszRPN|0{U^fi0>O+Gn>I$m#sr zP$I8G2Q?u~7dO9wVoUoyTXa)hl6nGH6m!crVeJkn#Rlbn6j9j=T2?5C=OqB|ddbN| z%@ynbpMj<4Vwz`(0zz*vWyS{`(OTz{uDhGI;Dw_RX=jq?e6D4cnR?8Opl=IgZ6t7R zXO?5gI~Tbtvo&xJ3@{iW1n~SkERkM+hU9e)nY$%STb)RCJAkQC>I-PVg8Kjf;>W;2 z2NcK)(b9Skbkee05ts}B_5tvE%^2e;GA+y5v8sC#-$j6|fC*2EHI`0U7EPj32bsZC z3AojRXA^JuGB%aPl|OE93h?0YU5x=Z_IKG&AKi}$0$D_6b<$=7*axuvDA#X@Rqbff z$bdJD;(F$EMI428Cbh2C4p}E_JwE2MqbYS~(o%e;yas=H0q4+H1^F*SHVh_aVcM%( zlMW(PyF{Os^9PJ`yD#`VF?I8Yy2;g4*4@u!v?APFQ?}W@jwGlUSl8;Ij_CeP=JmkKk{<-srJN!Yn zf;zoyJF5atH4|2MbHDR%PAcR0bvG*@pt7ZWK%2^J%r&#Id7*|Vlo|BE`k4f4tDwO+ zl9CG)dpZfC1PaBiX9J@Y<*zdFco=%m8&n3*q<^VvO{J&I6zU|!%otvDlcCM6g7i^> zur3NecK2wpt*^@GqRzb$<@WMdx%nK#8Wu_w)nVm@7z6S)zI3Q#=a-4}i2QWW|MiWm zauWj;)j@zPa|7fkAoImd{4@)vg|MG7H3xilOzqSZ#zPd%_FLL=b$p@_G`_WN5n-{z zqMn)63HGNbG@-AHHCGrZ6x3!d52az~PW&ZE1B29D@xC#;_;Y!tOfh!63QZ*VfDBH3 z7}Q8NOelo}P$)9?7aJvykz)_abklg$jDTa8_?y|(`InsdgKzoHB~0(qa&@{l75Vb! zVqdRBrx%Kb&nK(WtE%WE(_vKVD~i#U6Q(sYF00=zF9c_g_n2#~@>Yrp^4{6Bwb3he zM(8wB&y;USj%XCJ=FH-ghSo9)**Is%u~29K zrp0L#(Fu`;`RMC#=Den0A>hBof2vKpysq`v>GBfEv-N-Tz##h;K*KuRV<Lsaai zxs8P{IrjqC*GY-{mO3X&wT3B(~cdcLM$k8!86)DtAE$>8+sPhy49K~PmiH8UQEF|R_UP8 zs{scG_j)8(X58KSI_7v)PJwpE7N1s-Qx(=?qyi$lU`eWJX)VOBJ3Jv zXm|p7%uXPV(qvvinI|AAugb`vZK-XNBIsE#-5?J)*Q@g45P9Xll%kEbQ^BV4$dSYHD|!GQ4hF; zG4BVxtgwT)s&{|Dy@STUI@Bw3Yrv{mxaQh!g-ePQYUO0CUoEJi;Znf=9{MTepV}X5 zIRfkx(k;rCPE~w5JSA7PwIZ0$KV?wYLE3OOXhi%K7P63>nWTCRoDwJm0Ao(DQ}5GI zzciz9$n@`8M=;WR*B|?LEs^T*HX*(--yFfinAKqSDvaiuK7@p}t*DWimCHcpu4x1G zIUXWjN+ir@d2hEr8EwH>ywojCQlE3;6eOH;Wwv4niae>*8F2TJ=B9E|Vj(G=PA31A z`m?*U2oip=VZfL6qlzi+YkeJpA_{q~jy5}f#Q1Tl{9M*QyX^%X^k_WCv!d9g_M*#@ zppHate-+XC)eZ-GpOHfGd)U?;tj~|F@qShGZ+m@*-2oM}wZt;1TkHV6nm>~PmWoan zEXL#vvg$Y9UP>Tajy1)GT{?c3J4zQnnIl&Jbf{086xvDDf0A3-!`BD=C4o7Cm;PDN zRmBjtTxrQ>Brf4^4i5p+b>^aXc}hWSXS0n(B(a@e)Xqm`MPGYJ6b6TXl-YY@k|rRT(=juB`*6hS zU6U#EuZzWbT~JqAMs;QJHsSxuXf%iUiX zwP>d7gLmmPL(-W{GQ8ONx$1Tb3szy8jbU;+^$XbuA0NHj27n@Q?xj<{-F8%O59d<4 z`20yW=Hn=$ZJtEYe!`4o9RZkCJs;d<#MT#AJF8w@FiDFwJMczt6YlcRa@Q*1#OE^6 z16-_01t6NW+jAy3Q89l>{o^2L1mnAuR3Sde_WM$rtf8)Z11xSFj^Ksy=)s7NQFVS# ztB0KqjULQA;V%4MktrSp&K|vN>vVHl!hUPLg_@F(WwR!3Di*Yv(P2fopY0CG&u%WV z`vrH`q^hEJL)@UT=?Jgnr<_U~?H?L#tyDGDz0_Wa1-%sGglr+Pl<}4*F`J`a_un=$ zf)6(9N62~Cjq!LF3PfRd305)3X^9eXkp4P2v^gxuKMPNNV=kKfGLUqTwtZOX>1O!t zNlM(R{Tmn7m@JD8e$T`UF$TSrO zZZ)&oh{tluXawstg|g3|a|tU+e)&kf#a(t;9tV75J;+ zCwX>=jt9!66rA>XR`wjueirEFGEUk(w=nD*3?ZFX&nHGCyUkm%?3SpOiol|NdienN zwI!4f*vUJ@H3vg=Bh@lvi``GhHQ6ba&|^>HLd`fkeC%Q*9Oak>Sa)j|d=f}^aiaT-83?ld;si2!UqdaVo=rU`vu%~5+uVn^@kD<)o%9H1=um^P zmuMHN<{VuY#H%O`N{y9w*MY!C!d(5lEVM(1>*z!hUB+w?zi?J?YlPyo(abAX)sO?I zN|0J4Y(ZCd(%FL$KbadKie`=uJg2-YctTUCehu^`jNAmO)!%{_Ibb5qu}+ET%w}in z-g@6|H7i_EzMdc&5qSa>}xuM|P5entV!4hN|!m`J|{B>cri z&6-mQDv{zWn){ZAG|{tTv81IR8w<3^&r<3)UA4EGhs?E3Y0J33%7MHDBfJ(4TIsmf zx%>(5UNLu&`J^%97PQ^XP4)+etS}p!n@8WOY8Hft57(h;zF~SB4#eAw5)4q{07vJ*phF~5d z0X7m_^cE!ocDnCgXTOqIZ?-al{5(nnZZM@N0CvyUXTx1HCBXG>4bng%MLcBTbId3~ zveSt#T2e6->?7G@ZO7~^&%J%1aB^Bp5~O|Pre?a1*S=cU0U86cuW_@`b6)zlAKb3l z;mt>dT`PbMaX5Dgg8_64;wM}XoSg=EXq>P7FFYk{keYBXBPrUy$`A{$SnGbyYvKOLCiNxN4k07oh|G@%ovL&_14PmliK`600TKAo} zjT#(v#;Awk);=+yzyu9c06KbM-KZ$q z^ie8PmiB^!+t{d7no-59ZW6iAU2pGJ zg00AR=&2Xgc$Y5MoSWLlaWXt?`ZMhkahW^`#njn*ugs>Ze_tdMox$*}~A zVKyiu4MA4{cT_917MlS+#$M z2f4g!^Vf73a9_-umQorSewg{2ihz;T5p7tZ4lq&L8;gg$?_4~?s;I%JI-~YAm2gwF zpW34K092LelNSh_XFvKYu7+^Yu*B#M7}Rqpe;TzEFfq`Z5C)+T)~!=H*|BJa_`|F% z$g~+P5o)d}=#As;PG*0C``p%@^WWyC7zGt65z%zdjL>8o!=JJOi=oIV-tG2nxp9ae zlQyM3QC$F)^o-Gb`HJqX05g7UXjV2NYt36)UVM`d z5|xdJmFgR8CS4~;xoftdv>5ta=yIyenR(h{q=M#DR4Re2kyV=guibQPU8z0LW!Py+ z6h@J*Tp?8tteu9h6d~pah&G4=Q2{0hPC~323;5I&a%G)hwjbF;JG zRSXS)NiqX~1PpC?)B+|k(I!*M0>p{5Y3#mRD@|$4T15OH{m81rnvMxjgKjH(T_^eN zMmnXA+bAKwwuTqD5(Zz(?y*XIYhZfz(J=)|_(1>Rp>6N*O*>x2%sC;w6Y0(@2WGNp zO1PlpsOB&DWehp;Faw61fo?Tq+)mihMYbGGCIk^C#9#>?A!6~W@>#5_mWhyg79o#! zvebYI--T(OEjqkpO3YA9^kfa(A|$iqG)klxNpwjLshnSgLkTIA7FxwwLmVQZC3ekg zv$HttCfcm}(%2UI1IL6=o5|w*Iz3SdH{a6Kfh|ht{p4z~%e3!pPZ-glGzSs==bq%0 zHyF=y4Ds7(Sw(PE@u$D#$qm|(+CQsN^$cl|nO5-wU_dV66L8K98kH?#K@{{wI{77%e5g(ADKfkhQ55)ixvQn>#6SvCHg!6`oYHB6Xd z7c+<|9#^`FK_f7n{YCGvqI!=R_u6T_508Y$C+KZvN*Nk8#^qSDYp{={J?ClwLL z^}C?iM()Y7G5vTKie3q*BL)fM{=$#P&JYHP&ku_=d?n@!lW)%@qm0!qXv{D(H7)An z(pZj6!myUY%VOl9;*u(&l8O$&f}z&HMj9`YsvKfw3XWsIM(NOshTi|TT$Oc2bR3rp z+aI*Dj^@%5h8(&hX*^*k8yGQ=^S!U8cshj(V_avY7Tc@(w>KQLrmkxC51LS=TeY&_ zv`4Dx4S@P*rBbcfmA4F6zA%fFO^WN2Z%k1YjD*zm^yPfN}FL7l1-F zjCxCxsI&0>C*$R6K=jz0q)W%ws?FMhdRO`uK{oymNbem=OOofO)2RvfPlAaW7 zQYi3EPQK5WT%s9DKay0=3NF!S@?^@30|(K7OMYL)o1Jc=DhToA3vJ!bp=Z_q}&F^0X;mvgLr7G zX}sW1z7QXwd<6TE?RO?pQC@Bh$dd`SX-&p1)zS77AX!rfPe(9$>LZ;B&u*dVG=9NB zk9OCS%j^$!5T>np$=IEv%~-GjYB~JQ_;-x00@z!_ zv!??q`D^Gwp?U?Upr!L!M)%tr8E_K5jDSiE?vW>E4FK*6oBRhY&Mf&QJj=TBCFLdr zZY<$1dMwTryvt&}kq)5At+Dl66$z_eF}O@n_nxpjey{1q`qf1eP0|4OC;+X?FOMP^ zsiN5U1hJgD#&^F!%ncH5(tZYB@k+K%+IkxoZjk{zN4!7le%M5Jr4RZ1Gmy&PF5Sd^ zW%ixO+hFL08%mJeeQbnBuJ20syuI~!)p460wpn=Bt*TuLR_WJnQsL2yAs|M!xjsEL%hfR|=vX%LJFSP@-;FEU`pQ+pQ zL>Q}Xj12JtVrXe@PUHO29H0q#?86njx3athQM&rFZT{ql8*K2JiG-i}X0rgQ7cYj! zxSY{+ItniCwNWg1TZnXYc02Ka8FOr&BxN2>+ja(};=!(F`2d7p_+f$!%prE9!|dVm z?esv5%HuOMFRDF^jPWU$7ueBYcT3Bz1!j!|#ES+fNP_}VfJw+peEh09FKsDD5lHW% z59M;dY3E-vlbfmYR%^1Jh99up{=S7W5`%zyvYKrqnvfB`!%7cDdG~Fpzn_UKt=Ul8 zRKPL1bPuYM3k6FblWYL|&`|F`cl(nKwnvxXHBAMD#b)m>QbFv&dh1iqb02zj(Hn~aVwbHL^ejy-SFM_CCi@$t0wDZ{^YYK~066-Cb7NC>O zL$q!PA(oqhOq>T6cuF%1Cxzy5arX{+nRUa@#~I3@4y#aw#wY)y*Ms) z=uuHO6NU!_8$r(zWLb+hOY-RurcXbi zr$$0}G)2L>CIk4uyX{N8wbK#xQeSkxQXB)sMN!OEFx*Cb-ZlXR=R<$J$^&i@uWO!Z zOlo_TkjVfW6YUt%6DX zisFHKv~@Hu9+0PZ=_}mL+w=I8!-+4@#2duDoYOxTfpCrBYF4v&^PQ((+Z{%ZI>XKl zD&7S!rdTA>)e=dC7^xQ(haBrZDE`h3blB&i`J~FszsF%!O?2Leal27zBXM^uxl&v^ zaEA$C+c7KYtm+znv}i10^k?F*2Z)(FBr;l7k7_fiSGxk#4+AELjE4S`lo}lO|_9LQ>*J6Jyab@1?v{M^znxE6@O3J6byC@@FDk-FOU~}) z)v4B$gjuX3dDpk;V}fF&V;5kSAK2hq8M9P#g3)S0&|JX?Zo}?$21@&HwFr4{Akd4j zk~ze$@k}Z=O>^=Lea-_)f97;_k&tRw)XP@9ywctCU`2m%-h(1LjyfJIvnGJ{#Hc(h zDB+o(mP<2=BI(lyu$Q;1H%ZE0D8MCUPO8YRM$37{0fz%c^QI0=Hjli*%^uo|PgPA7 zF>@P`Pb|t(8j)(b9T!MEHd(f-Kc5{pfSiqFl4{JN@-TJSKX{1*<(pd&KM~hwNj8;q z_)UqXUmY43Jt{+;29t5fOm@GFAGS=r2E>2mmR%jWO?+1y?$#ISjuARc515%(Vm*7Xq%^Vz8u%0QUc3;1AJ{wsL%bPJcS zKf9CPQjpe@)c2Y&sJlsj{)>TCf2E|&Q@f)J@U+dty-Eu{C857;CKW~9y*??0-0Am~ zkyVBQnW?N9?ad;F&5vfvenTNAj53uy{M`v@xQ+hF(9lo|Zl*FSjbb}3?P7+`mj86U zYxwv3#82r51~bE%aYuH7p8N zTuI^g>}k?CM!l7gkNBUQeWAEw`cP6q5IOusVgXH{C}vAq22?uOzaj|$6$$aj5^^-+ zvr5{~3`Cxb8#<~}r%mIyk-KkHjLAs86s}UU@oEfj>$5Vxa zG;4O+^vqM&8^uNS^_gt;{?0gV!lr_nc3)Hr#UUj6Qba@31L>u?PY73!JTheTY6`FR z0;Pl}XHf2^CZ*y2=p>3NB|WL;Sp%KCI$oTvcDz{m5gJg(#hx zH-!JLO>JZp|-0OuE)-NvMBZePK`};gvy;9>cjuC@fZ5rO8M$B8f=;lMPF`^E^?O(!{jxK7A&Qj93Rsn^Bd>bm^U?c*xCxvw=nggY!cNEzUxzj?{1BSd zp{CY1O2r(N_p^Hx8IUy=tZxYqMlr+wY$N+;0DFN&S}>&`c@GyE914ZBt>HlnH=s5>X z`1~W;Uf+cuU6*rnqs?5Yj~+(P^Lqu z&>>3mtWqD^SjI8XX7eWAfjFCVRt~3@OwUI-ZP=MV{R^7hxS$jQiB~RYz&v8Q2pJT2 z$XKT?#4up{KdE-yu7EdM&@H)Q4e>oQ(u zSa3htdW-&W z*p&m8Y&$ALd;$Z53hj#P-)NKun9msgb=6zQzqfLOZ_D@marLTP>FkK}!}nvM_wdSP zlOB8+Y%E*KkRPodO#IP78P(CsY&xkYG-HBKyQ3>9*+xz3vsHB~E0mFLrnWSrWrfRdN?j_4O9#buLjvS6wQsv ziyj3!!PNUz~Y z)1jlo9hV$h;_9AGh83#~T8Q95bB5Y>^tn$o2Xn8MQjO=hswY|ypJABelaY%SY`IE` zZv;CJ)kV{n^(LC{i+07`()2NRn=YYI)9|K;B4g zYNjn2o4_e3_~c+xe=hO;RHD&qLRA1e*4M2EEN8W=;+`a7Bf9@Qrb!=@qPX;{LQ$&T zLgfr!dYZfSp0zg2ic??So*TK|4!iilgyV{J^s*RAPfaFOE~w_;n8x@a?7q-R@gQA2 zaG*~O#zc2m{o7yK1AY_teE;N1w@ zud05Zfo)8*e0Nfho82M0lB==*Zli;11Pvoo@?-})01OHUWEL6Ky}cC(h=`F61kjx0 f|5IvT9^SdT$N4#h<7%LP4*2I&^0ySux)q+6t0LPEN`Tctxfq@+u_@8LeK3oPG$dt%Pa z8$*sFJMe$ zKS)4cUjOBFmL!2!;GLwkT_F%=%GdvIyo!W9z>BbMGV+qJ`$!n@w6IaApPRskKx8CD z)x4IDbG-Fp4_{skC9S1?YQRpBQzR57QLAhyr8@CmNB>gT$ootMyZAHHpLUqpR(k1Z zNh#{r^*4224te?2ZsGA}A^98s3Av2x!#l4_qpHKUwAK?_gPX>V>Kon91#}3|P(ezt zUue}u$j~B<(BYt=&>5kECY7w^%JOVvPdKt3t-|JT@|z4@;(J{m;g^y@Woc$ykf49aqr_>ICumLrh3HUtgb* zU_XJOaG)_$z3IHd!s4Q+!PnR!_)r)IEpQKWImf92$rQt^0=ktpcf^?zIZR!L_Fa^P zuT3N(6|&f6U>X*5$EB*L8RiWs%Dj%8A4d5`jj%P5;u9IbUZ!Lf>kk zp@E~PukU<6@UC`e+$(o*q1IGJmJ+P}(+4#-PSZB!?k@G|{*SV<{os11r(ayPJIs1r zaI&(p)4$-LDYRbtPlHHJ+wRa@GYk($(pb_te7V1d3K%o>Z@4&J-E$!k@qTGwg$jb3 zd-*XsYS!p|P`0XGJRcnw7j_kk?}hvHxiL1gji5@eJ$kLvH)&3i>_yro=x~J*gQGX6>)IDfP4)~)w$N|i8aM3F)tV#4 zAvteu%haugi;xL2dUOTc{Sp#TRejjMSZ{UxP@tt9+tAaE%Zvt=Hqt1H_OCvY>3z*c z_c!aAVqtd&Qc_a6JJ-4e_M~Ekp}2aDUW6eYTCUo zF_*2eo}u~v{reXU2{JWIcb{qf6$Iml>thDLuD(98qvdAQlk)jUrrjnF9JBVxrpak6*v~%GAr9 z{%%{>?kME)ht!%5Cqgj5;j4&N)zd=~7XB+38t#uWvIDqQ2XuTmG4Awm|c-e_iK z_DB2(cBq4SDNz$HDKW9e=o#vEHLyn~ynpA)l_#qe-(Bi<#^ZtYy) zR+wM<-1Pmn)tvhF0d;O;FOgbyvh^DdUxRJ@)!}?nDivj@7`cFe0ISce6{B7Yn&-(1 zd5YNg2k|~IX7=Lp((-)ua*bGTWo6~`%U!BkWqTN8?%oZ`h{5Y=6B82&fjD|Px-Z~1 z6qJ;;=39TKC*Hn&i}&7zsuWLb^~AW-{~?vle^iS>im+6(+Mw4YG)$|Oz?_SKRxu}; zx3I%Q-Ys!$U?8-C3g7v>?`^__hK5F=3Y@!c4H%ZgYRk{``EpGb9UUblG%4-R5*jeR zv3);hihD*z5DyLxOsaE|lO+)O^2^I*brNHJ#&+yCA|2Vpa!4_rZ_ocLeODUmy`$eD zy*2U+gd4Zr0>s?hJZk>>>I(Z#z;`X%Cdz*St;P4I>c=j5f4G>qc!(NOzQaw;KqXqL z{U08Ol`nJ}F=@OeGsW^MQR)f`C{6aOG>P1Pw@%a1W+E0AG~D(pREgZ?2UvCGpOkLi z9xgXKR(N+m{zIgT?z_9#Wivf+tKHRZdSq#7Z4K(*kak}Mp%60~xPc5G4F@NDSMh69A;{L4@pz$aELvem&YCv(-1g2IU;mDTjyHcr>O z#Xq#Wh}FL2;T9|A8W`6`$HzyAvP|t<1+wUq!NjqDDs_O(%IMn-M$iKnUR(dQp=TgCA+7^uF<~Pb z2@MXK*XxXo$-gfYzR(5Ey*nZ$Cnqk2L36X~^vMRD;ge9e$!s;t;xY{-1qHFsDt-KE&x4c7oeNUYcM<9nt`RZFJJqs1gO zlzaz-R1vaI5fQMTN#E0`d&>wdF!asPDq0Q2Qbm^8(J?bytn76-Z9tX!C!!N@H#(q{ zgOfl)LbA|q8$Ubz!{%K_OAFqI_^9aZ!qOy$oirITG%`U!VsLyabsC}4M(oexn1qB# znjKdPin5SMzbF9)iKzp9XOSk*+0USIj&p1tvOj2i0A;xqpkCXY`d z9Dt;sbFw#A#Ne~HS1?meTs!A)U&P}uk1G}|Ze{%x`yj5XoArnR?=D*t9Uc8=XXkCa zJ0ZPWc1CN)=t=MTy5Tklh?EV-ZuMIM)DvMe_{84b^IF#jl$8$e`d)6%Adyf1!FE{f zqoAO0oqSiM!=hkfLKY5q{G>!jRR(I>Kc%Cyjef1CX4|m%l$0HBP_I0^PcUR$wXZyj zQkSr^Sljg-eqi2!JsfP`^K7`xT}?QvKR!8GZO3CbgKG#l4pqn`X7ib)yS=|J_}m&r z8LY0agcYVrtJv#(_px@Tp`8tt(5$guSU`X%YP~C_Fe5Wl<3P_&xG(e3d5#m6L?Ew7 zKAp2xN{}P_y(>%-t%8`u-iw}tFuZrL4Cb?hnHiNbEQOYv!&(P?$>3*`k&3%vVp0=f z*1wJR%NjH{=vY`(|Fjz=FFxL^$;rz*9~@clT|KjWKcp+4^1{L*oB2K3CINy{!1JiE z=;dxVm#gXbJ7#8-7+gk-EJNIP^z?mcEQYU{YjRSlBk+a9PpEsXp@<+y01g>B1uuHU zdJ#h|n-~3X?)Lix|k~4DMZ0aRkPXx94~~4n)vU zZ<3Rf2`+Wn7#U;#sv<#RV`J(4nDdn)7Z$Yq9&gb*1W44f$_p$fzG9=a++QE-*50|> zPtZv!!=a!Ib#?k$J0rWfxjT(7w;M1z%%&R+4h|YG&03p`$mOshGcz;ao&~jfat+zU z%F);f``wDfP%$Cm{s2H6*`NYq(XEMy!xWmCsS`GSak;NrVc1MUF527BA^339H#8(= z6Zo`48JhM%U%&I$p(GjFr-@y0ZSA{~<@1gHh+30Hm!S^NsZ%sOJS`UJa;fl8kW6dp z>%WcRrASa8}eCI2Q8=}zmi^YN(`v$oFR_oTsuVfO*>H@l!HoVb+BG8LK0=s^OcEdAdAdf<$43wq6~)zLqOPVE8X5|H`|z;!YiFdz z=ZYTczyHpUmw8H0-+^699olf21lXYTl#+}Lg7|klP3vSZ1{eE2!2jXe+}-oI%Lh4? z#SkS(M2Xt{;b?zm+StYBBmG>pegthispew+>|xOd=yLY0Bgzk-rB_D%9tWHg;19rl z54jw32CS(&gRJXtzk()HUsqS=M8~QfJ+LVmGc`X6_Y`-ZubWooRO2ZkDk|EzD(>w3 zx>`o2_91IN_jo)Oj24UIeR8(CvH0%^R@)DZ#xl7gBO^tutmpt*k2pHAD~$+o-JJ+2 zD=V9w*sV4nDWun(Iu_K{)?TRh<+1I$$#9MJB{dI14(V3$ z2nex)@z@L+J-xken8%r0+z;mz5p@BenXAwaNuXr37VGLWr(Swqmo>Q=*T)l>g#H^c zHVLM>l+*qP0A-4u?!cGNF}p02RcvNI(Dr>PCcf5Iq0JP-T{3V>I-PGyM(qaRVyaS4 zWLTGLNd8xmY(Ad-e(#K-KI+Gx-r_47JDo=Pw!r&Kcvs5_blsuBE5}%3{?uz6GaI#U z6;dN+fd)D{nQ~#Ql$8I$!^1;983x?FB_JSBspiGT#@>*4-#iL2vbSe)`TSZb`F&9k zcUjQh?KV0JGwZj8qT}GCwB2-c2r%lmCDX7Eg~U3aO_IAEEuedNc+}K3^^WWo^s@-X z$M5Y~B{Y2hPB(NYeTdoJ(vn^)l)h)VVz@0y8};Ld^kbuh%Y=MYw9k^<@rk7YHvmNq z@(1sTKNXPC>TO=)aOthM!r_f1VDT+lZ|+?`JD}c@-U+fP=^0ay>B- z2p#np7P;`!l*V^haL`)a0i6r%3NggBOTV!*GBPA;sGLt$-UD($)-*aKPncg(At$S- zpkTpRy{gVF+aQ-zMo&+l!Z#19w{=#@vh>|nY)nl5W9x<0ehe|OWI!Yc@pSv)XVZuK zE8C)zCordlmbH-7F4tWJ^q^L^bBe93?EalRPsgb*7XUKnrErj%FuTXAE{$zo;Et#H z;EDpaoXV`-*CHPo1**`<;|&=a+}R(J)m2aP-3)~sfv7{xDj7ykqn9^wS$uK7s1B^0crS)?FkXua|tlZmMFDdb*f59xs&(F`X6WjOlq;-BzWurx7D7sCU>15&cah{x2jq*`@IloUOq`L8F8pa ziE6sKx^5gD$z@?eJ#$pkoe!pDWWW!DDjD%xWQSV|5gLj}$SwWZ?)moH^+I4rDe7N&ArOhNpakNNa%jggqxqg zW5jqCZ~RHG)xcguxIS6sUszh0o{rDu3nCW}H@ZFBfD0gk%F-?{zZDQVva>XP)lyCp z=4E?*ZqLXa^e)hKEmF$MwQQY=Bg(&(tL}T#SO@&U#;#3I$6FZ7mXswGGHF?WnE;sX zzZXaJ)mU17Qcr5C zEBpTAsJ4ItitOFqaF{G^5rLFBAjH+(&wY!kmR6nhvS}>!$dw)0gLcVhWpoGToSt>1C*zbhvB!YQ7BQBY98>f9$W#M~90zNQ{E zaZd@0TAvC~IVvEa9>+_Ad(3Tq|3NElq|D&57}Pi<&}Z=b?(NcgXbC$$3oM25MD*t) ze}Lpk(>p326XQ;Ara3tB>a``X0>76}mQu$9BVS4%cip$Ezs7sWaNLGTpgH!Sc za2;&+LO@a&Dk>|j%XgPO9U>|#nT{Xm=;(|9koUTO#2=E!W8o`FONe?Gs4z4XM|e0u zvrms%(bFRm+J%IzYjH4Jim@ImCpHPVea5Qg@MmIc;YT3w$hzA}JHb^*#T)A@{H=+E zjUVpM3hJkNa`%d_mbKpz8dZ?n_kJps#m&4sC$*Mevtfw1R>L&Z)XLY_*BJ5Y8}zEU&z(K#T_diz z)L01b9335XzGjrZ1K?u7=d`u8br#U{(rbca$#t4k~{StbMDkWur!e z!>hgmN}A4{aesJiY6)F5;0Uw*5vZj?FfbMK0^a`$7j`oe5=8CSIx@}|i1|H~{Dg8n zMj!7ktj*JywRNzCQ9sgQsr>x;Q_aA@AndcOynNA+PF&II?!IctpwPuXAi&y87s{wK zFD^->z~N*~CO`Q;TvrZU01C)X@~|_4o3VD5po^=i^a_d|_RXgM$)`{kxeH91hc$LTmHa)m7)(G5F& zQrJ1Tic$<)P2OH@|HLdk%|BW{-x?l#X4S0H66OEIRX4eQ z>y|6m*4D<(Bf`Vy$Hv835AS)*7m*11NMPgQa;(`pIk9dv0y&6&hqT`XhWyVvemfX^5$)O`P|O(2Aba__7d8or!h^A-l#~iN zIr(f~7^3wsvtd{EM+JpqFqT4$h?x?FnS*1Xd^NcEBO!eUcu2L{ptAtr$ajj0ic-cL z+}y<^LVj94oRX=*0nX(2WIpEg_4PgYw=0Quw^@Pj*!zy<;iP?eNlw^&*lZp$4+VRB3i;?>iGXH+MNrl#gnZ7NRcEYO(; zuK${tnwA1#M;XXKC4WaVRDJ9;kE_&FRHhZ1v*K3+~PFJ~~n zTy|7r=`=|0>=bl=xVBG7BVOi+i;lL@3g)uermKy61awd(C!$o>P<_=cy5M_cBwW;u z!9O0?zjE}ftY0?Iy(o48;7c7ao-LF~`VIIPx;x| z6EnHpGiNC{>=sxLKo(a6b$hF3>+e|BEXX~j0MaM}ik%Et`Js2HSiYM07eriUrOrzq z8UOgL;kc4V5&^o(_3dplo5ebFY>b)ru78yh5)xEyME?Ez_Z^v#oAr8cu1a4PoRZk3 zPFtQvKumy!5LHX7sq%bVyTyKJoRS-m&Tfuyb#+b^(p+9MJY z60fN$R5Zxfdk6Lih~OK)%Qd}lFHo3t8zq3M4AjIJA^-b4 z-DZad>Z9ObC~yfN?%lK1Gr&MFwy=o~3V~refkM;vXQ1!P;&D9NymP(1U&CC~&MU5h z;c#_L&&U!rG$iHYm(^DqkFSZ+|}@)GMpyCZ@`On3$MzfV@^ZeQ`gak{WY2aGk^pzdT~n(9rCG zO$y_AQo#xX`5K_~5A=u5=o~mP8|gx(Ffb@YbR0Jly}iA7=4^GB^{-Mkt1;9TcOmdk z@^h69hr;MP*E(wgo_x|XGb#A_2^lo23M;drUcAr!&;NzZu5p$TW*}++tqJB5yIY>( zK)*pk_6lU?2FDW7@<-o*8#bdJ2-*N876LgMoQN`3i=IQ#mcXo*aS*+WK@*sUb!Y5D&hyg(s=by z?ypYczxoK~S60R`>$l~}rZHR16vvGs%UA1l#ZK0+(lIcY{z`sl5_0e3!pNF3PAG9^Yf$yZg|A)a{r3UDp#(dI#RO`v4r5O6KC544~vL)jw82rIU zk-$_@&Rxe_UkC~c;v!M4p)r7NyyAz2hBi9MZZ#3St7BUj#X6j;7S9#(w{d?O>G)f; zI70I?7#|Ywcq=NaYColSkn;?^*zK7yBc;E>0Pa`eByPB6m&j(x>NUO;AG@l_m-1H_jM(E}G)snzAV1KDwR! z=m;e_r@;X8q+?`c#Et|O;j}OiXu$cz!od+ELlXf~nXoV^(D?j>z;n$k5jia_ELe&k zKYjr5OhUy31H>kN>cF*nchWJX6LfMv`tECgxgE!br-Wo7g@ z0V=NF*UWCVquFN8b}v7jm>W;2CC0rOpId?x3+CkAxjTx(1ir_eGoB9-fF&JdNd84LWtiz8&1TzNCmkoES*6D^TUlnj{7 zmjgd!>~8`7Ao2P(XsC+E;4tMC6#O%skkZEg_3PIkCte`0N5;g6OG`(pZ8QMtezv`@ z$}UGS>(~qRd{MW*yBl%5+)PQqnLXQq2a}ai0DwY~$xMZIy-NPOjOKUyi^+w$m{t%7 zhUaT9z@u)HC8fh&a#~shsMN3S^~254x1HtDI!OR&#$Mh->=w4)eE9I;-zT^$S7|LA zu=ETJ46%4@P*YP=UU!>s$I3kxybaQ@L ziHcVrf9Qcxj3mMcY){1-0|531(}B%3%Iyx4f|Fqjn% zEnqoxYqpVbBsq}LVwcu-&qX01LQHY{{?38 z*h_hNwMY}-4PkkEduug25XU|si|FYQ$;rvFdjB)Cva&LIYIDcqGlMJNMS$Es9}{|< zEXc?z%E^TumZ{@`F{wmE3j6+La(eoT2%@7+u=eW$ zZ~RU-?yn9TF8@h?r!0+Rl9%yAxR@zQ17^+Cvq`qRe)%q z@*9*LGo+gQKK0_wtu6fcJHWKdNzV!jC=UJ=$tELcX81habD2VuV^^`i5{VJt^Ub$K z;Of`?8v$F9_nDMm0qEoko1O>UFAe}(j#TY-52i-v zEveF##5ClueM2Yu?d7xj?+F_n{O=z;r{Fh)X%!QHnahiBgEM$`e%`x2&dk}bEKVt2 z;pQ;}XmimT`5B;xL)a^8;V>9C0qh>@pgMc3@FH>ZN7dA@>bP0S9E-^7$2k`mii*Ce zKeG5)K>{F5^po#3IVT0u($Z3cA9GsFTSOc(Ha7H^3Vp=Wi(Q5W-suW$j!~doJ_Q`c zyxhDz=W1p30r=f-^QZ;-QqV>;8Hd*deT{)dGxc@M>kC^0pXX3CHp6Sf2B?j*Bix3E z;i2&G1u&`IPuGOXG~pXD;JEu7-V7xUDo~jGUY>;DR_pcY8yafq=xA8+&*-Ke^MYhm z-mqQ>N(#_2^rbvSHM7oI@cC>$nT6W~EgD=H3y14!)vp-nnrr?^b9YOvb$ zlySqj-%l!W^7JgZ)7o`i#xs**hhlhu2 zZ8zZcha-h;VbFpe4G1xxn@AS7z1foQpNl(Q|4po_dubG2KrY^LxpNH+4vK1OHjB-_ zb52tWAk@)*Bozwv$uKYn@2CNCyF#uIvbeZ-)bplplU)=5uEu9%m~N+Q#DL3upT15{ z$mr!`Df#|gtJJ_d2crU5(YFtRRG2471H;3nHz%vS-bc_(6Tt0y-7Jv3Q-lJ6_6!`; zp>)j*5ljGt0CwOFc;v;{#58hnFv#BmSh3b&l~3-g06xIF3$3P>`zI0Bb2Dt#(^To{ z>5rFlxrs9N)&&EgE{arZveb4`6<-7b3-rWxh0U{9VD36QH#dIO_qu0su*fZh8zPs^ z`Ubcm#^;-ZCcdA{bAM}}x~8+9pt>C|HGZ_;thvbR8SyQ7f5y(BY+nH)R1S5FZym| z_!gSe_SedTF1QNNYm3f#TE(wqpk~t?0USO&i$CLZyu?+Z*Q!!>$_enVWZ5+r*^(gs=PhV;}WW>dDo#Fw@AmHIyx#?6KS#B}Mw6nK2=N9m}+(S3z z%X%;F{W(X#fFSq)RM49GoUr=cm-^a7^(}RE^{}!sdN+6X{BPg>8`;1~XETN2b3ahq zcP;z=y$94)P*#jhOemV{z?0BGKuNx`57|aaW_`Zy<1P5R$bB|7d7QK#_C(=u*S#nOxfyk3QeuzgvPyj6U z!1l*D@Is_Mwkaj3_#hK=ky+1I>A!&h?@IV;b!U5_lLNr4FI8DmM6C!dF0I>;EvTaHm3MUA0Heye5ca4V0h0kZtLL&P;DV9!^Jmq{iHJZ_ z-!l!~BNI*nNLf0*44&;4+upKGu`RT@GaUZP0SU}vL1$~Exw;|{7KLsJzhpFv;b zIqx=`Mzzae>angi(=`L%tbp4d8W7jmd@ikc-@g~t)g}D+@uRf7{9vc=O^s~?ZIkaw zPb4}ez1zuLrLO7vvwuxP4RDzWKq{nVj|Xir@!|%s=RNC+M{~p=XSVRRlhM(^eUpky zyua7c(c$%Z#2fnDl<3_a1stJD-6Myn%gal%E)}hEXa;6x1v8<%M}yjX0qB)_OH}Tv zo4g(EiH4x|_I5cR3^_AP%l;%|S$N3a%?VQqD>65Sd3K2kP+jRYHF^2@O+lzRIXk0g zB)tM*&@LG}>;e2b%_@UlaL}Cp^t?e2nbSt`XxZ|`{V$dp(Q9`+rzjKl_w;lh0NcQ6 zKt5V-$IWMXu{5YRni7MChVAR=`Q$#`5tWGH)^=F)b@`!#XJx+So9yoREokDH?f{~> zTso(+$-qu3C<@2Iw$4xY?f_Mb4LGSseWPmfPtVFS1+AefSK}>=hzL6yPVa%I9kQ#S zILA&X!2M&bA-~Cv1vDZQ`|MxQuOd*4U4Ob4adBZc=Nb9z%s+Mkt`}M*6{XE)??w|s9&o3@I6G=6&zTJ6*xjXyxE7Z>T+^OL_|bq zG{Gd(M~{PnY%beBa=A6X41m;xKQkOhtZREv3P2+J`W4H*&-8*hI>8x{#@%lcV&WVh z{c~eu<6GB0iNfN-=t9u1x6xM=^Sh4v9TQXB)FB<@(LzFO3NzBER%Okng?{wj77;9c z{{ovv!_O;)oJObT^$YB^Yh`UQZM~lrPTJbleOZ_XsEtDvB_HF_BaOJsfLa_Y zi_1JcjmyW}cYD5NyZzPA&kt|L-p-zkMKKdP2nn8$3N*A0JHoidV}?tGx42x<>onPE zAKzR8iSlwXzgohjg=iqaOK}qgLmIW5yl?kn19*t(&E|)51n3M9ud`3@r?1zm%0c@r z_(Zjnm9Dt<=99^3%ZV&i)ABsvKrGby!mR_%(TAR#wa^ zzbD(@{r&ylKLnMr!Z2wSSpQbKxK^rlh=86{4Z%DrB5!Xg?YOGzTYGAW42 zBsSwd=zleE5;X*TcYZ>qK8ty2zeRO)2s9WCd()e3Z>s1Ke|>yy^A&&WGoet@($ccO zQ34ef974+TWlx_(mRJre?g63_g6FY>OI-vAO2u+tgYzI$PG@CInOi~YA-Ou0fF%Q6H@q`P@LOd{eggUD zT;3ps9KHltQP5u=Y?hpovfDpbP|ojuiYsn}b;E z7N>IxbOxw>V|4m)(6-5Hd2RRVe98c{&g-$s3xvSJr6yG^t;j!rj6jR+hWvo-5PMdW zLpU%?Ktq@GAV_@Z^dD?&=vP)(@%6EE<49lmP7kOR&LM8#(?HMfMq{Ax$-|nZz)M~) z&Dp}<8(^iWEP~h!%`K^^K?Bae*454WE!sm+>bk}^VjB6cp}^A@2b2mZ34osuZu}Ra zHLAkbqDy2QYC!a<9L?7aL(ICGJQ6Pm;a{z3&J MBdH)!C1w=-Kg0QtR{#J2 literal 0 HcmV?d00001 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(""" + + + Header1 + Header2 +
+
+ """); + + 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 +
+ + + + + Name + Price +
+ +``` + +--- + +## 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 |