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 |