diff --git a/EpsonTemplatesTest/EpsonTemplatesTest.csproj b/EpsonTemplatesTest/EpsonTemplatesTest.csproj index 4a4c1b5..2083612 100644 --- a/EpsonTemplatesTest/EpsonTemplatesTest.csproj +++ b/EpsonTemplatesTest/EpsonTemplatesTest.csproj @@ -16,6 +16,39 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + diff --git a/EpsonTemplatesTest/Program.cs b/EpsonTemplatesTest/Program.cs index b082c11..3747a76 100644 --- a/EpsonTemplatesTest/Program.cs +++ b/EpsonTemplatesTest/Program.cs @@ -6,62 +6,22 @@ using Inspectron.Epson.Templates.Interpreter; using Inspectron.Epson.Templates.Language; using System.Text.Json; -var receipt = new KitchenReceipt -{ - Title = "Warme Küche", - TransactionDateTime = new DateTime(2025, 10, 23, 12, 18, 0), - ReceiptNumber = "132018166", - WaiterName = "Mariano Amato", - WaiterId = "32 (VK Restaurant)", - TableNumber = "22", - SpecialInstruction = "Next dish" -}; -// Add dishes (this section can be empty if no dishes were ordered) - -receipt.Gangs.Add(new Gang(2, "Gang")); - -receipt.Gangs[0].Dishes.Add(new Dish(1, "Avocado Sashimi") -{ - Modifications = new DishModifications - { - Removed = new List { "Wasabi" }, - Added = new List { "Extra Ginger" } - }, - Comment = "No soy sauce" -}); -receipt.Gangs[0].Dishes.Add(new Dish(1, "Baby Spinach Salad with Truffl")); -receipt.Gangs[0].Dishes.Add(new Dish(1, "Beef Tataki") -{ - Modifications = new DishModifications - { - Removed = new List { "Onion" }, - Added = new List { "Extra Sauce" } - }, - Comment = "Medium rare" -}); -receipt.Gangs[0].Dishes.Add(new Dish(1, "Salmon Taco") -{ - Modifications = new DishModifications - { - Added = new List { "Extra Lime" } - } -}); // Add additional info -var serializedReceipt = JsonSerializer.Serialize(receipt, new JsonSerializerOptions { WriteIndented = true }); -var lexer = new Lexer(File.ReadAllText("kitchen-u220.template")); -var parser = new Parser(lexer.Tokenize().Tokens); -var template = parser.Parse().Template; + + PrinterProfile _profileT30 = new("tm-t30iii", "TM-T30III", 48, 24, false); PrinterProfile _profile220 = new("tm-220", "TM-220", 33, 18, true); -var interpreter = new TemplateInterpreter(_profile220); -var printCommands = interpreter.Interpret(template, serializedReceipt); + +var printCommands = CreateCommands(_profileT30, "Templates\\final-receipt.template", "Samples\\final-receipt.json"); + + Console.WriteLine("=== PRINT COMMANDS ===\n"); -var printer = new HtmlPrinter(@".\test.html", paperWidth: 258); +var printer = new HtmlPrinter(@".\test.html", paperWidth: 384); await printer.ConnectAsync(""); bool useDelay = false; //var printer = new EpsonPrinter(); @@ -97,4 +57,17 @@ foreach (var command in printCommands) await printer.FeedLinesAsync(5); if (useDelay) await Task.Delay(200 * 5); -await printer.CutAsync(); \ No newline at end of file +await printer.CutAsync(); + + +List CreateCommands(PrinterProfile printerProfile, string templateFile,string jsonFile) +{ + var lexer = new Lexer(File.ReadAllText(templateFile)); + var parser = new Parser(lexer.Tokenize().Tokens); + var template = parser.Parse().Template; + + var interpreter = new TemplateInterpreter(printerProfile); + var printCommands = interpreter.Interpret(template, File.ReadAllText(jsonFile)); + return printCommands; + +} \ No newline at end of file diff --git a/EpsonTemplatesTest/Samples/bar-default.json b/EpsonTemplatesTest/Samples/bar-default.json new file mode 100644 index 0000000..962ac17 --- /dev/null +++ b/EpsonTemplatesTest/Samples/bar-default.json @@ -0,0 +1,64 @@ +{ + "Title": "Bar", + "TransactionDateTime": "2025-10-23T20:30:00", + "ReceiptNumber": "132018300", + "WaiterName": "Thomas Weber", + "WaiterId": "28 (Bar Staff)", + "TableNumber": "B5", + "SpecialInstruction": "VIP Table", + "Gangs": [ + { + "Id": 1, + "Name": "Round", + "Dishes": [ + { + "Number": 2, + "Name": "Aperol Spritz", + "Modifications": { + "Removed": [], + "Added": ["Extra Ice"] + }, + "Comment": null + }, + { + "Number": 1, + "Name": "Negroni", + "Modifications": { + "Removed": ["Orange Peel"], + "Added": [] + }, + "Comment": "Stirred, not shaken" + }, + { + "Number": 3, + "Name": "San Pellegrino 50cl", + "Modifications": { + "Removed": [], + "Added": [] + }, + "Comment": null + } + ] + } + ], + "Dishes": [ + { + "Number": 2, + "Name": "Espresso", + "Modifications": { + "Removed": [], + "Added": [] + }, + "Comment": null + }, + { + "Number": 1, + "Name": "Cappuccino", + "Modifications": { + "Removed": [], + "Added": ["Oat Milk"] + }, + "Comment": "Extra hot" + } + ] +} diff --git a/EpsonTemplatesTest/Samples/fallback.json b/EpsonTemplatesTest/Samples/fallback.json new file mode 100644 index 0000000..97935c4 --- /dev/null +++ b/EpsonTemplatesTest/Samples/fallback.json @@ -0,0 +1,4 @@ +{ + "Title": "General Receipt", + "TransactionDateTime": "2025-12-01T14:00:00" +} diff --git a/EpsonTemplatesTest/Samples/final-receipt.json b/EpsonTemplatesTest/Samples/final-receipt.json new file mode 100644 index 0000000..ded8dbd --- /dev/null +++ b/EpsonTemplatesTest/Samples/final-receipt.json @@ -0,0 +1,108 @@ +{ + "CompanyName": "Klingler Gastro AG", + "Address1": "Munzplatz 3", + "Address2": "CH-8001 Zurich", + "Phone": "043 321 22 22", + "ReceiptNumber": "1-81-202", + "DateTime": "2025-12-16T14:58:00", + "Guests": 2, + "Total": 160.00, + "Currency": "CHF", + "TotalInAlternateCurrency": 172.80, + "AlternateCurrency": "EUR", + "PaymentMethod": "MASTER", + "PaymentAmount": 160.00, + "WaiterName": "Yves", + "Terminal": "Hauptkasse ZH", + "TableNumber": "12", + "VatNumber": "CHE-449.635.880 MWST", + "ThankYouMessage": "Das Team bedankt sich herzlich fur Ihren", + "GoodbyeMessageLine1": "Besuch.", + "GoodbyeMessageLine2": "Auf Wiedersehen.", + "Items": [ + { + "Quantity": 1, + "Description": "San Pellegrino 50cl", + "UnitPrice": 6.50, + "TotalPrice": 6.50, + "TaxCategory": "A" + }, + { + "Quantity": 1, + "Description": "Panna 50cl", + "UnitPrice": 6.50, + "TotalPrice": 6.50, + "TaxCategory": "A" + }, + { + "Quantity": 1, + "Description": "Granini Tomatensaft", + "UnitPrice": 5.50, + "TotalPrice": 5.50, + "TaxCategory": "A" + }, + { + "Quantity": 2, + "Description": "Business Lunch Menu Seco", + "UnitPrice": 44.00, + "TotalPrice": 88.00, + "TaxCategory": "A" + }, + { + "Quantity": 2, + "Description": "Brunello di Montalcino 1", + "UnitPrice": 16.00, + "TotalPrice": 32.00, + "TaxCategory": "A" + }, + { + "Quantity": 2, + "Description": "Espresso", + "UnitPrice": 5.50, + "TotalPrice": 11.00, + "TaxCategory": "A" + }, + { + "Quantity": 1, + "Description": "Tip", + "UnitPrice": 10.50, + "TotalPrice": 10.50, + "TaxCategory": "B" + } + ], + "TaxBreakdown": [ + { + "Category": "A", + "Rate": 8.1, + "Gross": 149.50, + "Net": 138.30, + "TaxAmount": 11.20, + "Currency": "CHF" + }, + { + "Category": "B", + "Rate": 0, + "Gross": 10.50, + "Net": 10.50, + "TaxAmount": 0.00, + "Currency": "CHF" + } + ], + "TerminalReceipt": { + "ReceiptType": "*** Kundenbeleg ***", + "BookingType": "Buchung", + "PaymentSystem": "TWINT", + "TransactionNumber": "XXXXXXXXXXXXXXX1494", + "TransactionDateTime": "2025-11-11T12:34:49", + "TerminalId": "31108834", + "AID": "A0000015749E", + "TransactionSeqCount": "6127", + "TransactionRefNo": "99036644599", + "AuthCode": "0d3396", + "AcquirerId": "2", + "EftAmount": 67.00, + "TipAmount": 6.70, + "TotalEftAmount": 73.70, + "Currency": "CHF" + } +} diff --git a/EpsonTemplatesTest/Samples/kitchen-default.json b/EpsonTemplatesTest/Samples/kitchen-default.json new file mode 100644 index 0000000..5e8f887 --- /dev/null +++ b/EpsonTemplatesTest/Samples/kitchen-default.json @@ -0,0 +1,60 @@ +{ + "Title": "Warme Kuche", + "TransactionDateTime": "2025-10-23T12:18:00", + "ReceiptNumber": "132018166", + "WaiterName": "Mariano Amato", + "WaiterId": "32 (VK Restaurant)", + "TableNumber": "22", + "SpecialInstruction": "Next dish", + "Gangs": [ + { + "Id": 1, + "Name": "Gang", + "Dishes": [ + { + "Number": 1, + "Name": "Avocado Sashimi", + "Modifications": { + "Removed": ["Wasabi"], + "Added": ["Extra Ginger"] + }, + "Comment": "No soy sauce" + }, + { + "Number": 1, + "Name": "Baby Spinach Salad with Truffl", + "Modifications": { + "Removed": [], + "Added": [] + }, + "Comment": null + } + ] + }, + { + "Id": 2, + "Name": "Gang", + "Dishes": [ + { + "Number": 1, + "Name": "Beef Tataki", + "Modifications": { + "Removed": ["Onion"], + "Added": ["Extra Sauce"] + }, + "Comment": "Medium rare" + }, + { + "Number": 1, + "Name": "Salmon Taco", + "Modifications": { + "Removed": [], + "Added": ["Extra Lime"] + }, + "Comment": null + } + ] + } + ], + "Dishes": [] +} diff --git a/EpsonTemplatesTest/Samples/kitchen-u220.json b/EpsonTemplatesTest/Samples/kitchen-u220.json new file mode 100644 index 0000000..ecd5aee --- /dev/null +++ b/EpsonTemplatesTest/Samples/kitchen-u220.json @@ -0,0 +1,39 @@ +{ + "Title": "Kalte Kuche", + "TransactionDateTime": "2025-11-15T18:45:00", + "ReceiptNumber": "132018200", + "WaiterName": "Sofia Mueller", + "WaiterId": "45 (VK Restaurant)", + "TableNumber": "8", + "SpecialInstruction": null, + "Gangs": [], + "Dishes": [ + { + "Number": 2, + "Name": "Caesar Salad", + "Modifications": { + "Removed": ["Croutons"], + "Added": ["Extra Parmesan"] + }, + "Comment": "Dressing on the side" + }, + { + "Number": 1, + "Name": "Carpaccio", + "Modifications": { + "Removed": [], + "Added": [] + }, + "Comment": null + }, + { + "Number": 3, + "Name": "Bruschetta", + "Modifications": { + "Removed": [], + "Added": ["Extra Basil"] + }, + "Comment": null + } + ] +} diff --git a/EpsonTemplatesTest/Templates/assignments.json b/EpsonTemplatesTest/Templates/assignments.json new file mode 100644 index 0000000..d4a0850 --- /dev/null +++ b/EpsonTemplatesTest/Templates/assignments.json @@ -0,0 +1,25 @@ +{ + "assignments": [ + { + "receiptType": 1, + "profileId": "tm-t30iii", + "template": "kitchen-default.template" + }, + { + "receiptType": 1, + "profileId": "tm-u220ii", + "template": "kitchen-u220.template" + }, + { + "receiptType": 1, + "profileId": null, + "template": "kitchen-default.template" + }, + { + "receiptType": 2, + "profileId": null, + "template": "bar-default.template" + } + ], + "fallbackTemplate": "fallback.template" +} diff --git a/EpsonTemplatesTest/Templates/bar-default.template b/EpsonTemplatesTest/Templates/bar-default.template new file mode 100644 index 0000000..89488f1 --- /dev/null +++ b/EpsonTemplatesTest/Templates/bar-default.template @@ -0,0 +1,59 @@ +#red,big,center# {Title} # +--- + +#center# {TransactionDateTime:dd-MMM-yy HH:mm} Nr.:{ReceiptNumber} # +#center# {WaiterName} # +#center# {WaiterId} # +#big,bold,center# Tisch: {TableNumber} # + +@if SpecialInstruction + +#big,bold,center# {SpecialInstruction} # + +@end +--- +@foreach gang in Gangs +#red,center# {gang.Id}. {gang.Name} # +@foreach drink in gang.Dishes +{drink.Number}x {drink.Name} +@if drink.Modifications.Removed.count > 0 +@foreach removed in drink.Modifications.Removed +#bold# - {removed} # +@end +@end +@if drink.Modifications.Added.count > 0 +@foreach added in drink.Modifications.Added +#bold# + {added} # +@end +@end +@if drink.Comment +#bold# Comment: {drink.Comment} # +@end + +@end +@end +@if Gangs.count > 0 +--- +@end +@foreach drink in Dishes +{drink.Number}x {drink.Name} +@if drink.Modifications.Removed.count > 0 +@foreach removed in drink.Modifications.Removed +#bold# - {removed} # +@end +@end +@if drink.Modifications.Added.count > 0 +@foreach added in drink.Modifications.Added +#bold# + {added} # +@end +@end +@if drink.Comment +#bold# Comment: {drink.Comment} # +@end + +@end +@if Dishes.count > 0 + +--- + +@end diff --git a/EpsonTemplatesTest/Templates/fallback.template b/EpsonTemplatesTest/Templates/fallback.template new file mode 100644 index 0000000..c543f18 --- /dev/null +++ b/EpsonTemplatesTest/Templates/fallback.template @@ -0,0 +1,9 @@ +#center# Receipt # +--- +@if Title +{Title} +@end +@if TransactionDateTime +{TransactionDateTime:dd-MMM-yy HH:mm} +@end +--- diff --git a/EpsonTemplatesTest/Templates/final-receipt.template b/EpsonTemplatesTest/Templates/final-receipt.template new file mode 100644 index 0000000..f0cfece --- /dev/null +++ b/EpsonTemplatesTest/Templates/final-receipt.template @@ -0,0 +1,60 @@ +#center# {CompanyName} # +#center# {Address1} # +#center# {Address2} # +#center# {Phone} # + +@row bold +|24|Rechnung Nr. {ReceiptNumber}|24,right|{DateTime:HH:mm dd.MM.yyyy} +Guests: {Guests} + + + +@foreach item in Items +{item.Quantity}x {item.Description} {right:{item.UnitPrice:F2} {item.TotalPrice:F2} {item.TaxCategory}} +@end + +{right:---------} + +#big,bold,center# Summe: {Total:F2} {Currency} # + +@if TotalInAlternateCurrency +{right:{TotalInAlternateCurrency:F2} {AlternateCurrency}} + +@end +#bold# {PaymentMethod} {right:{PaymentAmount:F2} {Currency}} # + +@if TaxBreakdown.count > 0 +MwSt % Brutto Netto MwSt +@foreach tax in TaxBreakdown +{tax.Category}: {tax.Rate}% {tax.Gross:F2} {tax.Currency} {tax.Net:F2} {tax.Currency} {tax.TaxAmount:F2} {tax.Currency} +@end +@end + +{right:Bedient von:} {WaiterName} +{right:Terminal:} {Terminal} +{right:Tisch:} {TableNumber} + + +#center# {VatNumber} # + +@if TerminalReceipt +#center# {TerminalReceipt.ReceiptType} # +#center# {TerminalReceipt.BookingType} # +#center# {TerminalReceipt.PaymentSystem} # +{TerminalReceipt.TransactionNumber} +{TerminalReceipt.TransactionDateTime:dd.MM.yyyy} {right:{TerminalReceipt.TransactionDateTime:HH:mm:ss}} +Trm-Id: {right:{TerminalReceipt.TerminalId}} +AID: {right:{TerminalReceipt.AID}} +Trx. Seq-Cnt: {right:{TerminalReceipt.TransactionSeqCount}} +Trx. Ref-No: {right:{TerminalReceipt.TransactionRefNo}} +Auth. Code: {right:{TerminalReceipt.AuthCode}} +Acq-Id: {right:{TerminalReceipt.AcquirerId}} +EFT {TerminalReceipt.Currency}: {right:{TerminalReceipt.EftAmount:F2}} +Trinkgeld {TerminalReceipt.Currency}: {right:{TerminalReceipt.TipAmount:F2}} +Total-EFT {TerminalReceipt.Currency}: {right:{TerminalReceipt.TotalEftAmount:F2}} +--- +@end + +#center# {ThankYouMessage} # +#center# {GoodbyeMessageLine1} # +#center# {GoodbyeMessageLine2} # diff --git a/EpsonTemplatesTest/Templates/kitchen-default.template b/EpsonTemplatesTest/Templates/kitchen-default.template new file mode 100644 index 0000000..d096ebc --- /dev/null +++ b/EpsonTemplatesTest/Templates/kitchen-default.template @@ -0,0 +1,57 @@ +#red,big,tall,center# {Title} # +--- + +#center# {TransactionDateTime:dd-MMM-yy HH:mm} Nr.:{ReceiptNumber} # +#center# {WaiterName} # +#center# {WaiterId} # +#big,bold,center# Tisch: {TableNumber} # + +@if SpecialInstruction + +#big,bold,center# {SpecialInstruction} # + +@end +--- +@foreach gang in Gangs +#red,big,tall,center# {gang.Id}. {gang.Name} # +@foreach dish in gang.Dishes +#tall# {dish.Number}x {dish.Name} # +@if dish.Modifications.Removed.count > 0 +@foreach removed in dish.Modifications.Removed +#bold,tall# - {removed} # +@end +@end +@if dish.Modifications.Added.count > 0 +@foreach added in dish.Modifications.Added +#bold,tall# + {added} # +@end +@end +@if dish.Comment +#bold,tall# Comment: {dish.Comment} # +@end +@end +@end +@if Gangs.count > 0 +--- +@end +@foreach dish in Dishes +#tall# {dish.Number}x {dish.Name} # +@if dish.Modifications.Removed.count > 0 +@foreach removed in dish.Modifications.Removed +#bold,tall# - {removed} # +@end +@end +@if dish.Modifications.Added.count > 0 +@foreach added in dish.Modifications.Added +#bold,tall# + {added} # +@end +@end +@if dish.Comment +#bold,tall# Comment: {dish.Comment} # +@end +@end +@if Dishes.count > 0 + +--- + +@end diff --git a/EpsonTemplatesTest/Templates/kitchen-u220.template b/EpsonTemplatesTest/Templates/kitchen-u220.template new file mode 100644 index 0000000..d096ebc --- /dev/null +++ b/EpsonTemplatesTest/Templates/kitchen-u220.template @@ -0,0 +1,57 @@ +#red,big,tall,center# {Title} # +--- + +#center# {TransactionDateTime:dd-MMM-yy HH:mm} Nr.:{ReceiptNumber} # +#center# {WaiterName} # +#center# {WaiterId} # +#big,bold,center# Tisch: {TableNumber} # + +@if SpecialInstruction + +#big,bold,center# {SpecialInstruction} # + +@end +--- +@foreach gang in Gangs +#red,big,tall,center# {gang.Id}. {gang.Name} # +@foreach dish in gang.Dishes +#tall# {dish.Number}x {dish.Name} # +@if dish.Modifications.Removed.count > 0 +@foreach removed in dish.Modifications.Removed +#bold,tall# - {removed} # +@end +@end +@if dish.Modifications.Added.count > 0 +@foreach added in dish.Modifications.Added +#bold,tall# + {added} # +@end +@end +@if dish.Comment +#bold,tall# Comment: {dish.Comment} # +@end +@end +@end +@if Gangs.count > 0 +--- +@end +@foreach dish in Dishes +#tall# {dish.Number}x {dish.Name} # +@if dish.Modifications.Removed.count > 0 +@foreach removed in dish.Modifications.Removed +#bold,tall# - {removed} # +@end +@end +@if dish.Modifications.Added.count > 0 +@foreach added in dish.Modifications.Added +#bold,tall# + {added} # +@end +@end +@if dish.Comment +#bold,tall# Comment: {dish.Comment} # +@end +@end +@if Dishes.count > 0 + +--- + +@end diff --git a/EpsonTest/Program.cs b/EpsonTest/Program.cs index fe1a323..278fc2a 100644 --- a/EpsonTest/Program.cs +++ b/EpsonTest/Program.cs @@ -60,178 +60,204 @@ using FinalReceipt = Inspectron.Epson.PrintServer.Printers.Utils.FinalRecipe.Fin //Console.ReadLine(); -//var receipt = new FinalReceipt -//{ -// CompanyName = "Klingler Gastro AG", -// Address1 = "Münzplatz 3", -// Address2 = "CH-8001 Zürich", -// Phone = "043 321 22 22", -// ReceiptNumber = "1-81-202", -// 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.", -// TerminalReceipt = new PaymentTerminalReceipt -// { -// 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" -// } -//}; +var receipt = new FinalReceipt +{ + CompanyName = "Klingler Gastro AG", + Address1 = "Münzplatz 3", + Address2 = "CH-8001 Zürich", + Phone = "043 321 22 22", + ReceiptNumber = "1-81-202", + 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.", + SplitPayments = new List + { + new SplitPaymentInfo { PaymentMethod = "Cash", Amount = 50.00m, Currency = "CHF"}, + new SplitPaymentInfo { PaymentMethod = "Card", Amount = 110.00m, Currency = "CHF" } + }, + TerminalReceipts = new List + { + new PaymentTerminalReceipt + { + 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 PaymentTerminalReceipt + { + 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" + } + } +}; -//// Add items -//receipt.Items.Add(new ReceiptItem -//{ -// Quantity = 1, -// Description = "San Pellegrino 50cl", -// UnitPrice = 6.50m, -// TotalPrice = 6.50m, -// TaxCategory = "A" -//}); +// Add items +receipt.Items.Add(new ReceiptItem +{ + Quantity = 1, + Description = "San Pellegrino 50cl", + UnitPrice = 6.50m, + TotalPrice = 6.50m, + TaxCategory = "A" +}); -//receipt.Items.Add(new ReceiptItem -//{ -// Quantity = 1, -// Description = "Panna 50cl", -// UnitPrice = 6.50m, -// TotalPrice = 6.50m, -// TaxCategory = "A" -//}); +receipt.Items.Add(new ReceiptItem +{ + Quantity = 1, + Description = "Panna 50cl", + UnitPrice = 6.50m, + TotalPrice = 6.50m, + TaxCategory = "A" +}); -//receipt.Items.Add(new ReceiptItem -//{ -// Quantity = 1, -// Description = "Granini Tomatensaft", -// UnitPrice = 5.50m, -// TotalPrice = 5.50m, -// TaxCategory = "A" -//}); +receipt.Items.Add(new ReceiptItem +{ + Quantity = 1, + Description = "Granini Tomatensaft", + UnitPrice = 5.50m, + TotalPrice = 5.50m, + TaxCategory = "A" +}); -//receipt.Items.Add(new ReceiptItem -//{ -// Quantity = 2, -// Description = "Business Lunch Menu Seco", -// UnitPrice = 44.00m, -// TotalPrice = 88.00m, -// TaxCategory = "A" -//}); +receipt.Items.Add(new ReceiptItem +{ + Quantity = 2, + Description = "Business Lunch Menu Seco", + UnitPrice = 44.00m, + TotalPrice = 88.00m, + TaxCategory = "A" +}); -//receipt.Items.Add(new ReceiptItem -//{ -// Quantity = 2, -// Description = "Brunello di Montalcino 1", -// UnitPrice = 16.00m, -// TotalPrice = 32.00m, -// TaxCategory = "A" -//}); +receipt.Items.Add(new ReceiptItem +{ + Quantity = 2, + Description = "Brunello di Montalcino 1", + UnitPrice = 16.00m, + TotalPrice = 32.00m, + TaxCategory = "A" +}); -//receipt.Items.Add(new ReceiptItem -//{ -// Quantity = 2, -// Description = "Espresso", -// UnitPrice = 5.50m, -// TotalPrice = 11.00m, -// TaxCategory = "A" -//}); +receipt.Items.Add(new ReceiptItem +{ + Quantity = 2, + Description = "Espresso", + UnitPrice = 5.50m, + TotalPrice = 11.00m, + TaxCategory = "A" +}); -//receipt.Items.Add(new ReceiptItem -//{ -// Quantity = 1, -// Description = "Tip", -// UnitPrice = 10.50m, -// TotalPrice = 10.50m, -// TaxCategory = "B" -//}); +receipt.Items.Add(new ReceiptItem +{ + Quantity = 1, + Description = "Tip", + UnitPrice = 10.50m, + TotalPrice = 10.50m, + TaxCategory = "B" +}); -//// Add tax breakdown -//receipt.TaxBreakdown.Add(new TaxInfo -//{ -// Category = "A", -// Rate = 8.1m, -// Gross = 149.50m, -// Net = 138.30m, -// TaxAmount = 11.20m, -// Currency = "CHF" -//}); +// Add tax breakdown +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" -//}); +receipt.TaxBreakdown.Add(new TaxInfo +{ + Category = "B", + Rate = 0m, + Gross = 10.50m, + Net = 10.50m, + TaxAmount = 0.00m, + Currency = "CHF" +}); -//var serializedReceipt = JsonSerializer.Serialize(receipt, new JsonSerializerOptions { WriteIndented = true }); -//var deserializedReceipt = JsonSerializer.Deserialize(serializedReceipt); -//// Convert to print commands -//var converter = new FinalReceiptConverter(lineWidth: 48, bigFontLineWidth: 24); -//var printCommands = converter.Convert(serializedReceipt); +var serializedReceipt = JsonSerializer.Serialize(receipt, new JsonSerializerOptions { WriteIndented = true }); +var deserializedReceipt = JsonSerializer.Deserialize(serializedReceipt); +// Convert to print commands +var converter = new FinalReceiptConverter(lineWidth: 48, bigFontLineWidth: 24); +var printCommands = converter.Convert(serializedReceipt); -//// Print the commands -//Console.WriteLine("=== PRINT COMMANDS ===\n"); -//var printer = new HtmlPrinter(@".\test.html", paperWidth: 384); -//await printer.ConnectAsync(""); -//await printer.SetAbsolutePrintPosition(42); -//await printer.LoadImageAsync("ristorante-klinglers.ch-logo_white_bg.png", 300); -//await printer.PrintLoadedImage(); -//await printer.SetAbsolutePrintPosition(0); -//await Task.Delay(200); -////await printer.FeedLinesAsync(1); -//await printer.SetCustomLineSpacing(22); -//foreach (var command in printCommands) -//{ -// string attributes = ""; -// if (command.IsBig) attributes += "[BIG] "; -// if (command.IsBold) attributes += "[BOLD] "; +// Print the commands +Console.WriteLine("=== PRINT COMMANDS ===\n"); +var printer = new HtmlPrinter(@".\test.html", paperWidth: 384); +await printer.ConnectAsync(""); +await printer.SetAbsolutePrintPosition(42); +await printer.LoadImageAsync("ristorante-klinglers.ch-logo_white_bg.png", 300); +await printer.PrintLoadedImage(); +await printer.SetAbsolutePrintPosition(0); +await Task.Delay(200); +//await printer.FeedLinesAsync(1); +await printer.SetCustomLineSpacing(22); +foreach (var command in printCommands) +{ + string attributes = ""; + if (command.IsBig) attributes += "[BIG] "; + if (command.IsBold) attributes += "[BOLD] "; -// Console.WriteLine($"{attributes}{command.Text}"); + Console.WriteLine($"{attributes}{command.Text}"); -// if (command.IsBig) -// { -// await printer.SetFontSizeAsync(2, 1); -// } -// else -// { -// await printer.SetFontSizeAsync(1, 1); -// } -// //await Task.Delay(200); + if (command.IsBig) + { + await printer.SetFontSizeAsync(2, 1); + } + else + { + await printer.SetFontSizeAsync(1, 1); + } + //await Task.Delay(200); -// await printer.SetEmphasized(command.IsBold); -// //await Task.Delay(200); + await printer.SetEmphasized(command.IsBold); + //await Task.Delay(200); -// await printer.PrintTextAsync(command.Text + "\n"); -// //await Task.Delay(200); + await printer.PrintTextAsync(command.Text + "\n"); + //await Task.Delay(200); -//} +} //var receipt = new KitchenReceipt @@ -332,93 +358,93 @@ using FinalReceipt = Inspectron.Epson.PrintServer.Printers.Utils.FinalRecipe.Fin //} -var receipt = new KitchenReceipt -{ - Title = "Warme Küche", - TransactionDateTime = new DateTime(2025, 10, 23, 12, 18, 0), - ReceiptNumber = "132018166", - WaiterName = "Mariano Amato", - WaiterId = "32 (VK Restaurant)", - TableNumber = "22", - SpecialInstruction = "Next dish" -}; +//var receipt = new KitchenReceipt +//{ +// Title = "Warme Küche", +// TransactionDateTime = new DateTime(2025, 10, 23, 12, 18, 0), +// ReceiptNumber = "132018166", +// WaiterName = "Mariano Amato", +// WaiterId = "32 (VK Restaurant)", +// TableNumber = "22", +// SpecialInstruction = "Next dish" +//}; -// Add dishes (this section can be empty if no dishes were ordered) +//// Add dishes (this section can be empty if no dishes were ordered) -receipt.Gangs.Add(new Gang(2, "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" -}); -receipt.Gangs[0].Dishes.Add(new Dish(1, "Baby Spinach Salad with Truffl")); -receipt.Gangs[0].Dishes.Add(new Dish(1, "Beef Tataki") -{ - Modifications = new DishModifications - { - Removed = new List { "Onion" }, - Added = new List { "Extra Sauce" } - }, - Comment = "Medium rare" -}); -receipt.Gangs[0].Dishes.Add(new Dish(1, "Salmon Taco") -{ - Modifications = new DishModifications - { - Added = new List { "Extra Lime" } - } -}); +//receipt.Gangs[0].Dishes.Add(new Dish(1, "Avocado Sashimi") +//{ +// Modifications = new DishModifications +// { +// Removed = new List { "Wasabi" }, +// Added = new List { "Extra Ginger" } +// }, +// Comment = "No soy sauce" +//}); +//receipt.Gangs[0].Dishes.Add(new Dish(1, "Baby Spinach Salad with Truffl")); +//receipt.Gangs[0].Dishes.Add(new Dish(1, "Beef Tataki") +//{ +// Modifications = new DishModifications +// { +// Removed = new List { "Onion" }, +// Added = new List { "Extra Sauce" } +// }, +// Comment = "Medium rare" +//}); +//receipt.Gangs[0].Dishes.Add(new Dish(1, "Salmon Taco") +//{ +// Modifications = new DishModifications +// { +// Added = new List { "Extra Lime" } +// } +//}); -// Add additional info +//// Add additional info -var serializedReceipt = JsonSerializer.Serialize(receipt, new JsonSerializerOptions { WriteIndented = true }); -var deserializedReceipt = JsonSerializer.Deserialize(serializedReceipt); -KitchenReceiptConverter converter = new KitchenReceiptConverter(bigLineWidth: 18, lineWidth: 33); -var printCommands = converter.ConvertToPrintCommands(deserializedReceipt); +//var serializedReceipt = JsonSerializer.Serialize(receipt, new JsonSerializerOptions { WriteIndented = true }); +//var deserializedReceipt = JsonSerializer.Deserialize(serializedReceipt); +//KitchenReceiptConverter converter = new KitchenReceiptConverter(bigLineWidth: 18, lineWidth: 33); +//var printCommands = converter.ConvertToPrintCommands(deserializedReceipt); -Console.WriteLine("=== PRINT COMMANDS ===\n"); -//var printer = new HtmlPrinter(@".\test.html", paperWidth: 258); -//await printer.ConnectAsync(""); -bool useDelay=true; -var printer = new EpsonPrinter(); -await printer.ConnectAsync("127.0.0.1", 8888); -if (useDelay)await Task.Delay(200); -await printer.FeedLinesAsync(1); -await printer.SetCustomLineSpacing(22); -foreach (var command in printCommands) -{ - string attributes = ""; - if (command.IsBig) attributes += "[BIG] "; - if (command.IsBold) attributes += "[BOLD] "; - if (command.IsRed) attributes += "[RED] "; +//Console.WriteLine("=== PRINT COMMANDS ===\n"); +////var printer = new HtmlPrinter(@".\test.html", paperWidth: 258); +////await printer.ConnectAsync(""); +//bool useDelay=true; +//var printer = new EpsonPrinter(); +//await printer.ConnectAsync("127.0.0.1", 8888); +//if (useDelay)await Task.Delay(200); +//await printer.FeedLinesAsync(1); +//await printer.SetCustomLineSpacing(22); +//foreach (var command in printCommands) +//{ +// string attributes = ""; +// if (command.IsBig) attributes += "[BIG] "; +// if (command.IsBold) attributes += "[BOLD] "; +// if (command.IsRed) attributes += "[RED] "; - Console.WriteLine($"{attributes}{command.Text}"); - await printer.SetBiggerFontTM220(command.IsBig, command.IsTall| command.IsBig, secondaryFont: false); +// Console.WriteLine($"{attributes}{command.Text}"); +// await printer.SetBiggerFontTM220(command.IsBig, command.IsTall| command.IsBig, secondaryFont: false); - if (useDelay) await Task.Delay(200); +// if (useDelay) await Task.Delay(200); - await printer.SetRedColor(command.IsRed); +// await printer.SetRedColor(command.IsRed); - if (useDelay) await Task.Delay(200); +// if (useDelay) await Task.Delay(200); - await printer.SetEmphasized(command.IsBold); - if (useDelay) await Task.Delay(200); +// await printer.SetEmphasized(command.IsBold); +// if (useDelay) await Task.Delay(200); - await printer.PrintTextAsync(command.Text + "\n"); - if (useDelay) await Task.Delay(200); +// await printer.PrintTextAsync(command.Text + "\n"); +// if (useDelay) await Task.Delay(200); -} +//} -await printer.FeedLinesAsync(5); -if (useDelay) await Task.Delay(200*5); -await printer.CutAsync(); +//await printer.FeedLinesAsync(5); +//if (useDelay) await Task.Delay(200*5); +//await printer.CutAsync(); //await printer.SetDefaultLineSpacing(); //await printer.PrintTextAsync( diff --git a/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterLoopTests.cs b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterLoopTests.cs index 4016752..eef5797 100644 --- a/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterLoopTests.cs +++ b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterLoopTests.cs @@ -56,8 +56,8 @@ public class InterpreterLoopTests var interpreter = new TemplateInterpreter(_profile); var commands = interpreter.Interpret(template, """{"Items": ["A", "B"]}"""); - Assert.Contains(commands, c => c.Text == "0"); - Assert.Contains(commands, c => c.Text == "1"); + Assert.Contains(commands, c => c.Text == "0:A"); + Assert.Contains(commands, c => c.Text == "1:B"); } [Fact] @@ -67,8 +67,8 @@ public class InterpreterLoopTests var interpreter = new TemplateInterpreter(_profile); var commands = interpreter.Interpret(template, """{"Items": ["A", "B"]}"""); - Assert.Contains(commands, c => c.Text == "1"); - Assert.Contains(commands, c => c.Text == "2"); + Assert.Contains(commands, c => c.Text == "1. A"); + Assert.Contains(commands, c => c.Text == "2. B"); } [Fact] @@ -78,9 +78,7 @@ public class InterpreterLoopTests var interpreter = new TemplateInterpreter(_profile); var commands = interpreter.Interpret(template, """{"Items": ["A", "B", "C"]}"""); - var firstCmds = commands.Where(c => c.Text.Contains("First")).ToList(); - Assert.Single(firstCmds); - Assert.Contains(commands, c => c.Text == "A"); + Assert.Contains(commands, c => c.Text == "First: A"); } [Fact] @@ -90,8 +88,7 @@ public class InterpreterLoopTests var interpreter = new TemplateInterpreter(_profile); var commands = interpreter.Interpret(template, """{"Items": ["A", "B", "C"]}"""); - Assert.Contains(commands, c => c.Text.Contains("Last")); - Assert.Contains(commands, c => c.Text == "C"); + Assert.Contains(commands, c => c.Text == "Last: C"); } [Fact] @@ -115,9 +112,8 @@ public class InterpreterLoopTests var interpreter = new TemplateInterpreter(_profile); var commands = interpreter.Interpret(template, """{"Title": "List", "Items": ["A", "B"]}"""); - Assert.Contains(commands, c => c.Text == "List"); - Assert.Contains(commands, c => c.Text == "A"); - Assert.Contains(commands, c => c.Text == "B"); + Assert.Contains(commands, c => c.Text == "List: A"); + Assert.Contains(commands, c => c.Text == "List: B"); } [Fact] diff --git a/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterRowTests.cs b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterRowTests.cs index 79f15b6..5c13a70 100644 --- a/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterRowTests.cs +++ b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterRowTests.cs @@ -102,4 +102,105 @@ public class InterpreterRowTests Assert.NotNull(rowCmd); Assert.Equal(30, rowCmd.Text.Length); } + + [Fact] + public void Interpret_Row_WithBoldStyle_SetsBold() + { + var template = Parse("@row bold\n|20|Name|10,right|Price\n@endrow"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Name") && c.Text.Contains("Price")); + Assert.NotNull(rowCmd); + Assert.True(rowCmd.IsBold); + } + + [Fact] + public void Interpret_Row_WithBigStyle_SetsBig() + { + var template = Parse("@row big\n|12|Name|6,right|Price\n@endrow"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Name") && c.Text.Contains("Price")); + Assert.NotNull(rowCmd); + Assert.True(rowCmd.IsBig); + } + + [Fact] + public void Interpret_Row_WithTallStyle_SetsTall() + { + var template = Parse("@row tall\n|20|Name|10,right|Price\n@endrow"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Name") && c.Text.Contains("Price")); + Assert.NotNull(rowCmd); + Assert.True(rowCmd.IsTall); + } + + [Fact] + public void Interpret_Row_WithMultipleStyles_AppliesAll() + { + var template = Parse("@row bold, big\n|12|Name|6,right|Price\n@endrow"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Name") && c.Text.Contains("Price")); + Assert.NotNull(rowCmd); + Assert.True(rowCmd.IsBold); + Assert.True(rowCmd.IsBig); + } + + [Fact] + public void Interpret_Row_WithRedStyle_OnSupportedPrinter_SetsRed() + { + var redProfile = new PrinterProfile("test", "Test Printer", 48, 24, SupportsRed: true); + var template = Parse("@row red\n|20|Warning|10,right|!\n@endrow"); + var interpreter = new TemplateInterpreter(redProfile); + var commands = interpreter.Interpret(template, "{}"); + + var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Warning")); + Assert.NotNull(rowCmd); + Assert.True(rowCmd.IsRed); + } + + [Fact] + public void Interpret_Row_WithRedStyle_OnUnsupportedPrinter_DoesNotSetRed() + { + var template = Parse("@row red\n|20|Warning|10,right|!\n@endrow"); + var interpreter = new TemplateInterpreter(_profile); // _profile has supportsRed: false + var commands = interpreter.Interpret(template, "{}"); + + var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Warning")); + Assert.NotNull(rowCmd); + Assert.False(rowCmd.IsRed); + } + + [Fact] + public void Interpret_Row_WithoutStyles_NoStylesApplied() + { + var template = Parse("@row\n|20|Name|10,right|Price\n@endrow"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Name") && c.Text.Contains("Price")); + Assert.NotNull(rowCmd); + Assert.False(rowCmd.IsBold); + Assert.False(rowCmd.IsBig); + Assert.False(rowCmd.IsTall); + Assert.False(rowCmd.IsRed); + } + + [Fact] + public void Interpret_Row_WithSpacingStyle_SetsLineSpacing() + { + var template = Parse("@row spacing:50\n|20|Name|10,right|Price\n@endrow"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Name") && c.Text.Contains("Price")); + Assert.NotNull(rowCmd); + Assert.Equal(50, rowCmd.SetLineSpacing); + } } diff --git a/Inspectron.Epson.Templates.Tests/Language/CommentTests.cs b/Inspectron.Epson.Templates.Tests/Language/CommentTests.cs index fe5151b..ae1d6ea 100644 --- a/Inspectron.Epson.Templates.Tests/Language/CommentTests.cs +++ b/Inspectron.Epson.Templates.Tests/Language/CommentTests.cs @@ -216,4 +216,70 @@ Line 3"; } #endregion + + #region Comment lines should not produce empty lines + + [Fact] + public void Tokenize_ConsecutiveCommentLines_ProduceNoNewLineTokens() + { + var lexer = new Lexer("@**@\n@**@\n@**@"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + // Should only have EOF token, no NewLine tokens for comment-only lines + Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.NewLine); + } + + [Fact] + public void Tokenize_CommentLineBetweenContent_NoExtraNewLines() + { + var lexer = new Lexer("Line1\n@**@\nLine2"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + // Should have exactly 2 NewLine tokens (after Line1 and after Line2) + var newLineCount = result.Tokens.Count(t => t.Type == TokenType.NewLine); + Assert.Equal(2, newLineCount); + } + + [Fact] + public void Tokenize_MultipleConsecutiveCommentLines_NoNewLines() + { + var lexer = new Lexer("Header\n@* comment 1 *@\n@* comment 2 *@\n@* comment 3 *@\nFooter"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "Header"); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "Footer"); + // Should have exactly 2 NewLine tokens (after Header and after Footer) + var newLineCount = result.Tokens.Count(t => t.Type == TokenType.NewLine); + Assert.Equal(2, newLineCount); + } + + [Fact] + public void Tokenize_EmptyLinesBetweenContent_ProducesNewLines() + { + var lexer = new Lexer("Line1\n\n\nLine2"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + // Should have 4 NewLine tokens (after Line1, after each empty line, after Line2) + var newLineCount = result.Tokens.Count(t => t.Type == TokenType.NewLine); + Assert.Equal(4, newLineCount); + } + + [Fact] + public void Tokenize_EmptyLinesPreserved_CommentsRemoved() + { + var lexer = new Lexer("Header\n\n@* comment *@\n\nFooter"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + // Should have 4 NewLine tokens: after Header, after first empty line, after second empty line, after Footer + // The comment line should NOT produce a NewLine + var newLineCount = result.Tokens.Count(t => t.Type == TokenType.NewLine); + Assert.Equal(4, newLineCount); + } + + #endregion } diff --git a/Inspectron.Epson.Templates/Interpreter/TemplateInterpreter.cs b/Inspectron.Epson.Templates/Interpreter/TemplateInterpreter.cs index 85cc0b6..b5fd544 100644 --- a/Inspectron.Epson.Templates/Interpreter/TemplateInterpreter.cs +++ b/Inspectron.Epson.Templates/Interpreter/TemplateInterpreter.cs @@ -41,78 +41,77 @@ public class TemplateInterpreter List commands, StyleContext style) { + var lineBuffer = new StringBuilder(); + var hasInlineContent = false; + + void FlushLineBuffer() + { + if (hasInlineContent) + { + var text = ApplyAlignment(lineBuffer.ToString(), style); + commands.Add(CreateCommand(text, style)); + lineBuffer.Clear(); + hasInlineContent = false; + } + } + foreach (var node in nodes) { - InterpretNode(node, context, commands, style); + switch (node) + { + // Inline nodes - accumulate into line buffer + case TextNode textNode: + lineBuffer.Append(textNode.Text); + hasInlineContent = true; + break; + + case BindingNode bindingNode: + var element = context.Resolve(bindingNode.Path); + lineBuffer.Append(_formatter.Format(element, bindingNode.Format)); + hasInlineContent = true; + break; + + // Styled text - flush buffer, emit styled content, continue accumulating + case StyledTextNode styledNode: + FlushLineBuffer(); + InterpretStyledText(styledNode, context, commands, style); + break; + + // Block nodes - flush buffer first, then process + case SeparatorNode separatorNode: + FlushLineBuffer(); + InterpretSeparator(separatorNode, commands, style); + break; + + case EmptyLineNode: + FlushLineBuffer(); + commands.Add(CreateCommand(string.Empty, style)); + break; + + case IfNode ifNode: + FlushLineBuffer(); + InterpretIf(ifNode, context, commands, style); + break; + + case ForeachNode foreachNode: + FlushLineBuffer(); + InterpretForeach(foreachNode, context, commands, style); + break; + + case RowNode rowNode: + FlushLineBuffer(); + InterpretRow(rowNode, context, commands, style); + break; + + case ColumnNode columnNode: + FlushLineBuffer(); + InterpretColumn(columnNode, context, commands, style); + break; + } } - } - private void InterpretNode( - ITemplateNode node, - DataContext context, - List commands, - StyleContext style) - { - switch (node) - { - case TextNode textNode: - InterpretText(textNode, context, commands, style); - break; - - case BindingNode bindingNode: - InterpretBinding(bindingNode, context, commands, style); - break; - - case StyledTextNode styledNode: - InterpretStyledText(styledNode, context, commands, style); - break; - - case SeparatorNode separatorNode: - InterpretSeparator(separatorNode, commands, style); - break; - - case EmptyLineNode: - commands.Add(CreateCommand(string.Empty, style)); - break; - - case IfNode ifNode: - InterpretIf(ifNode, context, commands, style); - break; - - case ForeachNode foreachNode: - InterpretForeach(foreachNode, context, commands, style); - break; - - case RowNode rowNode: - InterpretRow(rowNode, context, commands, style); - break; - - case ColumnNode columnNode: - InterpretColumn(columnNode, context, commands, style); - break; - } - } - - private void InterpretText( - TextNode node, - DataContext context, - List commands, - StyleContext style) - { - var text = ApplyAlignment(node.Text, style); - commands.Add(CreateCommand(text, style)); - } - - private void InterpretBinding( - BindingNode node, - DataContext context, - List commands, - StyleContext style) - { - var element = context.Resolve(node.Path); - var text = _formatter.Format(element, node.Format); - text = ApplyAlignment(text, style); - commands.Add(CreateCommand(text, style)); + // Flush any remaining content + FlushLineBuffer(); } private void InterpretStyledText( @@ -217,7 +216,11 @@ public class TemplateInterpreter List commands, StyleContext style) { - var lineWidth = style.IsBig ? _profile.BigLineWidth : _profile.LineWidth; + // Apply row-level styles + var rowStyle = style.Clone(); + ApplyStyles(node.Styles, rowStyle); + + var lineWidth = rowStyle.IsBig ? _profile.BigLineWidth : _profile.LineWidth; var rowBuilder = new StringBuilder(); foreach (var column in node.Columns) @@ -270,7 +273,7 @@ public class TemplateInterpreter rowBuilder.Append(aligned); } - commands.Add(CreateCommand(rowBuilder.ToString(), style)); + commands.Add(CreateCommand(rowBuilder.ToString(), rowStyle)); } private void InterpretColumn( diff --git a/Inspectron.Epson.Templates/Language/Lexer.cs b/Inspectron.Epson.Templates/Language/Lexer.cs index 582f59e..ad575b0 100644 --- a/Inspectron.Epson.Templates/Language/Lexer.cs +++ b/Inspectron.Epson.Templates/Language/Lexer.cs @@ -13,6 +13,7 @@ public class Lexer private readonly List _errors = new(); private bool _inMultiLineComment; private SourcePosition _multiLineCommentStart; + private bool _isCommentOnlyLine; private static readonly Regex StyleStartRegex = new(@"^#([a-zA-Z0-9,:]+)#", RegexOptions.Compiled); private static readonly Regex BindingRegex = new(@"^\{([^}]+)\}", RegexOptions.Compiled); @@ -40,8 +41,14 @@ public class Lexer while (_lineIndex < _lines.Count) { + _isCommentOnlyLine = false; TokenizeLine(_lines[_lineIndex]); - _tokens.Add(new Token(TokenType.NewLine, "\n", new SourcePosition(_lineIndex + 1, _columnIndex + 1))); + + // Skip NewLine for comment-only lines and lines inside multi-line comments + if (!_isCommentOnlyLine && !_inMultiLineComment) + { + _tokens.Add(new Token(TokenType.NewLine, "\n", new SourcePosition(_lineIndex + 1, _columnIndex + 1))); + } _lineIndex++; _columnIndex = 0; } @@ -76,12 +83,14 @@ public class Lexer } else { + // Comment close line with no content after + _isCommentOnlyLine = true; _columnIndex = line.Length; } } else { - // Still in comment - skip entire line + // Still in comment - skip entire line (handled by _inMultiLineComment check in Tokenize) _columnIndex = line.Length; } return; @@ -107,6 +116,8 @@ public class Lexer } else { + // Comment-only line (no content after *@) + _isCommentOnlyLine = true; _columnIndex = line.Length; } } @@ -114,12 +125,14 @@ public class Lexer { // Start of multi-line comment block _inMultiLineComment = true; + _isCommentOnlyLine = true; _multiLineCommentStart = new SourcePosition(lineNumber, leadingWhitespace + 1); _columnIndex = line.Length; } else { // Single-line comment to end of line + _isCommentOnlyLine = true; _columnIndex = line.Length; } return; @@ -215,7 +228,7 @@ public class Lexer return true; case "@row": - _tokens.Add(new Token(TokenType.Row, string.Empty, position)); + _tokens.Add(new Token(TokenType.Row, value, position)); _columnIndex = trimmed.Length + leadingWhitespace; return true; diff --git a/Inspectron.Epson.Templates/Language/Nodes/RowNode.cs b/Inspectron.Epson.Templates/Language/Nodes/RowNode.cs index 88812c0..1ef14d4 100644 --- a/Inspectron.Epson.Templates/Language/Nodes/RowNode.cs +++ b/Inspectron.Epson.Templates/Language/Nodes/RowNode.cs @@ -1,6 +1,6 @@ namespace Inspectron.Epson.Templates.Language.Nodes; -public record RowNode(List Columns, SourcePosition Position) : ITemplateNode +public record RowNode(List Columns, IReadOnlyList Styles, SourcePosition Position) : ITemplateNode { public IReadOnlyList Children => Columns; } diff --git a/Inspectron.Epson.Templates/Language/Parser.cs b/Inspectron.Epson.Templates/Language/Parser.cs index 40b56a4..e13eb7d 100644 --- a/Inspectron.Epson.Templates/Language/Parser.cs +++ b/Inspectron.Epson.Templates/Language/Parser.cs @@ -254,6 +254,9 @@ public class Parser private RowNode ParseRow() { var rowToken = Current; + var styles = string.IsNullOrWhiteSpace(rowToken.Value) + ? Array.Empty() + : rowToken.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); Advance(); // consume @row SkipNewlines(); @@ -300,7 +303,7 @@ public class Parser AddError("Expected @endrow to close @row block", Current.Position); } - return new RowNode(columns, rowToken.Position); + return new RowNode(columns, styles, rowToken.Position); } private ColumnNode ParseColumnDef() diff --git a/Inspectron.Epson.Templates/Samples/bar-default.json b/Inspectron.Epson.Templates/Samples/bar-default.json new file mode 100644 index 0000000..962ac17 --- /dev/null +++ b/Inspectron.Epson.Templates/Samples/bar-default.json @@ -0,0 +1,64 @@ +{ + "Title": "Bar", + "TransactionDateTime": "2025-10-23T20:30:00", + "ReceiptNumber": "132018300", + "WaiterName": "Thomas Weber", + "WaiterId": "28 (Bar Staff)", + "TableNumber": "B5", + "SpecialInstruction": "VIP Table", + "Gangs": [ + { + "Id": 1, + "Name": "Round", + "Dishes": [ + { + "Number": 2, + "Name": "Aperol Spritz", + "Modifications": { + "Removed": [], + "Added": ["Extra Ice"] + }, + "Comment": null + }, + { + "Number": 1, + "Name": "Negroni", + "Modifications": { + "Removed": ["Orange Peel"], + "Added": [] + }, + "Comment": "Stirred, not shaken" + }, + { + "Number": 3, + "Name": "San Pellegrino 50cl", + "Modifications": { + "Removed": [], + "Added": [] + }, + "Comment": null + } + ] + } + ], + "Dishes": [ + { + "Number": 2, + "Name": "Espresso", + "Modifications": { + "Removed": [], + "Added": [] + }, + "Comment": null + }, + { + "Number": 1, + "Name": "Cappuccino", + "Modifications": { + "Removed": [], + "Added": ["Oat Milk"] + }, + "Comment": "Extra hot" + } + ] +} diff --git a/Inspectron.Epson.Templates/Samples/fallback.json b/Inspectron.Epson.Templates/Samples/fallback.json new file mode 100644 index 0000000..97935c4 --- /dev/null +++ b/Inspectron.Epson.Templates/Samples/fallback.json @@ -0,0 +1,4 @@ +{ + "Title": "General Receipt", + "TransactionDateTime": "2025-12-01T14:00:00" +} diff --git a/Inspectron.Epson.Templates/Samples/final-receipt.json b/Inspectron.Epson.Templates/Samples/final-receipt.json new file mode 100644 index 0000000..ded8dbd --- /dev/null +++ b/Inspectron.Epson.Templates/Samples/final-receipt.json @@ -0,0 +1,108 @@ +{ + "CompanyName": "Klingler Gastro AG", + "Address1": "Munzplatz 3", + "Address2": "CH-8001 Zurich", + "Phone": "043 321 22 22", + "ReceiptNumber": "1-81-202", + "DateTime": "2025-12-16T14:58:00", + "Guests": 2, + "Total": 160.00, + "Currency": "CHF", + "TotalInAlternateCurrency": 172.80, + "AlternateCurrency": "EUR", + "PaymentMethod": "MASTER", + "PaymentAmount": 160.00, + "WaiterName": "Yves", + "Terminal": "Hauptkasse ZH", + "TableNumber": "12", + "VatNumber": "CHE-449.635.880 MWST", + "ThankYouMessage": "Das Team bedankt sich herzlich fur Ihren", + "GoodbyeMessageLine1": "Besuch.", + "GoodbyeMessageLine2": "Auf Wiedersehen.", + "Items": [ + { + "Quantity": 1, + "Description": "San Pellegrino 50cl", + "UnitPrice": 6.50, + "TotalPrice": 6.50, + "TaxCategory": "A" + }, + { + "Quantity": 1, + "Description": "Panna 50cl", + "UnitPrice": 6.50, + "TotalPrice": 6.50, + "TaxCategory": "A" + }, + { + "Quantity": 1, + "Description": "Granini Tomatensaft", + "UnitPrice": 5.50, + "TotalPrice": 5.50, + "TaxCategory": "A" + }, + { + "Quantity": 2, + "Description": "Business Lunch Menu Seco", + "UnitPrice": 44.00, + "TotalPrice": 88.00, + "TaxCategory": "A" + }, + { + "Quantity": 2, + "Description": "Brunello di Montalcino 1", + "UnitPrice": 16.00, + "TotalPrice": 32.00, + "TaxCategory": "A" + }, + { + "Quantity": 2, + "Description": "Espresso", + "UnitPrice": 5.50, + "TotalPrice": 11.00, + "TaxCategory": "A" + }, + { + "Quantity": 1, + "Description": "Tip", + "UnitPrice": 10.50, + "TotalPrice": 10.50, + "TaxCategory": "B" + } + ], + "TaxBreakdown": [ + { + "Category": "A", + "Rate": 8.1, + "Gross": 149.50, + "Net": 138.30, + "TaxAmount": 11.20, + "Currency": "CHF" + }, + { + "Category": "B", + "Rate": 0, + "Gross": 10.50, + "Net": 10.50, + "TaxAmount": 0.00, + "Currency": "CHF" + } + ], + "TerminalReceipt": { + "ReceiptType": "*** Kundenbeleg ***", + "BookingType": "Buchung", + "PaymentSystem": "TWINT", + "TransactionNumber": "XXXXXXXXXXXXXXX1494", + "TransactionDateTime": "2025-11-11T12:34:49", + "TerminalId": "31108834", + "AID": "A0000015749E", + "TransactionSeqCount": "6127", + "TransactionRefNo": "99036644599", + "AuthCode": "0d3396", + "AcquirerId": "2", + "EftAmount": 67.00, + "TipAmount": 6.70, + "TotalEftAmount": 73.70, + "Currency": "CHF" + } +} diff --git a/Inspectron.Epson.Templates/Samples/kitchen-default.json b/Inspectron.Epson.Templates/Samples/kitchen-default.json new file mode 100644 index 0000000..5e8f887 --- /dev/null +++ b/Inspectron.Epson.Templates/Samples/kitchen-default.json @@ -0,0 +1,60 @@ +{ + "Title": "Warme Kuche", + "TransactionDateTime": "2025-10-23T12:18:00", + "ReceiptNumber": "132018166", + "WaiterName": "Mariano Amato", + "WaiterId": "32 (VK Restaurant)", + "TableNumber": "22", + "SpecialInstruction": "Next dish", + "Gangs": [ + { + "Id": 1, + "Name": "Gang", + "Dishes": [ + { + "Number": 1, + "Name": "Avocado Sashimi", + "Modifications": { + "Removed": ["Wasabi"], + "Added": ["Extra Ginger"] + }, + "Comment": "No soy sauce" + }, + { + "Number": 1, + "Name": "Baby Spinach Salad with Truffl", + "Modifications": { + "Removed": [], + "Added": [] + }, + "Comment": null + } + ] + }, + { + "Id": 2, + "Name": "Gang", + "Dishes": [ + { + "Number": 1, + "Name": "Beef Tataki", + "Modifications": { + "Removed": ["Onion"], + "Added": ["Extra Sauce"] + }, + "Comment": "Medium rare" + }, + { + "Number": 1, + "Name": "Salmon Taco", + "Modifications": { + "Removed": [], + "Added": ["Extra Lime"] + }, + "Comment": null + } + ] + } + ], + "Dishes": [] +} diff --git a/Inspectron.Epson.Templates/Samples/kitchen-u220.json b/Inspectron.Epson.Templates/Samples/kitchen-u220.json new file mode 100644 index 0000000..ecd5aee --- /dev/null +++ b/Inspectron.Epson.Templates/Samples/kitchen-u220.json @@ -0,0 +1,39 @@ +{ + "Title": "Kalte Kuche", + "TransactionDateTime": "2025-11-15T18:45:00", + "ReceiptNumber": "132018200", + "WaiterName": "Sofia Mueller", + "WaiterId": "45 (VK Restaurant)", + "TableNumber": "8", + "SpecialInstruction": null, + "Gangs": [], + "Dishes": [ + { + "Number": 2, + "Name": "Caesar Salad", + "Modifications": { + "Removed": ["Croutons"], + "Added": ["Extra Parmesan"] + }, + "Comment": "Dressing on the side" + }, + { + "Number": 1, + "Name": "Carpaccio", + "Modifications": { + "Removed": [], + "Added": [] + }, + "Comment": null + }, + { + "Number": 3, + "Name": "Bruschetta", + "Modifications": { + "Removed": [], + "Added": ["Extra Basil"] + }, + "Comment": null + } + ] +} diff --git a/Inspectron.Epson.Templates/Templates/final-receipt.template b/Inspectron.Epson.Templates/Templates/final-receipt.template new file mode 100644 index 0000000..a16bd2b --- /dev/null +++ b/Inspectron.Epson.Templates/Templates/final-receipt.template @@ -0,0 +1,58 @@ +#center# {CompanyName} # +#center# {Address1} # +#center# {Address2} # +#center# {Phone} # + + +#bold# {left:Rechnung Nr. {ReceiptNumber}} {right:{DateTime:HH:mm dd.MM.yyyy}} # +Guests: {Guests} + +@foreach item in Items +{item.Quantity}x {item.Description} {right:{item.UnitPrice:F2} {item.TotalPrice:F2} {item.TaxCategory}} +@end + +{right:---------} + +#big,bold,center# Summe: {Total:F2} {Currency} # + +@if TotalInAlternateCurrency +{right:{TotalInAlternateCurrency:F2} {AlternateCurrency}} + +@end +#bold# {PaymentMethod} {right:{PaymentAmount:F2} {Currency}} # + +@if TaxBreakdown.count > 0 +MwSt % Brutto Netto MwSt +@foreach tax in TaxBreakdown +{tax.Category}: {tax.Rate}% {tax.Gross:F2} {tax.Currency} {tax.Net:F2} {tax.Currency} {tax.TaxAmount:F2} {tax.Currency} +@end +@end + +{right:Bedient von:} {WaiterName} +{right:Terminal:} {Terminal} +{right:Tisch:} {TableNumber} + + +#center# {VatNumber} # + +@if TerminalReceipt +#center# {TerminalReceipt.ReceiptType} # +#center# {TerminalReceipt.BookingType} # +#center# {TerminalReceipt.PaymentSystem} # +{TerminalReceipt.TransactionNumber} +{TerminalReceipt.TransactionDateTime:dd.MM.yyyy} {right:{TerminalReceipt.TransactionDateTime:HH:mm:ss}} +Trm-Id: {right:{TerminalReceipt.TerminalId}} +AID: {right:{TerminalReceipt.AID}} +Trx. Seq-Cnt: {right:{TerminalReceipt.TransactionSeqCount}} +Trx. Ref-No: {right:{TerminalReceipt.TransactionRefNo}} +Auth. Code: {right:{TerminalReceipt.AuthCode}} +Acq-Id: {right:{TerminalReceipt.AcquirerId}} +EFT {TerminalReceipt.Currency}: {right:{TerminalReceipt.EftAmount:F2}} +Trinkgeld {TerminalReceipt.Currency}: {right:{TerminalReceipt.TipAmount:F2}} +Total-EFT {TerminalReceipt.Currency}: {right:{TerminalReceipt.TotalEftAmount:F2}} +--- +@end + +#center# {ThankYouMessage} # +#center# {GoodbyeMessageLine1} # +#center# {GoodbyeMessageLine2} # diff --git a/Inspectron.Epson/PrintServer/Printers/Utils/FinalReceipt/Models.cs b/Inspectron.Epson/PrintServer/Printers/Utils/FinalReceipt/Models.cs index 98dbefa..d37457a 100644 --- a/Inspectron.Epson/PrintServer/Printers/Utils/FinalReceipt/Models.cs +++ b/Inspectron.Epson/PrintServer/Printers/Utils/FinalReceipt/Models.cs @@ -14,6 +14,7 @@ public class FinalReceipt public string Currency { get; set; } public decimal? TotalInAlternateCurrency { get; set; } public string AlternateCurrency { get; set; } + public List SplitPayments { get; set; } = new List(); public string PaymentMethod { get; set; } public decimal PaymentAmount { get; set; } public List TaxBreakdown { get; set; } = new List(); @@ -24,7 +25,7 @@ public class FinalReceipt public string ThankYouMessage { get; set; } public string GoodbyeMessageLine1 { get; set; } public string GoodbyeMessageLine2 { get; set; } - public PaymentTerminalReceipt TerminalReceipt { get; set; } + public List TerminalReceipts { get; set; } = new List(); } public class ReceiptItem @@ -46,6 +47,13 @@ public class TaxInfo public string Currency { get; set; } } +public class SplitPaymentInfo +{ + public string PaymentMethod { get; set; } + public decimal Amount { get; set; } + public string Currency { get; set; } +} + public class PaymentTerminalReceipt { public string ReceiptType { get; set; } // e.g., "*** Kundenbeleg ***" diff --git a/Inspectron.Epson/PrintServer/Printers/Utils/FinalReceipt/ReceiptConverter.cs b/Inspectron.Epson/PrintServer/Printers/Utils/FinalReceipt/ReceiptConverter.cs index b02686c..f4ea497 100644 --- a/Inspectron.Epson/PrintServer/Printers/Utils/FinalReceipt/ReceiptConverter.cs +++ b/Inspectron.Epson/PrintServer/Printers/Utils/FinalReceipt/ReceiptConverter.cs @@ -60,6 +60,17 @@ public class ReceiptConverter commands.Add(new PrintCommand("")); } + // Split payments + if (finalReceipt.SplitPayments != null && finalReceipt.SplitPayments.Count > 0) + { + foreach (var splitPayment in finalReceipt.SplitPayments) + { + string splitLine = $"{splitPayment.PaymentMethod}: {splitPayment.Amount:F2} {splitPayment.Currency}"; + commands.Add(new PrintCommand(splitLine.PadLeft(_lineWidth))); + } + commands.Add(new PrintCommand("")); + } + // Payment method string paymentLine = $"{finalReceipt.PaymentMethod}"; string paymentAmount = $"{finalReceipt.PaymentAmount:F2} {finalReceipt.Currency}"; @@ -93,30 +104,31 @@ public class ReceiptConverter commands.Add(new PrintCommand(Center(finalReceipt.VatNumber, false))); commands.Add(new PrintCommand("")); - // Payment Terminal Receipt - if (finalReceipt.TerminalReceipt != null) + // Payment Terminal Receipts + if (finalReceipt.TerminalReceipts != null && finalReceipt.TerminalReceipts.Count > 0) { - var terminal = finalReceipt.TerminalReceipt; + foreach (var terminal in finalReceipt.TerminalReceipts) + { + commands.Add(new PrintCommand(Center(terminal.ReceiptType, false))); + commands.Add(new PrintCommand(Center(terminal.BookingType, false))); + commands.Add(new PrintCommand(Center(terminal.PaymentSystem, false))); + commands.Add(new PrintCommand(terminal.TransactionNumber)); + commands.Add(new PrintCommand($"{terminal.TransactionDateTime:dd.MM.yyyy}".PadRight(_lineWidth/2) + $"{terminal.TransactionDateTime:HH:mm:ss}".PadLeft(_lineWidth/2))); - commands.Add(new PrintCommand(Center(terminal.ReceiptType, false))); - commands.Add(new PrintCommand(Center(terminal.BookingType, false))); - commands.Add(new PrintCommand(Center(terminal.PaymentSystem, false))); - commands.Add(new PrintCommand(terminal.TransactionNumber)); - commands.Add(new PrintCommand($"{terminal.TransactionDateTime:dd.MM.yyyy}".PadRight(_lineWidth/2) + $"{terminal.TransactionDateTime:HH:mm:ss}".PadLeft(_lineWidth/2))); + commands.Add(new PrintCommand($"Trm-Id:".PadRight(_lineWidth/2) + $"{terminal.TerminalId}".PadLeft(_lineWidth/2))); + commands.Add(new PrintCommand($"AID:".PadRight(_lineWidth/2) + $"{terminal.AID}".PadLeft(_lineWidth/2))); + commands.Add(new PrintCommand($"Trx. Seq-Cnt:".PadRight(_lineWidth/2) + $"{terminal.TransactionSeqCount}".PadLeft(_lineWidth/2))); + commands.Add(new PrintCommand($"Trx. Ref-No:".PadRight(_lineWidth/2) + $"{terminal.TransactionRefNo}".PadLeft(_lineWidth/2))); + commands.Add(new PrintCommand($"Auth. Code:".PadRight(_lineWidth/2) + $"{terminal.AuthCode}".PadLeft(_lineWidth/2))); + commands.Add(new PrintCommand($"Acq-Id:".PadRight(_lineWidth/2) + $"{terminal.AcquirerId}".PadLeft(_lineWidth/2))); - commands.Add(new PrintCommand($"Trm-Id:".PadRight(_lineWidth/2) + $"{terminal.TerminalId}".PadLeft(_lineWidth/2))); - commands.Add(new PrintCommand($"AID:".PadRight(_lineWidth/2) + $"{terminal.AID}".PadLeft(_lineWidth/2))); - commands.Add(new PrintCommand($"Trx. Seq-Cnt:".PadRight(_lineWidth/2) + $"{terminal.TransactionSeqCount}".PadLeft(_lineWidth/2))); - commands.Add(new PrintCommand($"Trx. Ref-No:".PadRight(_lineWidth/2) + $"{terminal.TransactionRefNo}".PadLeft(_lineWidth/2))); - commands.Add(new PrintCommand($"Auth. Code:".PadRight(_lineWidth/2) + $"{terminal.AuthCode}".PadLeft(_lineWidth/2))); - commands.Add(new PrintCommand($"Acq-Id:".PadRight(_lineWidth/2) + $"{terminal.AcquirerId}".PadLeft(_lineWidth/2))); + commands.Add(new PrintCommand($"EFT {terminal.Currency}:".PadRight(_lineWidth/2) + $"{terminal.EftAmount:F2}".PadLeft(_lineWidth/2))); + commands.Add(new PrintCommand($"Trinkgeld {terminal.Currency}:".PadRight(_lineWidth/2) + $"{terminal.TipAmount:F2}".PadLeft(_lineWidth/2))); + commands.Add(new PrintCommand($"Total-EFT {terminal.Currency}:".PadRight(_lineWidth/2) + $"{terminal.TotalEftAmount:F2}".PadLeft(_lineWidth/2))); - commands.Add(new PrintCommand($"EFT {terminal.Currency}:".PadRight(_lineWidth/2) + $"{terminal.EftAmount:F2}".PadLeft(_lineWidth/2))); - commands.Add(new PrintCommand($"Trinkgeld {terminal.Currency}:".PadRight(_lineWidth/2) + $"{terminal.TipAmount:F2}".PadLeft(_lineWidth/2))); - commands.Add(new PrintCommand($"Total-EFT {terminal.Currency}:".PadRight(_lineWidth/2) + $"{terminal.TotalEftAmount:F2}".PadLeft(_lineWidth/2))); - - commands.Add(new PrintCommand(new string('-', _lineWidth))); - commands.Add(new PrintCommand("")); + commands.Add(new PrintCommand(new string('-', _lineWidth))); + commands.Add(new PrintCommand("")); + } } // Thank you message diff --git a/template_editor_plan.md b/template_editor_plan.md index 39b25d2..b184144 100644 --- a/template_editor_plan.md +++ b/template_editor_plan.md @@ -472,200 +472,538 @@ Inspectron.Epson.Templates.Tests/ --- -# Phase 2: Template Editor +# Phase 2: Template Editor (VS Code Extension + C# Language Server) ## 1. Overview -Desktop application for creating and editing receipt templates with live preview, syntax highlighting, autocomplete, and validation. +VS Code extension for creating and editing receipt templates with live preview, syntax highlighting, autocomplete, and validation. The extension uses a **C# Language Server** (LSP) that reuses the lexer, parser, and interpreter from Phase 1. The VS Code extension is a thin TypeScript client that connects to the language server. + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ VS Code │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ vscode-rtl (TypeScript) │ │ +│ │ - Language client (connects to LSP) │ │ +│ │ - Preview webview (renders HTML) │ │ +│ │ - Assignments webview │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ LSP (JSON-RPC over stdio) │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Inspectron.Epson.Templates.LanguageServer (C#) │ │ +│ │ - Semantic tokens (syntax highlighting) │ │ +│ │ - Completion provider │ │ +│ │ - Diagnostics (validation) │ │ +│ │ - Hover provider │ │ +│ │ - Folding ranges │ │ +│ │ - Document symbols │ │ +│ │ - Custom requests (preview rendering) │ │ +│ │ │ │ +│ │ References: Inspectron.Epson.Templates (Phase 1) │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` ### Deliverables -- Avalonia desktop application: `Inspectron.Epson.TemplateEditor` -- Syntax highlighting for RTL -- Autocomplete (styles, control flow, data bindings) -- Real-time validation with error/warning display -- Live preview with sample data -- Template assignment management UI +- C# Language Server: `Inspectron.Epson.Templates.LanguageServer` +- VS Code extension: `vscode-rtl` (thin TypeScript client) +- Language registration for `.template` files +- Semantic token provider for syntax highlighting (via LSP) +- Completion provider (via LSP) +- Diagnostic provider for real-time validation (via LSP) +- Webview panel for live preview with sample data +- Webview panel for template assignment management + +### Advantages +- **No code duplication** - reuses Phase 1 lexer, parser, interpreter +- **Single source of truth** - language behavior defined once in C# +- **Mature editing features** - VS Code provides find/replace, multi-cursor, undo/redo, git +- **Cross-platform** - .NET 8 runs on Windows, macOS, Linux +- **Easy distribution** - VSIX bundles extension + language server executable --- ## 2. Project Structure +### 2.1 Language Server (C#) + ``` -Inspectron.Epson.TemplateEditor/ -├── Inspectron.Epson.TemplateEditor.csproj -├── App.axaml -├── App.axaml.cs -├── Program.cs -├── ViewModels/ -│ ├── MainViewModel.cs -│ ├── EditorViewModel.cs -│ ├── PreviewViewModel.cs -│ ├── AssignmentsViewModel.cs -│ └── TemplateListViewModel.cs -├── Views/ -│ ├── MainWindow.axaml -│ ├── EditorView.axaml -│ ├── PreviewView.axaml -│ ├── AssignmentsView.axaml -│ └── TemplateListView.axaml -├── Controls/ -│ ├── ReceiptPreviewControl.cs -│ └── ValidationMargin.cs -├── Editor/ -│ ├── RtlSyntaxHighlighting.cs -│ ├── RtlCompletionProvider.cs -│ ├── RtlFoldingStrategy.cs -│ └── JsonSchemaExtractor.cs +Inspectron.Epson.Templates.LanguageServer/ +├── Inspectron.Epson.Templates.LanguageServer.csproj +├── Program.cs # Entry point, stdio server setup +├── RtlLanguageServer.cs # Main server class +├── Handlers/ +│ ├── TextDocumentSyncHandler.cs # Document open/change/close +│ ├── SemanticTokensHandler.cs # Syntax highlighting +│ ├── CompletionHandler.cs # Autocomplete +│ ├── DiagnosticHandler.cs # Validation errors/warnings +│ ├── HoverHandler.cs # Hover information +│ ├── FoldingRangeHandler.cs # Code folding +│ ├── DocumentSymbolHandler.cs # Outline/breadcrumbs +│ └── CustomRequestHandlers.cs # Preview rendering, schema extraction ├── Services/ -│ ├── TemplateService.cs -│ ├── SampleDataService.cs -│ └── ValidationService.cs -└── Resources/ - └── RTL.xshd +│ ├── DocumentManager.cs # Track open documents +│ ├── DiagnosticService.cs # Run validation, publish diagnostics +│ ├── CompletionService.cs # Build completion items +│ ├── SemanticTokenService.cs # Generate semantic tokens +│ ├── SchemaService.cs # Extract schema from sample JSON +│ └── ScopeTracker.cs # Track @foreach loop variables +└── Models/ + ├── RtlSemanticTokenTypes.cs # Token type definitions + └── RtlSemanticTokenModifiers.cs # Token modifier definitions +``` + +### 2.2 VS Code Extension (TypeScript - thin client) + +``` +vscode-rtl/ +├── package.json # Extension manifest +├── tsconfig.json # TypeScript configuration +├── webpack.config.js # Bundler configuration +├── .vscodeignore # Files to exclude from package +├── language-configuration.json # Bracket matching, auto-closing pairs +├── snippets/ +│ └── rtl.snippets.json # Code snippets (@if, @foreach, @row) +├── src/ +│ ├── extension.ts # Extension entry point +│ ├── languageClient.ts # LSP client setup and management +│ ├── preview/ +│ │ ├── previewPanel.ts # Webview panel management +│ │ └── previewStyles.css # Receipt visual styling +│ ├── assignments/ +│ │ └── assignmentsPanel.ts # Assignments editor webview +│ └── utils/ +│ └── serverPath.ts # Locate language server executable +├── media/ +│ ├── preview.css # Receipt preview styling +│ ├── assignments.css # Assignments editor styling +│ └── icons/ +│ ├── template.svg # File icon +│ └── preview.svg # Preview button icon +├── server/ # Bundled language server (published .exe/.dll) +│ └── (built from C# project) +└── test/ + └── extension.test.ts # Basic extension activation tests ``` --- ## 3. UI Layout +The extension integrates with VS Code's native UI: + ``` ┌─────────────────────────────────────────────────────────────────────────────┐ -│ File Edit View Tools Help │ +│ File Edit Selection View Go Run Terminal Help │ ├─────────────────────────────────────────────────────────────────────────────┤ -│ Templates │ │ -│ ┌────────────────────┐ │ ┌─────────────────────────┬───────────────────────┐ │ -│ │ 📄 kitchen-default │ │ │ Template Editor │ Preview │ │ -│ │ 📄 kitchen-u220 │ │ │ │ │ │ -│ │ 📄 bar-default │ │ │ 1│#bold,center,big# │ ┌───────────────────┐ │ │ -│ │ 📄 final-receipt │ │ │ 2│{title} │ │ Warme Küche │ │ │ -│ │ 📄 orders-overview │ │ │ 3│--- │ │───────────────────│ │ │ -│ │ 📄 fallback │ │ │ 4│ │ │ │ │ │ -│ │ │ │ │ 5│@row 60:40 │ │ 25-Jan Nr.:42 │ │ │ -│ │ ──────────────────│ │ │ 6│{date} | >{number} │ │ │ │ │ -│ │ 📋 Assignments │ │ │ 7│@endrow │ │ Max Mustermann │ │ │ -│ │ │ │ │ 8│#center# {waiter} │ │ │ │ │ -│ └────────────────────┘ │ │ 9│ │ │ Tisch: 12 │ │ │ -│ │ │ 10│@foreach dish in... │ │───────────────────│ │ │ -│ Sample Data │ │ 11│ {dish.number}x... │ │ 2x Tomatensuppe │ │ │ -│ ┌────────────────────┐ │ │ 12│@end │ │ 1x Schnitzel │ │ │ -│ │kitchen-default.json│ │ │ │ │ - Pommes │ │ │ -│ │ │ │ │ │ │ + Reis │ │ │ -│ │ [Edit Sample] │ │ │ │ └───────────────────┘ │ │ -│ └────────────────────┘ │ │ │ │ │ -│ │ │ │ Printer: [TM-T30III▼]│ │ -├────────────────────────┴─┴─────────────────────────┴───────────────────────┴─┤ -│ Problems │ -│ ┌──────────────────────────────────────────────────────────────────────────┐ │ -│ │ ⚠ Line 11: Unused loop variable 'dish' in nested scope │ │ -│ │ ❌ Line 15: Unclosed '@foreach' block started at line 10 │ │ -│ └──────────────────────────────────────────────────────────────────────────┘ │ -├──────────────────────────────────────────────────────────────────────────────┤ -│ ✓ Saved │ kitchen-default.template │ Ln 8, Col 15 │ 2 warnings, 1 error │ -└──────────────────────────────────────────────────────────────────────────────┘ +│ EXPLORER │ kitchen-default.template │ RTL Preview │ +│ ┌─────────────────┐ │ ─────────────────────────────────────│───────────────│ +│ │ ▼ TEMPLATES │ │ 1│#bold,center,big# {title} │┌─────────────┐│ +│ │ kitchen-def...│ │ 2│--- ││ Warme Küche ││ +│ │ kitchen-u220 │ │ 3│ ││─────────────││ +│ │ bar-default │ │ 4│@row 60:40 ││ ││ +│ │ final-receipt │ │ 5│{date:dd-MMM} | >{orderNumber} ││25-Jan Nr:42 ││ +│ │ orders-overv..│ │ 6│@endrow ││ ││ +│ │ fallback │ │ 7│#center# {waiter} ││Max Musterma.││ +│ │ │ │ 8│ ││ ││ +│ │ ▼ SAMPLES │ │ 9│@foreach dish in dishes ││Tisch: 12 ││ +│ │ kitchen-def...│ │ 10│ {dish.quantity}x {dish.name} ││─────────────││ +│ │ bar-default...│ │ 11│@end ││2x Tomatensup││ +│ │ │ │ ││1x Schnitzel ││ +│ │ ▼ RTL ASSIGNMENTS│ │ ││ - Pommes ││ +│ │ Receipt → ...│ │ ││ + Reis ││ +│ │ WorkareaTic..│ │ │└─────────────┘│ +│ │ NextCourse →.│ │ │ │ +│ └─────────────────┘ │ │Printer: ▼ │ +│ │ │ TM-T30III │ +├─────────────────────┴──────────────────────────────────────┴───────────────┤ +│ PROBLEMS OUTPUT DEBUG CONSOLE TERMINAL │ +│ ┌──────────────────────────────────────────────────────────────────────────┐│ +│ │ ⚠ kitchen-default.template [2] ││ +│ │ ⚠ Line 10: Unused loop variable 'dish' in nested scope [RTL100] ││ +│ │ ❌ fallback.template [1] ││ +│ │ ❌ Line 5: Unclosed '@foreach' block started at line 3 [RTL002] ││ +│ └──────────────────────────────────────────────────────────────────────────┘│ +├─────────────────────────────────────────────────────────────────────────────┤ +│ RTL │ Ln 7, Col 12 │ UTF-8 │ CRLF │ {} kitchen-default.json │ ✓ │ +└─────────────────────────────────────────────────────────────────────────────┘ ``` +### UI Components + +| Component | VS Code Feature | Implementation | +|-----------|-----------------|----------------| +| Template list | Explorer tree view | Native file explorer or custom TreeDataProvider | +| Sample data list | Explorer tree view | Custom TreeDataProvider | +| Assignments list | Explorer tree view | Custom TreeDataProvider with inline editing | +| Editor | Native text editor | Language client connects to C# language server | +| Syntax highlighting | Semantic tokens | Language server provides tokens via LSP | +| Autocomplete | Completion items | Language server provides completions via LSP | +| Validation | Problems panel | Language server publishes diagnostics via LSP | +| Preview | Webview panel | TypeScript requests rendered HTML from language server | +| Status bar | Status bar items | Shows current sample file, printer profile | + --- ## 4. Features -### 4.1 Template List Panel +### 4.1 Language Registration (VS Code Extension) -- List all `.template` files from `templates/` directory -- Click to open in editor -- Right-click context menu: Rename, Delete, Duplicate -- "New Template" button -- "Assignments" entry opens assignment editor +**package.json contribution:** +```json +{ + "contributes": { + "languages": [{ + "id": "rtl", + "aliases": ["Receipt Template Language", "RTL"], + "extensions": [".template"], + "configuration": "./language-configuration.json", + "icon": { "light": "./media/icons/template.svg", "dark": "./media/icons/template.svg" } + }] + } +} +``` -### 4.2 Editor Panel (AvaloniaEdit) +**Note:** No TextMate grammar needed - syntax highlighting is provided via LSP semantic tokens. -**Syntax Highlighting:** +**language-configuration.json:** +```json +{ + "comments": { "lineComment": "//" }, + "brackets": [["@if", "@end"], ["@foreach", "@end"], ["@row", "@endrow"]], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "#", "close": "#" } + ], + "surroundingPairs": [ + { "open": "{", "close": "}" }, + { "open": "#", "close": "#" } + ] +} +``` -| Element | Color | -|---------|-------| -| Style markers `#...#` | Blue | -| Bindings `{...}` | Teal | -| Control flow `@if`, `@foreach`, `@end`, `@row`, `@endrow` | Purple | -| Separators `---` | Gray | -| Column delimiters `\|` | Yellow | -| Alignment markers `>`, `^` | Orange | +### 4.2 Syntax Highlighting (LSP Semantic Tokens) -**Code Folding:** -- Collapse `@if...@end` blocks -- Collapse `@foreach...@end` blocks -- Collapse `@row...@endrow` blocks +The language server provides semantic tokens via the `textDocument/semanticTokens/full` LSP method. -**Autocomplete Triggers:** +**C# Semantic Token Types (SemanticTokensHandler.cs):** -| Trigger | Shows | -|---------|-------| -| `#` | Style keywords | -| `,` (inside `#...#`) | Style keywords | -| `@` (at line start) | Control flow keywords | -| `{` | Root data properties + loop variables | -| `.` (inside `{...}`) | Child properties | -| `:` (inside `{...}`) | Format specifiers | -| `in ` (after `@foreach x`) | Collection properties | +```csharp +public static class RtlSemanticTokenTypes +{ + public const string Keyword = "keyword"; // @if, @foreach, @end, @row, @endrow + public const string Variable = "variable"; // {binding} + public const string Property = "property"; // {object.property} + public const string Decorator = "decorator"; // #style# + public const string String = "string"; // Style names: bold, center + public const string Number = "number"; // Ratios: 60:40 + public const string Operator = "operator"; // ==, !=, >, <, |, >, ^ + public const string Comment = "comment"; // Separators: ---, === + public const string Type = "type"; // Format specifiers: F2, dd-MMM +} +``` -**Validation:** -- Real-time parsing (debounced 300ms) -- Error squiggles (red underline) -- Warning squiggles (yellow underline) -- Gutter icons (❌ error, ⚠ warning) -- Click error → jump to line +**Token mapping:** -### 4.3 Preview Panel +| Element | Token Type | Default Color | +|---------|------------|---------------| +| `@if`, `@foreach`, `@end` | `keyword` | Purple | +| `@row`, `@endrow` | `keyword` | Purple | +| `#...#` markers | `decorator` | Blue | +| Style names `bold`, `center` | `string` | Green | +| `{...}` bindings | `variable` | Teal | +| Property paths `.property` | `property` | Teal | +| Format specifiers `:F2` | `type` | Orange | +| Ratios `60:40` | `number` | Light Green | +| Separators `---` | `comment` | Gray | +| Column delimiter `\|` | `operator` | Yellow | +| Alignment `>`, `^` | `operator` | Orange | +| Comparison `==`, `!=` | `operator` | Red | -**Receipt Preview Control:** -- Renders `List` visually -- Mimics thermal receipt appearance -- Monospace font -- Configurable width based on selected printer -- Updates live as template changes (debounced) +### 4.3 Code Folding (LSP Folding Ranges) -**Printer Selector:** -- Dropdown: TM-T30III, TM-U220II -- Changes `LineWidth` and `BigLineWidth` -- Shows/hides red color support +**FoldingRangeHandler.cs:** +```csharp +public class FoldingRangeHandler : IFoldingRangeHandler +{ + public Task?> Handle(FoldingRangeRequestParam request, CancellationToken ct) + { + var ranges = new List(); + // Parse document, find @if...@end, @foreach...@end, @row...@endrow blocks + // Add FoldingRange for each block + return Task.FromResult?>(new Container(ranges)); + } +} +``` -### 4.4 Sample Data Panel +Supports: +- Fold `@if...@end` blocks +- Fold `@foreach...@end` blocks +- Fold `@row...@endrow` blocks +- Nested folding -- Shows current sample JSON file -- "Edit Sample" button → opens JSON in editor (modal or panel) -- JSON syntax highlighting -- Validates JSON on save -- Shows extracted schema (tree view of available properties) +### 4.4 Autocomplete (LSP Completion) -### 4.5 Problems Panel +**CompletionHandler.cs triggers:** -- Lists all errors and warnings -- Grouped by severity (errors first) -- Each item shows: icon, line number, message -- Double-click → jump to location in editor -- Filterable: Show errors only, Show all +| Trigger | Context | Completions | +|---------|---------|-------------| +| `#` | Line start or after `#` | Style keywords: `bold`, `big`, `red`, `center`, `right`, `spacing:` | +| `,` | Inside `#...#` | Style keywords | +| `@` | Line start | Control flow: `if`, `foreach`, `end`, `row`, `endrow` | +| `{` | Anywhere | Root data properties from sample JSON + active loop variables | +| `.` | Inside `{...}` | Child properties based on path + loop metadata (`_index`, `_first`, etc.) | +| `:` | Inside `{...}` after property | Format specifiers: `F2`, `D4`, `dd-MMM-yy`, `HH:mm` | +| `in ` | After `@foreach x` | Collection properties from schema | -### 4.6 Assignments Editor +**CompletionService.cs features:** +- Context-aware (detects binding, style, control flow context) +- Completion item kinds (Property, Keyword, Snippet, etc.) +- Documentation for format specifiers +- Loop variable scope tracking via ScopeTracker + +```csharp +public class CompletionService +{ + private readonly SchemaService _schemaService; + private readonly ScopeTracker _scopeTracker; + + public List GetCompletions(DocumentUri uri, Position position) + { + var context = DetectContext(uri, position); + return context switch + { + CompletionContext.Style => GetStyleCompletions(), + CompletionContext.ControlFlow => GetControlFlowCompletions(), + CompletionContext.Binding => GetBindingCompletions(uri, position), + CompletionContext.BindingProperty => GetPropertyCompletions(uri, position), + CompletionContext.BindingFormat => GetFormatCompletions(), + _ => new List() + }; + } +} +``` + +### 4.5 Diagnostics (LSP PublishDiagnostics) + +**DiagnosticService.cs:** +- Validates on document open and change (debounced) +- Uses Phase 1 `TemplateEngine.Validate()` method +- Publishes diagnostics via `textDocument/publishDiagnostics` + +```csharp +public class DiagnosticService +{ + private readonly TemplateEngine _templateEngine; + private readonly ILanguageServerFacade _server; + + public void ValidateDocument(DocumentUri uri, string content) + { + var result = _templateEngine.Validate(content); + var diagnostics = result.Errors + .Select(e => new Diagnostic + { + Range = new Range(e.Line - 1, e.Column - 1, e.Line - 1, e.Column + 10), + Severity = DiagnosticSeverity.Error, + Code = e.Code, + Message = e.Message, + Source = "rtl" + }) + .Concat(result.Warnings.Select(w => new Diagnostic + { + Range = new Range(w.Line - 1, w.Column - 1, w.Line - 1, w.Column + 10), + Severity = DiagnosticSeverity.Warning, + Code = w.Code, + Message = w.Message, + Source = "rtl" + })) + .ToList(); + + _server.TextDocument.PublishDiagnostics(new PublishDiagnosticsParams + { + Uri = uri, + Diagnostics = new Container(diagnostics) + }); + } +} +``` + +### 4.6 Hover Provider (LSP Hover) + +**HoverHandler.cs:** +- Hover over style → shows description +- Hover over binding → shows data type from schema +- Hover over control flow → shows syntax help +- Hover over format specifier → shows example output + +```csharp +public class HoverHandler : IHoverHandler +{ + public Task Handle(HoverParams request, CancellationToken ct) + { + var content = GetHoverContent(request.TextDocument.Uri, request.Position); + if (content == null) return Task.FromResult(null); + + return Task.FromResult(new Hover + { + Contents = new MarkedStringsOrMarkupContent(new MarkupContent + { + Kind = MarkupKind.Markdown, + Value = content + }) + }); + } +} +``` + +### 4.7 Code Snippets (VS Code Extension) + +**snippets/rtl.snippets.json:** +```json +{ + "If Block": { + "prefix": "@if", + "body": ["@if ${1:condition}", " $0", "@end"], + "description": "Conditional block" + }, + "Foreach Loop": { + "prefix": "@foreach", + "body": ["@foreach ${1:item} in ${2:collection}", " $0", "@end"], + "description": "Loop over collection" + }, + "Row": { + "prefix": "@row", + "body": ["@row ${1:60}:${2:40}", "${3:left} | >${4:right}", "@endrow"], + "description": "Two-column row" + }, + "Styled Text": { + "prefix": "#style", + "body": ["#${1|bold,big,red,center,right|}# ${0:text}"], + "description": "Styled text line" + } +} +``` + +### 4.8 Preview Panel (Webview + Custom LSP Request) + +The preview uses a **custom LSP request** to get rendered HTML from the language server. + +**Custom request (Language Server):** +```csharp +// CustomRequestHandlers.cs +[Method("rtl/renderPreview")] +public record RenderPreviewParams +{ + public DocumentUri Uri { get; init; } + public string PrinterProfile { get; init; } // "tm-t30iii" or "tm-u220ii" + public string? SampleDataPath { get; init; } +} + +public record RenderPreviewResult +{ + public string Html { get; init; } // Rendered receipt as HTML + public string? Error { get; init; } // Render error message if any +} + +public class CustomRequestHandlers : IJsonRpcRequestHandler +{ + public Task Handle(RenderPreviewParams request, CancellationToken ct) + { + // 1. Load template content + // 2. Load sample data JSON + // 3. Get printer profile + // 4. Call TemplateEngine.Render() + // 5. Convert PrintCommand[] to HTML + // 6. Return HTML or error + } +} +``` + +**VS Code Extension (previewPanel.ts):** +```typescript +async function updatePreview() { + const result = await client.sendRequest('rtl/renderPreview', { + uri: activeDocument.uri.toString(), + printerProfile: selectedPrinter, + sampleDataPath: selectedSamplePath + }); + + if (result.error) { + panel.webview.html = renderError(result.error); + } else { + panel.webview.html = wrapInReceiptStyle(result.html); + } +} +``` + +**Features:** +- Side-by-side preview (like Markdown preview) +- Printer profile selector (TM-T30III, TM-U220II) +- Live updates as template changes (debounced 300ms) +- Shows render errors inline + +### 4.9 Sample Data Integration + +**Custom LSP requests:** +```csharp +// Get schema from sample JSON (for autocomplete) +[Method("rtl/getSchema")] +public record GetSchemaParams { public string SampleDataPath { get; init; } } +public record GetSchemaResult { public SchemaNode Schema { get; init; } } + +// List available sample files +[Method("rtl/listSamples")] +public record ListSamplesResult { public List SampleFiles { get; init; } } +``` + +**Features:** +- Status bar item shows current sample file +- Click status bar → quick pick to change sample +- Sample JSON files in `samples/` directory +- Automatic schema extraction for autocomplete +- Sample file association by naming convention + +### 4.10 Tree Views (VS Code Extension) + +**Templates Tree View:** +- Lists all `.template` files +- Context menu: Rename, Delete, Duplicate, New +- Click to open + +**Samples Tree View:** +- Lists all `.json` sample files +- Shows which template they're associated with +- Click to open + +**Assignments Tree View:** +- Shows current assignments from `assignments.json` +- Inline display: `Receipt → final-receipt.template` +- Context menu: Edit, Delete +- "Add Assignment" button + +### 4.11 Assignments Editor (Webview) + +**Webview panel for editing assignments.json:** ``` ┌─────────────────────────────────────────────────────────────────────┐ -│ Template Assignments │ +│ Template Assignments [Save] │ ├─────────────────────────────────────────────────────────────────────┤ -│ ┌─────────────┬──────────────────┬─────────────────────┬──────────┐ │ -│ │ Receipt Type│ Printer │ Template │ Actions │ │ -│ ├─────────────┼──────────────────┼─────────────────────┼──────────┤ │ -│ │ Receipt │ (Any) │ final-receipt ▼│ [Delete] │ │ -│ │ WorkareaTicket│ (Any) │ kitchen-default ▼│ [Delete] │ │ -│ │ WorkareaTicket│ TM-U220II │ kitchen-u220 ▼│ [Delete] │ │ -│ │ NextCourse │ (Any) │ kitchen-default ▼│ [Delete] │ │ -│ │ OrdersOverview│ (Any) │ orders-overview ▼│ [Delete] │ │ -│ └─────────────┴──────────────────┴─────────────────────┴──────────┘ │ +│ │ +│ Receipt Type Printer Template Actions │ +│ ─────────────────────────────────────────────────────────────────── │ +│ [Receipt ▼] [(Any) ▼] [final-receipt ▼] [Delete] │ +│ [WorkareaTicket ▼] [(Any) ▼] [kitchen-default ▼] [Delete] │ +│ [WorkareaTicket ▼] [TM-U220II ▼] [kitchen-u220 ▼] [Delete] │ +│ [NextCourse ▼] [(Any) ▼] [kitchen-default ▼] [Delete] │ +│ [OrdersOverview ▼] [(Any) ▼] [orders-overview ▼] [Delete] │ │ │ │ [+ Add Assignment] │ │ │ │ Fallback Template: [fallback.template ▼] │ │ │ -│ ⚠ Warning: No specific template for Receipt + TM-U220II │ -│ │ -│ [Cancel] [Save] │ └─────────────────────────────────────────────────────────────────────┘ ``` @@ -674,188 +1012,700 @@ Inspectron.Epson.TemplateEditor/ ## 5. Data Flow ``` -┌──────────────┐ -│ Template │──────┐ -│ (.template) │ │ -└──────────────┘ │ - ▼ -┌──────────────┐ ┌──────────────┐ ┌──────────────┐ -│ Sample JSON │──▶│ Template │──▶│ PrintCommands│ -│ (.json) │ │ Engine │ │ │ -└──────────────┘ └──────────────┘ └──────┬───────┘ - │ │ - │ ▼ - │ ┌──────────────┐ - │ │ Preview │ - │ │ Renderer │ - ▼ └──────────────┘ - ┌──────────────┐ - │ Validation │ - │ Results │ - └──────────────┘ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ VS Code │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────────────────────────────────────────────────────────┐ │ +│ │ vscode-rtl Extension (TypeScript) │ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ │ +│ │ │ Language │ │ Preview │ │ Assignments │ │ │ +│ │ │ Client │ │ Webview │ │ Webview │ │ │ +│ │ └──────┬───────┘ └──────┬───────┘ └────────────┬─────────────┘ │ │ +│ │ │ │ │ │ │ +│ └─────────┼───────────────────┼─────────────────────────┼───────────────┘ │ +│ │ LSP │ Custom Request │ File I/O │ +│ │ (JSON-RPC) │ rtl/renderPreview │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ Language Server (C# - stdio process) │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────────────────┐ │ │ +│ │ │ Handlers │ │ │ +│ │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │ +│ │ │ │ Semantic │ │ Completion │ │ Diagnostic │ │ Custom │ │ │ │ +│ │ │ │ Tokens │ │ Handler │ │ Handler │ │ Requests │ │ │ │ +│ │ │ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ │ │ +│ │ └───────┼──────────────┼──────────────┼──────────────┼────────┘ │ │ +│ │ │ │ │ │ │ │ +│ │ ▼ ▼ ▼ ▼ │ │ +│ │ ┌─────────────────────────────────────────────────────────────┐ │ │ +│ │ │ Services │ │ │ +│ │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │ +│ │ │ │ Document │ │ Schema │ │ Scope │ │ Diagnostic │ │ │ │ +│ │ │ │ Manager │ │ Service │ │ Tracker │ │ Service │ │ │ │ +│ │ │ └────────────┘ └─────┬──────┘ └────────────┘ └─────┬──────┘ │ │ │ +│ │ └──────────────────────┼─────────────────────────────┼────────┘ │ │ +│ │ │ │ │ │ +│ │ ▼ ▼ │ │ +│ │ ┌─────────────────────────────────────────────────────────────┐ │ │ +│ │ │ Inspectron.Epson.Templates (Phase 1 - Referenced) │ │ │ +│ │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │ +│ │ │ │ Lexer │ │ Parser │ │ Template │ │ │ │ +│ │ │ │ │ │ │ │ Engine │ │ │ │ +│ │ │ └────────────┘ └────────────┘ └────────────┘ │ │ │ +│ │ └─────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ ``` +**Key points:** +- Language Server is a standalone C# console application communicating via stdio +- All language features (highlighting, completion, validation) reuse Phase 1 code +- Custom LSP requests handle preview rendering and schema extraction +- VS Code extension is a thin client (~500 lines of TypeScript) + --- -## 6. Autocomplete Implementation +## 6. Language Server Implementation Details -### 6.1 Context Detection +### 6.1 Context Detection (CompletionService.cs) ```csharp public enum CompletionContext { None, - Style, // Inside #...# - ControlFlow, // After @ at line start - Binding, // Inside {...} - BindingProperty, // After . inside {...} - BindingFormat, // After : inside {...} + Style, // Inside #...# + ControlFlow, // After @ at line start + Binding, // Inside {...} + BindingProperty, // After . inside {...} + BindingFormat, // After : inside {...} ForeachCollection, // After "in" in @foreach - RowContent // Inside @row...@endrow + RowContent // Inside @row...@endrow +} + +public class CompletionService +{ + public CompletionContext DetectContext(string line, int column) + { + var textBefore = line[..column]; + + // Check for binding context + var lastOpenBrace = textBefore.LastIndexOf('{'); + var lastCloseBrace = textBefore.LastIndexOf('}'); + if (lastOpenBrace > lastCloseBrace) + { + var bindingText = textBefore[(lastOpenBrace + 1)..]; + if (bindingText.Contains(':')) return CompletionContext.BindingFormat; + if (bindingText.Contains('.')) return CompletionContext.BindingProperty; + return CompletionContext.Binding; + } + + // Check for style context + var hashCount = textBefore.Count(c => c == '#'); + if (hashCount % 2 == 1) return CompletionContext.Style; + + // Check for control flow + if (Regex.IsMatch(textBefore, @"^\s*@\w*$")) + return CompletionContext.ControlFlow; + + // Check for foreach collection + if (Regex.IsMatch(textBefore, @"@foreach\s+\w+\s+in\s+\w*$")) + return CompletionContext.ForeachCollection; + + return CompletionContext.None; + } } ``` -### 6.2 Schema Extraction +### 6.2 Schema Extraction (SchemaService.cs) ```csharp -public class JsonSchemaExtractor -{ - public SchemaNode Extract(string json); -} - public abstract record SchemaNode(string Name); public record ObjectNode(string Name, List Properties) : SchemaNode(Name); -public record ArrayNode(string Name, SchemaNode ItemType) : SchemaNode(Name); -public record ValueNode(string Name, ValueType Type) : SchemaNode(Name); +public record ArrayNode(string Name, SchemaNode? ItemType) : SchemaNode(Name); +public record ValueNode(string Name, JsonValueKind ValueKind) : SchemaNode(Name); -public enum ValueType { String, Number, Boolean, DateTime, Unknown } +public class SchemaService +{ + private readonly Dictionary _schemaCache = new(); + + public SchemaNode ExtractSchema(string jsonContent) + { + using var doc = JsonDocument.Parse(jsonContent); + return AnalyzeElement("root", doc.RootElement); + } + + private SchemaNode AnalyzeElement(string name, JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.Object => new ObjectNode(name, + element.EnumerateObject() + .Select(p => AnalyzeElement(p.Name, p.Value)) + .ToList()), + + JsonValueKind.Array => new ArrayNode(name, + element.GetArrayLength() > 0 + ? AnalyzeElement("item", element[0]) + : null), + + _ => new ValueNode(name, element.ValueKind) + }; + } + + public SchemaNode? ResolvePropertyPath(SchemaNode root, string path) + { + var parts = path.Split('.'); + var current = root; + + foreach (var part in parts) + { + current = current switch + { + ObjectNode obj => obj.Properties.FirstOrDefault(p => p.Name == part), + ArrayNode arr => arr.ItemType, + _ => null + }; + if (current == null) return null; + } + + return current; + } +} ``` -### 6.3 Scope Tracking +### 6.3 Scope Tracking (ScopeTracker.cs) Track active `@foreach` loops to resolve loop variable bindings: ```csharp -public record LoopScope(string VariableName, string CollectionPath, SchemaNode ItemSchema); +public record LoopScope( + string VariableName, + string CollectionPath, + SchemaNode? ItemSchema, + int StartLine, + int EndLine); public class ScopeTracker { - public List GetActiveScopes(string templateText, int cursorPosition); + private readonly SchemaService _schemaService; + + public List GetActiveScopes(string documentContent, int line, SchemaNode? rootSchema) + { + var lines = documentContent.Split('\n'); + var stack = new Stack<(string Variable, string Collection, int Line)>(); + var scopes = new List(); + + for (int i = 0; i <= line && i < lines.Length; i++) + { + var currentLine = lines[i]; + var foreachMatch = Regex.Match(currentLine, @"@foreach\s+(\w+)\s+in\s+(\S+)"); + + if (foreachMatch.Success) + { + stack.Push((foreachMatch.Groups[1].Value, foreachMatch.Groups[2].Value, i)); + } + else if (Regex.IsMatch(currentLine, @"@end\b") && stack.Count > 0) + { + // Only pop if this @end is before or at the cursor line + if (i < line) + { + stack.Pop(); + } + } + } + + // Convert stack to scopes with schema info + foreach (var (variable, collection, startLine) in stack) + { + var collectionSchema = rootSchema != null + ? _schemaService.ResolvePropertyPath(rootSchema, collection) + : null; + + var itemSchema = collectionSchema is ArrayNode arr ? arr.ItemType : null; + + scopes.Add(new LoopScope(variable, collection, itemSchema, startLine, -1)); + } + + return scopes; + } + + public List GetLoopVariableCompletions(List scopes) + { + var items = new List(); + + foreach (var scope in scopes) + { + // Add the loop variable itself + items.Add(new CompletionItem + { + Label = scope.VariableName, + Kind = CompletionItemKind.Variable, + Detail = $"Loop variable from @foreach {scope.VariableName} in {scope.CollectionPath}" + }); + + // Add loop metadata + items.AddRange(new[] + { + new CompletionItem { Label = $"{scope.VariableName}._index", Kind = CompletionItemKind.Property, Detail = "0-based index" }, + new CompletionItem { Label = $"{scope.VariableName}._number", Kind = CompletionItemKind.Property, Detail = "1-based number" }, + new CompletionItem { Label = $"{scope.VariableName}._first", Kind = CompletionItemKind.Property, Detail = "true if first iteration" }, + new CompletionItem { Label = $"{scope.VariableName}._last", Kind = CompletionItemKind.Property, Detail = "true if last iteration" } + }); + + // Add item properties if schema is available + if (scope.ItemSchema is ObjectNode obj) + { + foreach (var prop in obj.Properties) + { + items.Add(new CompletionItem + { + Label = $"{scope.VariableName}.{prop.Name}", + Kind = CompletionItemKind.Property, + Detail = GetPropertyTypeDescription(prop) + }); + } + } + } + + return items; + } +} +``` + +### 6.4 Semantic Tokens (SemanticTokenService.cs) + +```csharp +public class SemanticTokenService +{ + // Token types must match SemanticTokensLegend sent during initialization + private static readonly string[] TokenTypes = new[] + { + "keyword", // 0: @if, @foreach, @end, @row + "variable", // 1: {binding} + "property", // 2: .property + "decorator", // 3: #style# + "string", // 4: style names + "number", // 5: ratios + "operator", // 6: |, >, ^, == + "comment", // 7: separators + "type" // 8: format specifiers + }; + + public SemanticTokens GetSemanticTokens(string content) + { + var tokens = new List(); // [deltaLine, deltaStart, length, tokenType, tokenModifiers] + var lexer = new Lexer(content); + var allTokens = lexer.Tokenize(); + + int prevLine = 0; + int prevChar = 0; + + foreach (var token in allTokens) + { + var tokenType = MapTokenType(token.Type); + if (tokenType < 0) continue; + + var deltaLine = token.Line - 1 - prevLine; + var deltaStart = deltaLine == 0 ? token.Column - 1 - prevChar : token.Column - 1; + + tokens.Add(deltaLine); + tokens.Add(deltaStart); + tokens.Add(token.Length); + tokens.Add(tokenType); + tokens.Add(0); // No modifiers + + prevLine = token.Line - 1; + prevChar = token.Column - 1; + } + + return new SemanticTokens { Data = tokens.ToImmutableArray() }; + } + + private int MapTokenType(TokenType type) => type switch + { + TokenType.If or TokenType.Foreach or TokenType.End or + TokenType.Row or TokenType.EndRow => 0, // keyword + TokenType.Binding => 1, // variable + TokenType.PropertyPath => 2, // property + TokenType.StyleMarker => 3, // decorator + TokenType.StyleName => 4, // string + TokenType.Ratio => 5, // number + TokenType.Operator or TokenType.ColumnDelimiter + or TokenType.AlignmentMarker => 6, // operator + TokenType.Separator => 7, // comment + TokenType.FormatSpecifier => 8, // type + _ => -1 // skip + }; } ``` --- -## 7. File Operations +## 7. Commands and Keybindings -| Action | Behavior | -|--------|----------| -| New Template | Prompt for name, create empty file, open in editor | -| Open Template | Load from disk, show in editor | -| Save | Write to disk, trigger reload hint for print service | -| Save As | Prompt for new name, save copy | -| Delete | Confirm dialog, remove file | -| Rename | Prompt for new name, rename file, update assignments | -| Duplicate | Prompt for new name, copy content | - ---- - -## 8. Keyboard Shortcuts - -| Shortcut | Action | -|----------|--------| -| `Ctrl+S` | Save current template | -| `Ctrl+N` | New template | -| `Ctrl+O` | Open templates folder | -| `Ctrl+Shift+P` | Toggle preview panel | -| `Ctrl+Space` | Trigger autocomplete | -| `F2` | Rename selected template | -| `Delete` | Delete selected template | -| `Ctrl+G` | Go to line | -| `Ctrl+F` | Find in template | -| `Ctrl+H` | Find and replace | -| `F8` | Go to next problem | -| `Shift+F8` | Go to previous problem | - ---- - -## 9. Configuration - -Editor settings stored in `editor-settings.json`: +### Commands (package.json) ```json { - "templatesDirectory": "./templates", - "theme": "dark", - "fontSize": 14, - "showLineNumbers": true, - "wordWrap": false, - "autoSave": true, - "autoSaveDelayMs": 2000, - "previewDebounceMs": 300, - "lastOpenedTemplate": "kitchen-default.template" + "contributes": { + "commands": [ + { + "command": "rtl.openPreview", + "title": "Open Preview", + "category": "RTL", + "icon": "$(open-preview)" + }, + { + "command": "rtl.openPreviewToSide", + "title": "Open Preview to the Side", + "category": "RTL", + "icon": "$(open-preview)" + }, + { + "command": "rtl.refreshPreview", + "title": "Refresh Preview", + "category": "RTL" + }, + { + "command": "rtl.selectSampleData", + "title": "Select Sample Data", + "category": "RTL" + }, + { + "command": "rtl.editSampleData", + "title": "Edit Sample Data", + "category": "RTL" + }, + { + "command": "rtl.editAssignments", + "title": "Edit Assignments", + "category": "RTL" + }, + { + "command": "rtl.newTemplate", + "title": "New Template", + "category": "RTL", + "icon": "$(new-file)" + }, + { + "command": "rtl.duplicateTemplate", + "title": "Duplicate Template", + "category": "RTL" + }, + { + "command": "rtl.restartServer", + "title": "Restart Language Server", + "category": "RTL" + } + ] + } +} +``` + +### Keybindings + +| Shortcut | Command | When | +|----------|---------|------| +| `Ctrl+Shift+V` | `rtl.openPreview` | `editorLangId == rtl` | +| `Ctrl+K V` | `rtl.openPreviewToSide` | `editorLangId == rtl` | +| `Ctrl+Shift+R` | `rtl.refreshPreview` | `editorLangId == rtl` | + +**Note:** Standard VS Code shortcuts work automatically: +- `Ctrl+S` - Save +- `Ctrl+Space` - Trigger autocomplete +- `F8` / `Shift+F8` - Navigate problems +- `Ctrl+G` - Go to line +- `Ctrl+F` / `Ctrl+H` - Find / Replace + +--- + +## 8. Configuration + +### Extension Settings (contributes.configuration) + +```json +{ + "contributes": { + "configuration": { + "title": "RTL - Receipt Template Language", + "properties": { + "rtl.templatesDirectory": { + "type": "string", + "default": "./templates", + "description": "Path to templates directory" + }, + "rtl.samplesDirectory": { + "type": "string", + "default": "./templates/samples", + "description": "Path to sample data directory" + }, + "rtl.defaultPrinterProfile": { + "type": "string", + "enum": ["tm-t30iii", "tm-u220ii"], + "default": "tm-t30iii", + "description": "Default printer profile for preview" + }, + "rtl.preview.debounceMs": { + "type": "number", + "default": 300, + "description": "Delay before updating preview after changes" + }, + "rtl.server.path": { + "type": "string", + "default": "", + "description": "Path to language server executable (uses bundled server if empty)" + }, + "rtl.trace.server": { + "type": "string", + "enum": ["off", "messages", "verbose"], + "default": "off", + "description": "Traces communication between VS Code and the language server" + } + } + } + } +} +``` + +### Workspace Settings Example + +```json +// .vscode/settings.json +{ + "rtl.templatesDirectory": "./templates", + "rtl.samplesDirectory": "./templates/samples", + "rtl.defaultPrinterProfile": "tm-t30iii" } ``` --- -## 10. Dependencies +## 9. Dependencies + +### 9.1 Language Server (C#) + +**Inspectron.Epson.Templates.LanguageServer.csproj:** ```xml - - - - - + + + Exe + net8.0 + enable + enable + true + true + - - + + + + - - + + + + +``` - - - +### 9.2 VS Code Extension (TypeScript) + +**package.json:** + +```json +{ + "name": "vscode-rtl", + "displayName": "RTL - Receipt Template Language", + "description": "Syntax highlighting, autocomplete, and preview for Receipt Template Language", + "version": "1.0.0", + "publisher": "inspectron", + "engines": { + "vscode": "^1.85.0" + }, + "categories": ["Programming Languages", "Formatters"], + "activationEvents": [ + "onLanguage:rtl", + "workspaceContains:**/*.template" + ], + "main": "./dist/extension.js", + "dependencies": { + "vscode-languageclient": "^9.0.0" + }, + "devDependencies": { + "@types/vscode": "^1.85.0", + "@types/node": "^20.x", + "typescript": "^5.3.0", + "webpack": "^5.x", + "webpack-cli": "^5.x", + "ts-loader": "^9.x", + "@vscode/test-electron": "^2.x" + }, + "scripts": { + "vscode:prepublish": "npm run package && npm run build-server", + "compile": "webpack", + "watch": "webpack --watch", + "package": "webpack --mode production --devtool hidden-source-map", + "build-server": "dotnet publish ../Inspectron.Epson.Templates.LanguageServer -c Release -o ./server", + "test": "node ./out/test/runTest.js" + } +} ``` --- -## 11. Testing +## 10. Testing -### Manual Test Cases +### 10.1 Language Server Tests (C# - xUnit) -1. **Basic Editing** - - Create new template - - Edit with syntax highlighting - - Save and verify file content +``` +Inspectron.Epson.Templates.LanguageServer.Tests/ +├── Handlers/ +│ ├── SemanticTokensHandlerTests.cs +│ ├── CompletionHandlerTests.cs +│ ├── DiagnosticHandlerTests.cs +│ └── HoverHandlerTests.cs +├── Services/ +│ ├── CompletionServiceTests.cs +│ ├── SchemaServiceTests.cs +│ ├── ScopeTrackerTests.cs +│ └── SemanticTokenServiceTests.cs +└── Fixtures/ + ├── valid-template.template + ├── invalid-template.template + └── sample-data.json +``` -2. **Autocomplete** - - Type `#` → shows styles - - Type `{` → shows data properties - - Type `{dish.` inside foreach → shows dish properties + loop vars +### Test Cases -3. **Validation** - - Missing `@end` → shows error - - Unused loop variable → shows warning - - Fix error → error disappears +**1. Semantic Tokens Tests** +- Tokenize style markers `#bold,center#` → decorator + string +- Tokenize bindings `{property.path}` → variable + property +- Tokenize control flow → keyword +- Tokenize separators → comment +- Tokenize row syntax `@row 60:40` → keyword + number -4. **Preview** - - Edit template → preview updates - - Switch printer → width changes - - Missing data → shows empty (no crash) +**2. Completion Tests** +- Complete styles after `#` +- Complete properties after `{` +- Complete child properties after `.` +- Complete format specifiers after `:` +- Complete loop variables in scope +- Complete loop metadata (`_index`, `_first`, etc.) -5. **Assignments** - - Add new assignment - - Change template mapping - - Delete assignment - - Save and verify JSON +**3. Diagnostics Tests** +- Error on unclosed `@foreach` +- Error on unclosed `{` binding +- Warning on unused loop variable +- Error on invalid style name + +**4. Schema Tests** +- Extract schema from nested JSON +- Resolve property paths +- Handle arrays correctly + +**5. Scope Tracker Tests** +- Track single @foreach scope +- Track nested @foreach scopes +- Close scope after @end + +### 10.2 VS Code Extension Tests (TypeScript) + +``` +vscode-rtl/test/ +├── extension.test.ts # Extension activation tests +├── languageClient.test.ts # LSP client connection tests +└── fixtures/ + └── test-workspace/ + ├── test.template + └── test.json +``` + +### Manual Test Checklist + +- [ ] Install extension in VS Code +- [ ] Open `.template` file - syntax highlighting works (semantic tokens) +- [ ] Type `@` at line start - autocomplete shows control flow +- [ ] Type `{` - autocomplete shows data properties +- [ ] Type `{item.` inside foreach - autocomplete shows item properties + `_index`, `_first`, etc. +- [ ] Add syntax error - red squiggle appears, Problems panel updates +- [ ] Fix error - squiggle disappears +- [ ] Hover over style - shows description +- [ ] Hover over binding - shows type from schema +- [ ] Open preview (`Ctrl+Shift+V`) - receipt preview appears +- [ ] Edit template - preview updates live +- [ ] Change printer in preview - width adjusts +- [ ] Open assignments editor - table displays correctly +- [ ] Add/edit/delete assignment - saves to JSON +- [ ] Restart Language Server command works + +--- + +## 11. Distribution + +### Build Process + +```bash +# 1. Build language server for all platforms +dotnet publish Inspectron.Epson.Templates.LanguageServer -c Release -r win-x64 -o vscode-rtl/server/win-x64 +dotnet publish Inspectron.Epson.Templates.LanguageServer -c Release -r linux-x64 -o vscode-rtl/server/linux-x64 +dotnet publish Inspectron.Epson.Templates.LanguageServer -c Release -r osx-x64 -o vscode-rtl/server/osx-x64 + +# 2. Build VS Code extension +cd vscode-rtl +npm install +npm run package + +# 3. Create VSIX +npx @vscode/vsce package + +# Creates: vscode-rtl-1.0.0.vsix +``` + +### VSIX Contents + +``` +vscode-rtl-1.0.0.vsix +├── extension/ +│ ├── dist/ +│ │ └── extension.js # Bundled TypeScript +│ ├── server/ +│ │ ├── win-x64/ # Windows language server +│ │ ├── linux-x64/ # Linux language server +│ │ └── osx-x64/ # macOS language server +│ ├── media/ +│ ├── snippets/ +│ ├── language-configuration.json +│ └── package.json +└── [Content_Types].xml +``` + +### Installation Options + +1. **Local VSIX install:** + ```bash + code --install-extension vscode-rtl-1.0.0.vsix + ``` + +2. **VS Code Marketplace:** (optional, for public distribution) + ```bash + npx @vscode/vsce publish + ``` + +3. **Manual installation:** + - Copy extracted folder to: + - Windows: `%USERPROFILE%\.vscode\extensions\` + - macOS/Linux: `~/.vscode/extensions/` --- ## 12. Timeline -### Phase 1: Core Engine (Foundation) +### Phase 1: Core Engine (Foundation) - C# 1. Project setup and structure 2. Lexer implementation + tests 3. Parser implementation + tests @@ -867,16 +1717,32 @@ Editor settings stored in `editor-settings.json`: 9. Integration testing 10. Migration and deployment -### Phase 2: Editor (After Phase 1 Complete) -1. Avalonia project setup -2. Basic UI layout (panels, splitters) -3. Template list with file operations -4. Editor integration with AvaloniaEdit -5. Syntax highlighting -6. Basic preview rendering -7. Validation display -8. Autocomplete - styles and control flow -9. Autocomplete - data bindings with scope -10. Assignments editor -11. Sample data editor -12. Polish and testing +### Phase 2: VS Code Extension + Language Server (After Phase 1 Complete) + +**Language Server (C#):** +1. Project setup with OmniSharp LSP +2. Server initialization and stdio transport +3. TextDocumentSyncHandler (open/change/close) +4. SemanticTokensHandler (syntax highlighting) +5. DiagnosticService + PublishDiagnostics +6. FoldingRangeHandler +7. HoverHandler +8. CompletionHandler - styles and control flow +9. SchemaService and ScopeTracker +10. CompletionHandler - data bindings with scope awareness +11. Custom request: rtl/renderPreview +12. Custom request: rtl/getSchema, rtl/listSamples +13. Unit tests + +**VS Code Extension (TypeScript):** +14. Extension project setup (package.json, webpack) +15. Language client setup and server spawning +16. Language registration and configuration +17. Preview webview - calls rtl/renderPreview +18. Preview webview - printer selector, live updates +19. Tree views (templates, samples) - optional +20. Assignments editor webview - optional +21. Code snippets +22. Integration testing +23. Multi-platform build and VSIX packaging +24. Documentation and distribution diff --git a/template_syntax.md b/template_syntax.md index b339a67..a4a82d3 100644 --- a/template_syntax.md +++ b/template_syntax.md @@ -624,6 +624,54 @@ Create tabular layouts with fixed-width columns. @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 ``` @@ -870,6 +918,7 @@ Templates are assigned to receipt types and printer profiles in `assignments.jso | `@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 |