template engine

This commit is contained in:
EugeneTes
2026-02-03 14:27:13 +01:00
parent 46b648d753
commit ec5decb12e
31 changed files with 4640 additions and 946 deletions

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Inspectron.Epson\Inspectron.Epson.csproj" />
<ProjectReference Include="..\Inspectron.Epson.TemplateEngine\Inspectron.Epson.TemplateEngine.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Templates\*.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="ristorante-klinglers.ch-logo.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="ristorante-klinglers.ch-logo_white_bg.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,118 @@
using EpsonTest.Templates;
using Inspectron.Epson.TemplateEngine;
var engine = new ReceiptTemplateEngine();
var templatesDir = Path.Combine(AppContext.BaseDirectory, "Templates");
// ============================================================================
// FINAL RECEIPT - HTML PRINTER
// ============================================================================
var json = TestHelpers.BuildFinalReceiptJson(includeTax: true);
var template = File.ReadAllText(Path.Combine(templatesDir, "FinalReceipt.xml"));
var printCommands = engine.Render(template, json, lineWidth: 48, bigFontLineWidth: 24);
TestHelpers.PrintCommandsToConsole(printCommands);
var printer = await TestHelpers.CreateHtmlPrinterAsync();
await TestHelpers.PrintCommandsToHtmlPrinterAsync(
printer,
printCommands,
logoPath: "ristorante-klinglers.ch-logo_white_bg.png");
// ============================================================================
// FINAL RECEIPT - EPSON PRINTER
// ============================================================================
//var json = TestHelpers.BuildFinalReceiptJson();
//var template = File.ReadAllText(Path.Combine(templatesDir, "FinalReceipt.xml"));
//var printCommands = engine.Render(template, json, lineWidth: 48, bigFontLineWidth: 24);
//TestHelpers.PrintCommandsToConsole(printCommands);
//var printer = await TestHelpers.CreateEpsonPrinterAsync();
//await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands);
// ============================================================================
// INVOICE RECEIPT - HTML PRINTER
// ============================================================================
//var json = TestHelpers.BuildInvoiceReceiptJson();
//var template = File.ReadAllText(Path.Combine(templatesDir, "InvoiceReceipt.xml"));
//var printCommands = engine.Render(template, json, lineWidth: 48, bigFontLineWidth: 24);
//TestHelpers.PrintCommandsToConsole(printCommands);
//var printer = await TestHelpers.CreateHtmlPrinterAsync();
//await TestHelpers.PrintCommandsToHtmlPrinterAsync(
// printer,
// printCommands,
// logoPath: "ristorante-klinglers.ch-logo_white_bg.png");
// ============================================================================
// INVOICE RECEIPT - EPSON PRINTER
// ============================================================================
//var json = TestHelpers.BuildInvoiceReceiptJson();
//var template = File.ReadAllText(Path.Combine(templatesDir, "InvoiceReceipt.xml"));
//var printCommands = engine.Render(template, json, lineWidth: 48, bigFontLineWidth: 24);
//TestHelpers.PrintCommandsToConsole(printCommands);
//var printer = await TestHelpers.CreateEpsonPrinterAsync();
//await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands);
// ============================================================================
// KITCHEN RECEIPT - HTML PRINTER
// ============================================================================
//var json = TestHelpers.BuildKitchenReceiptJson();
//var template = File.ReadAllText(Path.Combine(templatesDir, "KitchenReceipt.xml"));
//var printCommands = engine.Render(template, json, lineWidth: 33, bigFontLineWidth: 20);
//TestHelpers.PrintCommandsToConsole(printCommands);
//var printer = await TestHelpers.CreateHtmlPrinterAsync(
// path: @".\kitchen_test.html",
// paperWidth: TestHelpers.KitchenPaperWidth);
//await TestHelpers.PrintKitchenCommandsToHtmlPrinterAsync(printer, printCommands);
// ============================================================================
// KITCHEN RECEIPT (NEXT DISH) - HTML PRINTER
// ============================================================================
//var json = TestHelpers.BuildKitchenReceiptNextDishJson();
//var template = File.ReadAllText(Path.Combine(templatesDir, "KitchenReceipt.xml"));
//var printCommands = engine.Render(template, json, lineWidth: 33, bigFontLineWidth: 20);
//TestHelpers.PrintCommandsToConsole(printCommands);
//var printer = await TestHelpers.CreateHtmlPrinterAsync(
// path: @".\kitchen_next_dish.html",
// paperWidth: TestHelpers.KitchenPaperWidth);
//await TestHelpers.PrintKitchenCommandsToHtmlPrinterAsync(printer, printCommands);
// ============================================================================
// KITCHEN RECEIPT - EPSON PRINTER
// ============================================================================
//var json = TestHelpers.BuildKitchenReceiptJson();
//var template = File.ReadAllText(Path.Combine(templatesDir, "KitchenReceipt.xml"));
//var printCommands = engine.Render(template, json, lineWidth: 33, bigFontLineWidth: 20);
//TestHelpers.PrintCommandsToConsole(printCommands);
//var printer = await TestHelpers.CreateEpsonPrinterAsync(paperWidth: TestHelpers.KitchenPaperWidth);
//await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands, useDelay: true);
// ============================================================================
// ORDER ITEMS RECEIPT - HTML PRINTER
// ============================================================================
//var json = TestHelpers.BuildOrderItemsJson();
//var template = File.ReadAllText(Path.Combine(templatesDir, "OrderItems.xml"));
//var printCommands = engine.Render(template, json, lineWidth: 48, bigFontLineWidth: 24);
//TestHelpers.PrintCommandsToConsole(printCommands);
//var printer = await TestHelpers.CreateHtmlPrinterAsync(path: @".\order_items.html");
//await TestHelpers.PrintCommandsToHtmlPrinterAsync(printer, printCommands);

View File

@@ -0,0 +1,115 @@
<receipt>
<line align="center">{{CompanyName}}</line>
<line align="center">{{Address1}}</line>
<line align="center">{{Address2}}</line>
<line align="center">{{Phone}}</line>
<line />
<line />
<if test="IsDebtor">
<line>Debitorenrechnung</line>
</if>
<columns left="{{ReceiptLabel}}" right="{{DateTime:HH:mm dd.MM.yyyy}}" bold="true" />
<line>Guests: {{Guests}}</line>
<line />
<foreach items="Items" var="item">
<columns left="{{item.Quantity}}x {{item.Description}}" right="{{item.PriceDisplay}}" wrap="true" />
<foreach items="item.SubItems" var="sub">
<line> - {{sub}}</line>
</foreach>
</foreach>
<line />
<line align="right">---------</line>
<line />
<line align="center" big="true" bold="true">Summe: {{Total:F2}} {{Currency}}</line>
<line />
<if test="TotalInAlternateCurrency">
<line align="right">{{TotalInAlternateCurrency:F2}} {{AlternateCurrency}}</line>
<line />
</if>
<if test="DiscountInfo">
<columns left="{{DiscountInfo.Description}}" right="{{DiscountInfo.Amount:F2}} {{DiscountInfo.Currency}}" wrap="true" />
<line />
</if>
<foreach items="SplitPayments" var="sp">
<line align="right">{{sp.PaymentMethod}}: {{sp.Amount:F2}} {{sp.Currency}}</line>
</foreach>
<if test="HasSplitPayments">
<line />
</if>
<columns left="{{PaymentMethod}}" right="{{PaymentAmount:F2}} {{Currency}}" bold="true" />
<line />
<foreach items="TaxBreakdown" var="tax">
<if test="$first">
<row>
<col width="11" align="left">MwSt %</col>
<col width="11" align="right">Brutto</col>
<col width="11" align="right">Netto</col>
<col align="right">MwSt</col>
</row>
</if>
<row>
<col width="11" align="left">{{tax.Category}}:{{tax.Rate}}%</col>
<col width="11" align="right"> {{tax.Gross:F2}} {{tax.Currency}}</col>
<col width="11" align="right"> {{tax.Net:F2}} {{tax.Currency}}</col>
<col align="right"> {{tax.TaxAmount:F2}} {{tax.Currency}}</col>
</row>
</foreach>
<if test="!HasTaxableCategories">
<line>Nicht mehrwertsteuerpflichtig</line>
</if>
<line />
<columns left="Bedient von:" right="{{WaiterName}}" />
<if test="Terminal">
<columns left="Terminal:" right="{{Terminal}}" />
</if>
<columns left="Tisch:" right="{{TableNumber}}" />
<line />
<line />
<line align="center">{{VatNumber}}</line>
<line />
<foreach items="TerminalReceipts" var="tr">
<line align="center">{{tr.ReceiptType}}</line>
<line align="center">{{tr.BookingType}}</line>
<line align="center">{{tr.PaymentSystem}}</line>
<line>{{tr.TransactionNumber}}</line>
<columns left="{{tr.TransactionDateTime:dd.MM.yyyy}}" right="{{tr.TransactionDateTime:HH:mm:ss}}" />
<columns left="Trm-Id:" right="{{tr.TerminalId}}" />
<columns left="AID:" right="{{tr.AID}}" />
<columns left="Trx. Seq-Cnt:" right="{{tr.TransactionSeqCount}}" />
<columns left="Trx. Ref-No:" right="{{tr.TransactionRefNo}}" />
<columns left="Auth. Code:" right="{{tr.AuthCode}}" />
<columns left="Acq-Id:" right="{{tr.AcquirerId}}" />
<columns left="EFT {{tr.Currency}}:" right="{{tr.EftAmount:F2}}" />
<columns left="Trinkgeld {{tr.Currency}}:" right="{{tr.TipAmount:F2}}" />
<columns left="Total-EFT {{tr.Currency}}:" right="{{tr.TotalEftAmount:F2}}" />
<separator />
<line />
</foreach>
<line align="center">{{ThankYouMessage}}</line>
<line align="center">{{GoodbyeMessageLine1}}</line>
<line align="center">{{GoodbyeMessageLine2}}</line>
<if test="IsDebtor">
<line />
<line />
<line />
<separator />
<line>Unterschrift</line>
</if>
</receipt>

View File

@@ -0,0 +1,29 @@
<receipt>
<line align="center">{{CompanyName}}</line>
<line align="center">{{Address1}}</line>
<line align="center">{{Address2}}</line>
<line align="center">{{Phone}}</line>
<line />
<line />
<columns left="{{ReceiptLabel}}" right="{{DateTime:HH:mm dd.MM.yyyy}}" bold="true" />
<if test="Guests">
<line>Guests: {{Guests}}</line>
</if>
<line />
<foreach items="Items" var="item">
<columns left="{{item.Quantity}}x {{item.Description}}" right="{{item.PriceDisplay}}" wrap="true" />
<foreach items="item.SubItems" var="sub">
<line> - {{sub}}</line>
</foreach>
</foreach>
<line />
<line align="right">---------</line>
<line />
<line align="center" big="true" bold="true">Summe: {{Total:F2}} {{Currency}}</line>
<line align="center">Tisch: {{TableNumber}}</line>
<line align="center">{{ThankYouMessage}}</line>
</receipt>

View File

@@ -0,0 +1,65 @@
<receipt>
<line align="center" big="true" tall="true" red="true">{{Title}}</line>
<separator />
<line />
<line align="center">{{TransactionDateTime:dd-MMM-yy HH:mm}} Nr.:{{ReceiptNumber}}</line>
<line align="center">{{WaiterName}}</line>
<line align="center" big="true" bold="true">Tisch: {{TableNumber}}</line>
<if test="SpecialInstruction">
<line />
<line align="center" big="true" bold="true">{{SpecialInstruction}}</line>
<line />
</if>
<separator />
<foreach items="Gangs" var="gang">
<line align="center" big="true" tall="true" red="true">{{gang.Id}}. {{gang.Name}}</line>
<foreach items="gang.Dishes" var="dish">
<line tall="true" wrap="true">{{dish.GuestPrefix}}{{dish.Number}}x {{dish.Name}}</line>
<if test="dish.Modifications.HasModifications">
<foreach items="dish.Modifications.Removed" var="removed">
<line tall="true" bold="true"> - {{removed}}</line>
</foreach>
<foreach items="dish.Modifications.Added" var="added">
<line tall="true" bold="true"> + {{added}}</line>
</foreach>
</if>
<if test="dish.Comment">
<line tall="true" bold="true">Comment: {{dish.Comment}}</line>
</if>
</foreach>
<if test="!$last">
<cut />
<line />
<line />
</if>
</foreach>
<if test="HasGangs">
<separator />
</if>
<foreach items="Dishes" var="dish">
<line tall="true" wrap="true">{{dish.GuestPrefix}}{{dish.Number}}x {{dish.Name}}</line>
<if test="dish.Modifications.HasModifications">
<foreach items="dish.Modifications.Removed" var="removed">
<line tall="true" bold="true"> - {{removed}}</line>
</foreach>
<foreach items="dish.Modifications.Added" var="added">
<line tall="true" bold="true"> + {{added}}</line>
</foreach>
</if>
<if test="dish.Comment">
<line tall="true" bold="true">Comment: {{dish.Comment}}</line>
</if>
</foreach>
<if test="HasDishes">
<line />
<separator />
<line />
</if>
</receipt>

View File

@@ -0,0 +1,34 @@
<receipt>
<table items="Items" var="item">
<col width="8" align="center">Order:</col>
<col align="center">QR:</col>
<col width="12" align="center">{{DateTime:dd.MM.yyyy}}</col>
</table>
<if test="ClientNotes">
<line>|Client Notes: {{ClientNotes}}</line>
</if>
<separator />
<foreach items="Items" var="item">
<line />
<columns left="{{item.Number}} {{item.Name}}" right="{{item.SizePart}}x{{item.Quantity}}" />
<foreach items="item.SubItems" var="sub">
<line> - {{sub}}</line>
</foreach>
<if test="item.Modifications.HasModifications">
<line> Change of</line>
<line> Ingredients:</line>
<foreach items="item.Modifications.Removed" var="removed">
<line> - {{removed}}</line>
</foreach>
<foreach items="item.Modifications.Added" var="added">
<line> + {{added}}</line>
</foreach>
</if>
<if test="item.Comment">
<line> SpecialInstruction: {{item.Comment}}</line>
</if>
</foreach>
</receipt>

View File

@@ -0,0 +1,579 @@
using Inspectron.Epson;
using Inspectron.Epson.PrintServer.Printers.Utils;
using Inspectron.Epson.PrintServer.Printers.Utils.FinalRecipe;
using Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt;
using Inspectron.Epson.PrintServer.Printers.Utils.KitchenReceipt;
using Inspectron.Epson.PrintServer.Printers.Utils.OrderItems;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace EpsonTest.Templates;
public static class TestHelpers
{
// Default connection settings
public const string DefaultEpsonIp = "127.0.0.1";
public const int DefaultEpsonPort = 8888;
public const string DefaultHtmlPath = @".\test.html";
public const int DefaultPaperWidth = 384;
public const int KitchenPaperWidth = 258;
#region Printer Creation
public static async Task<EpsonPrinter> CreateEpsonPrinterAsync(
string ip = DefaultEpsonIp,
int port = DefaultEpsonPort, int paperWidth = DefaultPaperWidth)
{
var printer = new EpsonPrinter();
await printer.ConnectAsync(ip, port);
return printer;
}
public static async Task<HtmlPrinter> CreateHtmlPrinterAsync(
string path = DefaultHtmlPath,
int paperWidth = DefaultPaperWidth)
{
var printer = new HtmlPrinter(path, paperWidth: paperWidth);
await printer.ConnectAsync("");
return printer;
}
#endregion
#region Print Helpers
public static async Task PrintCommandsToHtmlPrinterAsync(
HtmlPrinter printer,
IEnumerable<PrintCommand> commands,
string? logoPath = null,
int logoWidth = 300,
int logoXPosition = 42)
{
if (logoPath != null)
{
await printer.SetAbsolutePrintPosition(logoXPosition);
await printer.LoadImageAsync(logoPath, logoWidth);
await printer.PrintLoadedImage();
await printer.SetAbsolutePrintPosition(0);
}
await printer.SetCustomLineSpacing(22);
foreach (var command in commands)
{
if (command.IsBig)
await printer.SetFontSizeAsync(2, 1);
else
await printer.SetFontSizeAsync(1, 1);
await printer.SetEmphasized(command.IsBold);
await printer.PrintTextAsync(command.Text + "\n");
}
}
public static async Task PrintCommandsToEpsonPrinterAsync(
EpsonPrinter printer,
IEnumerable<PrintCommand> commands,
bool useDelay = true,
int delayMs = 200)
{
await printer.FeedLinesAsync(1);
await printer.SetCustomLineSpacing(22);
foreach (var command in commands)
{
if (command.IsCut)
{
await printer.FeedLinesAsync(10);
if (useDelay) await Task.Delay(delayMs * 5);
await printer.CutAsync(false);
if (useDelay) await Task.Delay(delayMs);
continue;
}
await printer.SetBiggerFontTM220(command.IsBig, command.IsTall | command.IsBig, secondaryFont: false);
if (useDelay) await Task.Delay(delayMs);
await printer.SetRedColor(command.IsRed);
if (useDelay) await Task.Delay(delayMs);
await printer.SetEmphasized(command.IsBold);
if (useDelay) await Task.Delay(delayMs);
await printer.PrintTextAsync(command.Text + "\n");
if (useDelay) await Task.Delay(delayMs);
}
await printer.FeedLinesAsync(10);
if (useDelay) await Task.Delay(delayMs * 5);
await printer.CutAsync();
}
public static async Task PrintKitchenCommandsToHtmlPrinterAsync(
HtmlPrinter printer,
IEnumerable<PrintCommand> commands)
{
await printer.FeedLinesAsync(1);
await printer.SetCustomLineSpacing(22);
foreach (var command in commands)
{
if (command.IsCut)
{
await printer.CutAsync(false);
continue;
}
if (command.IsBig)
await printer.SetFontSizeAsync(2, 1);
else
await printer.SetFontSizeAsync(1, 1);
await printer.SetRedColor(command.IsRed);
await printer.SetEmphasized(command.IsBold);
await printer.PrintTextAsync(command.Text + "\n");
}
}
#endregion
#region Console Output
public static void PrintCommandsToConsole(IEnumerable<PrintCommand> commands)
{
Console.WriteLine("=== PRINT COMMANDS ===\n");
foreach (var command in commands)
{
string attributes = "";
if (command.IsBig) attributes += "[BIG] ";
if (command.IsBold) attributes += "[BOLD] ";
if (command.IsRed) attributes += "[RED] ";
Console.WriteLine($"{attributes}{command.Text}");
}
}
#endregion
#region JSON Data Builders
public static string BuildKitchenReceiptJson()
{
var receipt = new KitchenReceipt
{
Title = "Warme Küche",
TransactionDateTime = new DateTime(2025, 10, 23, 12, 18, 0),
ReceiptNumber = "132018166",
WaiterName = "Mariano Amato",
WaiterId = "32 (VK Restaurant)",
TableNumber = "22"
};
receipt.Gangs.Add(new Gang(1, "Gang"));
receipt.Gangs.Add(new Gang(2, "Gang"));
receipt.Gangs[0].Dishes.Add(new Dish(1, "Avocado Sashimi")
{
Modifications = new DishModifications
{
Removed = new List<string> { "Wasabi" },
Added = new List<string> { "Extra Ginger" }
},
Comment = "No soy sauce",
GuestId = 1
});
receipt.Gangs[0].Dishes.Add(new Dish(1, "Baby Spinach Salad with Truffl") { GuestId = 2 });
receipt.Gangs[1].Dishes.Add(new Dish(1, "Avocado Sashimi") { GuestId = 1 });
receipt.Gangs[1].Dishes.Add(new Dish(1, "Baby Spinach Salad with Truffl") { GuestId = 2 });
return SerializeWithComputedProps(receipt);
}
public static string BuildKitchenReceiptNextDishJson()
{
var receipt = new KitchenReceipt
{
Title = "Warme Küche",
TransactionDateTime = new DateTime(2025, 10, 23, 12, 18, 0),
ReceiptNumber = "132018166",
WaiterName = "Mariano Amato",
WaiterId = "32 (VK Restaurant)",
TableNumber = "22",
SpecialInstruction = "Gang schicken"
};
receipt.Gangs.Add(new Gang(2, "Gang"));
return SerializeWithComputedProps(receipt);
}
public static string BuildFinalReceiptJson(bool includeTax = true)
{
var receipt = new FinalReceipt
{
CompanyName = "Klingler Gastro AG",
Address1 = "Münzplatz 3",
Address2 = "CH-8001 Zürich",
Phone = "043 321 22 22",
ReceiptNumber = null,
DateTime = new DateTime(2025, 12, 16, 14, 58, 0),
Guests = 2,
Total = 160.00m,
Currency = "CHF",
TotalInAlternateCurrency = 172.80m,
AlternateCurrency = "EUR",
PaymentMethod = "MASTER",
PaymentAmount = 160.00m,
WaiterName = "Yves",
Terminal = "Hauptkasse ZH",
TableNumber = "12",
VatNumber = "CHE-449.635.880 MWST",
ThankYouMessage = "Das Team bedankt sich herzlich für Ihren",
GoodbyeMessageLine1 = "Besuch.",
GoodbyeMessageLine2 = "Auf Wiedersehen.",
IsDebtor = true,
SplitPayments = new List<SplitPaymentInfo>
{
new() { PaymentMethod = "Cash", Amount = 50.00m, Currency = "CHF" },
new() { PaymentMethod = "Card", Amount = 110.00m, Currency = "CHF" }
},
TerminalReceipts = new List<PaymentTerminalReceipt>
{
new()
{
ReceiptType = "*** Kundenbeleg ***",
BookingType = "Buchung",
PaymentSystem = "TWINT",
TransactionNumber = "XXXXXXXXXXXXXXX1494",
TransactionDateTime = new DateTime(2025, 11, 11, 12, 34, 49),
TerminalId = "31108834",
AID = "A0000015749E",
TransactionSeqCount = "6127",
TransactionRefNo = "99036644599",
AuthCode = "0d3396",
AcquirerId = "2",
EftAmount = 67.00m,
TipAmount = 6.70m,
TotalEftAmount = 73.70m,
Currency = "CHF"
},
new()
{
ReceiptType = "*** Kundenbeleg ***",
BookingType = "Buchung",
PaymentSystem = "MASTER",
TransactionNumber = "XXXXXXXXXXXXXXX2587",
TransactionDateTime = new DateTime(2025, 11, 11, 12, 36, 12),
TerminalId = "31108834",
AID = "A0000000041010",
TransactionSeqCount = "6128",
TransactionRefNo = "99036644600",
AuthCode = "1a4b2c",
AcquirerId = "1",
EftAmount = 93.00m,
TipAmount = 0.00m,
TotalEftAmount = 93.00m,
Currency = "CHF"
}
},
DiscountInfo = new DiscountInfo()
{
Amount = -50.00m,
Currency = "CHF",
Description = "Fine Dine",
}
};
receipt.Items.Add(new FinalReceiptItem
{
Quantity = 1, Description = "San Pellegrino 50cl",
UnitPrice = 6.50m, TotalPrice = 6.50m, TaxCategory = "A"
});
receipt.Items.Add(new FinalReceiptItem
{
Quantity = 1, Description = "Panna 50cl",
UnitPrice = 6.50m, TotalPrice = 6.50m, TaxCategory = "A"
});
receipt.Items.Add(new FinalReceiptItem
{
Quantity = 1, Description = "An item with really long description that should wrap",
UnitPrice = 5.50m, TotalPrice = 5.50m, TaxCategory = "A"
});
receipt.Items.Add(new FinalReceiptItem
{
Quantity = 2, Description = "Business Lunch Menu Seco",
UnitPrice = 44.00m, TotalPrice = 88.00m, TaxCategory = "A",
SubItems = new List<string> { "Tomato Soup", "Grilled Salmon", "Tiramisu" }
});
receipt.Items.Add(new FinalReceiptItem
{
Quantity = 2, Description = "Brunello di Montalcino 1",
UnitPrice = 16.00m, TotalPrice = 32.00m, TaxCategory = "A"
});
receipt.Items.Add(new FinalReceiptItem
{
Quantity = 2, Description = "Espresso",
UnitPrice = 5.50m, TotalPrice = 11.00m, TaxCategory = "A"
});
receipt.Items.Add(new FinalReceiptItem
{
Quantity = 1, Description = "Tip",
UnitPrice = 10.50m, TotalPrice = 10.50m, TaxCategory = "B"
});
if (includeTax)
{
receipt.TaxBreakdown.Add(new TaxInfo
{
Category = "A", Rate = 8.1m, Gross = 149.50m,
Net = 138.30m, TaxAmount = 11.20m, Currency = "CHF"
});
receipt.TaxBreakdown.Add(new TaxInfo
{
Category = "B", Rate = 0m, Gross = 10.50m,
Net = 10.50m, TaxAmount = 0.00m, Currency = "CHF"
});
}
return SerializeWithComputedProps(receipt);
}
public static string BuildInvoiceReceiptJson()
{
var receipt = new InvoiceReceipt
{
CompanyName = "Klingler Gastro AG",
Address1 = "Münzplatz 3",
Address2 = "CH-8001 Zürich",
Phone = "043 321 22 22",
ReceiptNumber = null,
DateTime = new DateTime(2025, 12, 16, 14, 58, 0),
Guests = 2,
Total = 160.00m,
Currency = "CHF",
TableNumber = "12",
ThankYouMessage = "Thank you!"
};
receipt.Items.Add(new Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt.ReceiptItem
{
Quantity = 1, Description = "San Pellegrino 50cl",
UnitPrice = 6.50m, TotalPrice = 6.50m, TaxCategory = "A"
});
receipt.Items.Add(new Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt.ReceiptItem
{
Quantity = 1, Description = "Panna 50cl",
UnitPrice = 6.50m, TotalPrice = 6.50m, TaxCategory = "A"
});
receipt.Items.Add(new Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt.ReceiptItem
{
Quantity = 1, Description = "Granini Tomatensaft",
UnitPrice = 5.50m, TotalPrice = 5.50m, TaxCategory = "A"
});
receipt.Items.Add(new Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt.ReceiptItem
{
Quantity = 2, Description = "Business Lunch Menu Seco",
UnitPrice = 44.00m, TotalPrice = 88.00m, TaxCategory = "A",
SubItems = new List<string> { "Tomato Soup", "Grilled Salmon", "Tiramisu" }
});
receipt.Items.Add(new Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt.ReceiptItem
{
Quantity = 2, Description = "Brunello di Montalcino 1",
UnitPrice = 16.00m, TotalPrice = 32.00m, TaxCategory = "A"
});
receipt.Items.Add(new Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt.ReceiptItem
{
Quantity = 2, Description = "Espresso",
UnitPrice = 5.50m, TotalPrice = 11.00m, TaxCategory = "A"
});
receipt.Items.Add(new Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt.ReceiptItem
{
Quantity = 1, Description = "Tip",
UnitPrice = 10.50m, TotalPrice = 10.50m, TaxCategory = "B"
});
return SerializeWithComputedProps(receipt);
}
public static string BuildOrderItemsJson()
{
var receipt = new OrderItemsReceipt
{
OrderNumber = "1",
QRInfo = "Margherita Pizza Classic longer text",
DateTime = new DateTime(2026, 1, 14, 14, 35, 47),
ClientNotes = "Some main note",
Items = new List<OrderItem>
{
new()
{
Number = 1, Name = "pappara", Quantity = 1,
SubItems = new List<string> { "pizza", "cola" },
Comment = "Cola zero please"
},
new()
{
Number = 2, Name = "Classic Beef Burger", Quantity = 1,
Modifications = new ItemModifications
{
Removed = new List<string> { "Onion" },
Added = new List<string> { "butter" }
},
Comment = "Medium rare please"
},
new()
{
Number = 3, Name = "Margherita Pizza Classic",
Quantity = 2, Size = "L"
}
}
};
return SerializeWithComputedProps(receipt);
}
#endregion
#region Computed Properties
private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true };
private static string SerializeWithComputedProps(object receipt)
{
var json = JsonSerializer.Serialize(receipt, SerializerOptions);
var node = JsonNode.Parse(json)!;
switch (receipt)
{
case KitchenReceipt kr:
AddKitchenComputedProps(node, kr);
break;
case FinalReceipt fr:
AddFinalReceiptComputedProps(node, fr);
break;
case InvoiceReceipt ir:
AddInvoiceComputedProps(node, ir);
break;
case OrderItemsReceipt or:
AddOrderItemsComputedProps(node, or);
break;
}
return node.ToJsonString(SerializerOptions);
}
private static void AddKitchenComputedProps(JsonNode node, KitchenReceipt receipt)
{
node["HasGangs"] = receipt.Gangs.Count > 0;
node["HasDishes"] = receipt.Dishes.Count > 0;
// Add GuestPrefix and HasModifications to dishes in gangs
var gangsArray = node["Gangs"]?.AsArray();
if (gangsArray != null)
{
for (int g = 0; g < receipt.Gangs.Count; g++)
{
var gangDishes = gangsArray[g]?["Dishes"]?.AsArray();
if (gangDishes != null)
{
for (int d = 0; d < receipt.Gangs[g].Dishes.Count; d++)
{
var dish = receipt.Gangs[g].Dishes[d];
gangDishes[d]!["GuestPrefix"] = dish.GuestId.HasValue ? $"{dish.GuestId} " : "";
var mods = dish.Modifications;
bool hasMods = mods != null && (mods.Removed.Count > 0 || mods.Added.Count > 0);
if (gangDishes[d]!["Modifications"] == null)
gangDishes[d]!["Modifications"] = new JsonObject();
gangDishes[d]!["Modifications"]!["HasModifications"] = hasMods;
}
}
}
}
// Add GuestPrefix and HasModifications to top-level dishes
var dishesArray = node["Dishes"]?.AsArray();
if (dishesArray != null)
{
for (int d = 0; d < receipt.Dishes.Count; d++)
{
var dish = receipt.Dishes[d];
dishesArray[d]!["GuestPrefix"] = dish.GuestId.HasValue ? $"{dish.GuestId} " : "";
var mods = dish.Modifications;
bool hasMods = mods != null && (mods.Removed.Count > 0 || mods.Added.Count > 0);
if (dishesArray[d]!["Modifications"] == null)
dishesArray[d]!["Modifications"] = new JsonObject();
dishesArray[d]!["Modifications"]!["HasModifications"] = hasMods;
}
}
}
private static void AddFinalReceiptComputedProps(JsonNode node, FinalReceipt receipt)
{
node["ReceiptLabel"] = receipt.ReceiptNumber != null
? $"Rechnung Nr. {receipt.ReceiptNumber}"
: "";
node["HasSplitPayments"] = receipt.SplitPayments.Count > 0;
node["HasTaxableCategories"] = receipt.TaxBreakdown.Any(t =>
!string.Equals(t.Category, "d", StringComparison.OrdinalIgnoreCase));
var itemsArray = node["Items"]?.AsArray();
if (itemsArray != null)
{
for (int i = 0; i < receipt.Items.Count; i++)
{
var item = receipt.Items[i];
itemsArray[i]!["PriceDisplay"] =
$"{item.UnitPrice:F2}" +
$"{item.TotalPrice:F2}".PadLeft(7) +
$" {item.TaxCategory}";
}
}
}
private static void AddInvoiceComputedProps(JsonNode node, InvoiceReceipt receipt)
{
node["ReceiptLabel"] = receipt.ReceiptNumber != null
? $"Rechnung Nr. {receipt.ReceiptNumber}"
: "";
var itemsArray = node["Items"]?.AsArray();
if (itemsArray != null)
{
for (int i = 0; i < receipt.Items.Count; i++)
{
var item = receipt.Items[i];
itemsArray[i]!["PriceDisplay"] =
$"{item.UnitPrice:F2}" +
$"{item.TotalPrice:F2}".PadLeft(7) +
$" {item.TaxCategory}";
}
}
}
private static void AddOrderItemsComputedProps(JsonNode node, OrderItemsReceipt receipt)
{
var itemsArray = node["Items"]?.AsArray();
if (itemsArray != null)
{
for (int i = 0; i < receipt.Items.Count; i++)
{
var item = receipt.Items[i];
itemsArray[i]!["SizePart"] = !string.IsNullOrEmpty(item.Size)
? item.Size + " "
: "";
var mods = item.Modifications;
bool hasMods = mods != null && (mods.Removed.Count > 0 || mods.Added.Count > 0);
if (itemsArray[i]!["Modifications"] == null)
itemsArray[i]!["Modifications"] = new JsonObject();
itemsArray[i]!["Modifications"]!["HasModifications"] = hasMods;
}
}
}
#endregion
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,290 @@
using System.Text.Json;
using Inspectron.Epson.TemplateEngine.DataBinding;
namespace Inspectron.Epson.TemplateEngine.Tests;
public class ExpressionEvaluatorTests
{
private static DataContext CreateContext(string json)
{
var root = JsonDocument.Parse(json).RootElement;
return new DataContext(root);
}
[Fact]
public void Evaluate_SimpleProperty()
{
var ctx = CreateContext("""{"Name":"John"}""");
var eval = new ExpressionEvaluator(ctx);
Assert.Equal("Hello John", eval.Evaluate("Hello {{Name}}"));
}
[Fact]
public void Evaluate_NestedProperty()
{
var ctx = CreateContext("""{"Person":{"Name":"Alice"}}""");
var eval = new ExpressionEvaluator(ctx);
Assert.Equal("Hi Alice", eval.Evaluate("Hi {{Person.Name}}"));
}
[Fact]
public void Evaluate_MissingProperty_ReturnsEmpty()
{
var ctx = CreateContext("""{"Name":"John"}""");
var eval = new ExpressionEvaluator(ctx);
Assert.Equal("Hi ", eval.Evaluate("Hi {{Missing}}"));
}
[Fact]
public void Evaluate_NumberProperty()
{
var ctx = CreateContext("""{"Count":42}""");
var eval = new ExpressionEvaluator(ctx);
Assert.Equal("Count: 42", eval.Evaluate("Count: {{Count}}"));
}
[Fact]
public void Evaluate_FormatString_Decimal()
{
var ctx = CreateContext("""{"Price":9.5}""");
var eval = new ExpressionEvaluator(ctx);
Assert.Equal("9.50", eval.Evaluate("{{Price:F2}}"));
}
[Fact]
public void Evaluate_FormatString_DateTime()
{
var ctx = CreateContext("""{"Date":"2024-03-15T14:30:00Z"}""");
var eval = new ExpressionEvaluator(ctx);
Assert.Equal("15-Mar-24 14:30", eval.Evaluate("{{Date:dd-MMM-yy HH:mm}}"));
}
[Fact]
public void Evaluate_MultipleExpressions()
{
var ctx = CreateContext("""{"First":"John","Last":"Doe"}""");
var eval = new ExpressionEvaluator(ctx);
Assert.Equal("John Doe", eval.Evaluate("{{First}} {{Last}}"));
}
[Fact]
public void Evaluate_CaseInsensitivePropertyLookup()
{
var ctx = CreateContext("""{"name":"Alice"}""");
var eval = new ExpressionEvaluator(ctx);
Assert.Equal("Alice", eval.Evaluate("{{Name}}"));
}
[Fact]
public void Evaluate_EmptyTemplate()
{
var ctx = CreateContext("""{}""");
var eval = new ExpressionEvaluator(ctx);
Assert.Equal("", eval.Evaluate(""));
}
[Fact]
public void Evaluate_NoExpressions()
{
var ctx = CreateContext("""{}""");
var eval = new ExpressionEvaluator(ctx);
Assert.Equal("plain text", eval.Evaluate("plain text"));
}
[Fact]
public void Evaluate_LoopVariable_Index()
{
var ctx = CreateContext("""{}""");
ctx.SetLoopVariable("$index", 3);
var eval = new ExpressionEvaluator(ctx);
Assert.Equal("Index: 3", eval.Evaluate("Index: {{$index}}"));
}
[Fact]
public void EvaluateCondition_TruthyString()
{
var ctx = CreateContext("""{"Name":"John"}""");
var eval = new ExpressionEvaluator(ctx);
Assert.True(eval.EvaluateCondition("Name"));
}
[Fact]
public void EvaluateCondition_EmptyString_IsFalsy()
{
var ctx = CreateContext("""{"Name":""}""");
var eval = new ExpressionEvaluator(ctx);
Assert.False(eval.EvaluateCondition("Name"));
}
[Fact]
public void EvaluateCondition_MissingProperty_IsFalsy()
{
var ctx = CreateContext("""{}""");
var eval = new ExpressionEvaluator(ctx);
Assert.False(eval.EvaluateCondition("Name"));
}
[Fact]
public void EvaluateCondition_Negation()
{
var ctx = CreateContext("""{"Name":"John"}""");
var eval = new ExpressionEvaluator(ctx);
Assert.False(eval.EvaluateCondition("!Name"));
}
[Fact]
public void EvaluateCondition_Negation_Missing()
{
var ctx = CreateContext("""{}""");
var eval = new ExpressionEvaluator(ctx);
Assert.True(eval.EvaluateCondition("!Name"));
}
[Fact]
public void EvaluateCondition_NullValue_IsFalsy()
{
var ctx = CreateContext("""{"Name":null}""");
var eval = new ExpressionEvaluator(ctx);
Assert.False(eval.EvaluateCondition("Name"));
}
[Fact]
public void EvaluateCondition_Comparison_Equal()
{
var ctx = CreateContext("""{"Count":5}""");
var eval = new ExpressionEvaluator(ctx);
Assert.True(eval.EvaluateCondition("Count==5"));
}
[Fact]
public void EvaluateCondition_Comparison_NotEqual()
{
var ctx = CreateContext("""{"Count":5}""");
var eval = new ExpressionEvaluator(ctx);
Assert.True(eval.EvaluateCondition("Count!=3"));
}
[Fact]
public void EvaluateCondition_Comparison_GreaterThan()
{
var ctx = CreateContext("""{"Count":5}""");
var eval = new ExpressionEvaluator(ctx);
Assert.True(eval.EvaluateCondition("Count>3"));
}
[Fact]
public void EvaluateCondition_Comparison_StringEqual()
{
var ctx = CreateContext("""{"Status":"active"}""");
var eval = new ExpressionEvaluator(ctx);
Assert.True(eval.EvaluateCondition("Status=='active'"));
}
[Fact]
public void EvaluateCondition_LoopVariable_First()
{
var ctx = CreateContext("""{}""");
ctx.SetLoopVariable("$first", true);
var eval = new ExpressionEvaluator(ctx);
Assert.True(eval.EvaluateCondition("$first"));
}
[Fact]
public void EvaluateCondition_LoopVariable_NotLast()
{
var ctx = CreateContext("""{}""");
ctx.SetLoopVariable("$last", false);
var eval = new ExpressionEvaluator(ctx);
Assert.True(eval.EvaluateCondition("!$last"));
}
[Fact]
public void EvaluateCondition_NonEmptyArray_IsTruthy()
{
var ctx = CreateContext("""{"Items":[1,2,3]}""");
var eval = new ExpressionEvaluator(ctx);
Assert.True(eval.EvaluateCondition("Items"));
}
[Fact]
public void EvaluateCondition_EmptyArray_IsFalsy()
{
var ctx = CreateContext("""{"Items":[]}""");
var eval = new ExpressionEvaluator(ctx);
Assert.False(eval.EvaluateCondition("Items"));
}
[Fact]
public void EvaluateCondition_BooleanTrue_IsTruthy()
{
var ctx = CreateContext("""{"Active":true}""");
var eval = new ExpressionEvaluator(ctx);
Assert.True(eval.EvaluateCondition("Active"));
}
[Fact]
public void EvaluateCondition_BooleanFalse_IsFalsy()
{
var ctx = CreateContext("""{"Active":false}""");
var eval = new ExpressionEvaluator(ctx);
Assert.False(eval.EvaluateCondition("Active"));
}
[Fact]
public void DataContext_ChildScope_InheritsParentVariables()
{
var ctx = CreateContext("""{}""");
var item = JsonDocument.Parse("""{"Name":"Pizza"}""").RootElement;
ctx.SetVariable("item", item);
var child = ctx.CreateChildScope();
var childEval = new ExpressionEvaluator(child);
Assert.Equal("Pizza", childEval.Evaluate("{{item.Name}}"));
}
[Fact]
public void DataContext_ChildScope_OverridesParentVariable()
{
var ctx = CreateContext("""{}""");
var item1 = JsonDocument.Parse("""{"Name":"Pizza"}""").RootElement;
ctx.SetVariable("item", item1);
var child = ctx.CreateChildScope();
var item2 = JsonDocument.Parse("""{"Name":"Pasta"}""").RootElement;
child.SetVariable("item", item2);
var childEval = new ExpressionEvaluator(child);
Assert.Equal("Pasta", childEval.Evaluate("{{item.Name}}"));
var parentEval = new ExpressionEvaluator(ctx);
Assert.Equal("Pizza", parentEval.Evaluate("{{item.Name}}"));
}
}

View File

@@ -0,0 +1 @@
global using Xunit;

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RollForward>Major</RollForward>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Inspectron.Epson.TemplateEngine\Inspectron.Epson.TemplateEngine.csproj" />
<ProjectReference Include="..\Inspectron.Epson\Inspectron.Epson.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Templates\*.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,298 @@
using Inspectron.Epson.PrintServer.Printers.Utils;
using Inspectron.Epson.PrintServer.Printers.Utils.KitchenReceipt;
using System.Text.Json;
namespace Inspectron.Epson.TemplateEngine.Tests;
public class KitchenReceiptTemplateTests
{
private const int LineWidth = 42;
private const int BigLineWidth = 22;
private readonly ReceiptTemplateEngine _engine = new();
private static string GetTemplatePath()
{
return Path.Combine(AppContext.BaseDirectory, "Templates", "KitchenReceipt.xml");
}
[Fact]
public void RenderKitchenReceipt_BasicStructure()
{
var template = File.ReadAllText(GetTemplatePath());
var json = """
{
"Title": "Test Restaurant",
"TransactionDateTime": "2024-03-15T14:30:00Z",
"ReceiptNumber": "42",
"WaiterName": "Max",
"TableNumber": "5",
"SpecialInstruction": null,
"HasGangs": false,
"HasDishes": false,
"Gangs": [],
"Dishes": []
}
""";
var commands = _engine.Render(template, json, LineWidth, BigLineWidth);
Assert.True(commands.Count > 0);
// First line: title centered in big font, red
Assert.True(commands[0].IsBig);
Assert.True(commands[0].IsTall);
Assert.True(commands[0].IsRed);
Assert.Contains("Test Restaurant", commands[0].Text);
// Second line: separator
Assert.Equal(new string('-', LineWidth), commands[1].Text);
// Empty line
Assert.Equal("", commands[2].Text);
}
[Fact]
public void RenderKitchenReceipt_WithSpecialInstruction()
{
var template = File.ReadAllText(GetTemplatePath());
var json = """
{
"Title": "Test Restaurant",
"TransactionDateTime": "2024-03-15T14:30:00Z",
"ReceiptNumber": "42",
"WaiterName": "Max",
"TableNumber": "5",
"SpecialInstruction": "RUSH ORDER",
"HasGangs": false,
"HasDishes": false,
"Gangs": [],
"Dishes": []
}
""";
var commands = _engine.Render(template, json, LineWidth, BigLineWidth);
// Should contain the special instruction
var specialCommands = commands.Where(c => c.Text.Contains("RUSH ORDER")).ToList();
Assert.NotEmpty(specialCommands);
Assert.True(specialCommands[0].IsBig);
Assert.True(specialCommands[0].IsBold);
}
[Fact]
public void RenderKitchenReceipt_WithGangs()
{
var template = File.ReadAllText(GetTemplatePath());
var json = """
{
"Title": "Test Restaurant",
"TransactionDateTime": "2024-03-15T14:30:00Z",
"ReceiptNumber": "42",
"WaiterName": "Max",
"TableNumber": "5",
"SpecialInstruction": null,
"HasGangs": true,
"HasDishes": false,
"Gangs": [
{
"Id": 1,
"Name": "Vorspeise",
"Dishes": [
{
"Number": 2,
"Name": "Caesar Salad",
"GuestPrefix": "",
"GuestId": null,
"Modifications": { "Removed": [], "Added": [], "HasModifications": false },
"Comment": null
}
]
},
{
"Id": 2,
"Name": "Hauptgang",
"Dishes": [
{
"Number": 1,
"Name": "Wiener Schnitzel",
"GuestPrefix": "",
"GuestId": null,
"Modifications": { "Removed": ["Pommes"], "Added": ["Reis"], "HasModifications": true },
"Comment": "Well done"
}
]
}
],
"Dishes": []
}
""";
var commands = _engine.Render(template, json, LineWidth, BigLineWidth);
// Should contain gang names
Assert.Contains(commands, c => c.Text.Contains("1. Vorspeise"));
Assert.Contains(commands, c => c.Text.Contains("2. Hauptgang"));
// Should contain dish names
Assert.Contains(commands, c => c.Text.Contains("2x Caesar Salad"));
Assert.Contains(commands, c => c.Text.Contains("1x Wiener Schnitzel"));
// Should contain modifications
Assert.Contains(commands, c => c.Text.Contains("- Pommes") && c.IsBold);
Assert.Contains(commands, c => c.Text.Contains("+ Reis") && c.IsBold);
// Should contain comment
Assert.Contains(commands, c => c.Text.Contains("Comment: Well done") && c.IsBold);
// Should have cut between gangs (not after last)
var cutCommands = commands.Where(c => c.IsCut).ToList();
Assert.Single(cutCommands);
// Gang headers should be red and big
var gangHeaders = commands.Where(c => c.Text.Contains("Vorspeise") || c.Text.Contains("Hauptgang")).ToList();
Assert.All(gangHeaders, c =>
{
Assert.True(c.IsRed);
Assert.True(c.IsBig);
Assert.True(c.IsTall);
});
}
[Fact]
public void RenderKitchenReceipt_DishesAreTall()
{
var template = File.ReadAllText(GetTemplatePath());
var json = """
{
"Title": "Test",
"TransactionDateTime": "2024-01-01T00:00:00Z",
"ReceiptNumber": "1",
"WaiterName": "W",
"TableNumber": "1",
"SpecialInstruction": null,
"HasGangs": true,
"HasDishes": false,
"Gangs": [
{
"Id": 1,
"Name": "Gang1",
"Dishes": [
{
"Number": 1,
"Name": "Item",
"GuestPrefix": "",
"GuestId": null,
"Modifications": { "Removed": [], "Added": [], "HasModifications": false },
"Comment": null
}
]
}
],
"Dishes": []
}
""";
var commands = _engine.Render(template, json, LineWidth, BigLineWidth);
// Dish line should be tall
var dishCmd = commands.First(c => c.Text.Contains("1x Item"));
Assert.True(dishCmd.IsTall);
}
[Fact]
public void RenderKitchenReceipt_TableLineCenteredBigBold()
{
var template = File.ReadAllText(GetTemplatePath());
var json = """
{
"Title": "Test",
"TransactionDateTime": "2024-01-01T00:00:00Z",
"ReceiptNumber": "1",
"WaiterName": "W",
"TableNumber": "12",
"SpecialInstruction": null,
"HasGangs": false,
"HasDishes": false,
"Gangs": [],
"Dishes": []
}
""";
var commands = _engine.Render(template, json, LineWidth, BigLineWidth);
var tableCmd = commands.First(c => c.Text.Contains("Tisch: 12"));
Assert.True(tableCmd.IsBig);
Assert.True(tableCmd.IsBold);
// Should be centered in big font width
Assert.True(tableCmd.Text.StartsWith(" "));
}
[Fact]
public void CompareWithExistingConverter_BasicReceipt()
{
// Create a KitchenReceipt via the existing converter
var receipt = new KitchenReceipt
{
Title = "Test Restaurant",
TransactionDateTime = new DateTime(2024, 3, 15, 14, 30, 0),
ReceiptNumber = "42",
WaiterName = "Max Mustermann",
WaiterId = "W001",
TableNumber = "5",
SpecialInstruction = null,
Gangs = new List<Gang>(),
Dishes = new List<Dish>()
};
var existingConverter = new KitchenReceiptConverter(LineWidth, BigLineWidth);
var existingCommands = existingConverter.ConvertToPrintCommands(receipt);
// Render with template engine
var template = File.ReadAllText(GetTemplatePath());
var jsonData = JsonSerializer.Serialize(new
{
receipt.Title,
TransactionDateTime = receipt.TransactionDateTime.ToString("o"),
receipt.ReceiptNumber,
receipt.WaiterName,
receipt.TableNumber,
receipt.SpecialInstruction,
HasGangs = receipt.Gangs.Count > 0,
HasDishes = receipt.Dishes != null && receipt.Dishes.Any(),
receipt.Gangs,
receipt.Dishes
});
var templateCommands = _engine.Render(template, jsonData, LineWidth, BigLineWidth);
// Compare key structural elements
// Title should match
Assert.Equal(existingCommands[0].Text, templateCommands[0].Text);
Assert.Equal(existingCommands[0].IsRed, templateCommands[0].IsRed);
Assert.Equal(existingCommands[0].IsBig, templateCommands[0].IsBig);
Assert.Equal(existingCommands[0].IsTall, templateCommands[0].IsTall);
// Separator should match
Assert.Equal(existingCommands[1].Text, templateCommands[1].Text);
// Empty line
Assert.Equal(existingCommands[2].Text, templateCommands[2].Text);
// Date/receipt number line should match formatting
Assert.Equal(existingCommands[3].Text, templateCommands[3].Text);
// Waiter name
Assert.Equal(existingCommands[4].Text, templateCommands[4].Text);
// Table number
Assert.Equal(existingCommands[5].Text, templateCommands[5].Text);
Assert.Equal(existingCommands[5].IsBig, templateCommands[5].IsBig);
Assert.Equal(existingCommands[5].IsBold, templateCommands[5].IsBold);
}
}

View File

@@ -0,0 +1,154 @@
using Inspectron.Epson.TemplateEngine.Rendering;
namespace Inspectron.Epson.TemplateEngine.Tests;
public class LayoutEngineTests
{
private readonly LayoutEngine _engine = new();
[Fact]
public void AlignText_Center()
{
var result = _engine.AlignText("Hello", "center", 20);
Assert.Equal(" Hello", result);
}
[Fact]
public void AlignText_Right()
{
var result = _engine.AlignText("Hello", "right", 20);
Assert.Equal(" Hello", result);
}
[Fact]
public void AlignText_Left()
{
var result = _engine.AlignText("Hello", "left", 20);
Assert.Equal("Hello", result);
}
[Fact]
public void AlignText_TextExceedsWidth_NoChange()
{
var result = _engine.AlignText("Very long text", "center", 5);
Assert.Equal("Very long text", result);
}
[Fact]
public void FormatTwoColumns_BasicLayout()
{
var result = _engine.FormatTwoColumns("Left", "Right", 20);
Assert.Equal(20, result.Length);
Assert.StartsWith("Left", result);
Assert.EndsWith("Right", result);
}
[Fact]
public void FormatTwoColumnsWithWrap_ShortText_SingleLine()
{
var result = _engine.FormatTwoColumnsWithWrap("Left", "Right", 20);
Assert.Single(result);
Assert.Contains("Left", result[0]);
Assert.Contains("Right", result[0]);
}
[Fact]
public void FormatTwoColumnsWithWrap_LongLeft_MultipleLines()
{
var result = _engine.FormatTwoColumnsWithWrap(
"This is a very long left column text that needs wrapping",
"9.50",
30);
Assert.True(result.Count >= 1);
Assert.Contains("9.50", result[0]);
}
[Fact]
public void WrapText_ShortText_SingleLine()
{
var result = _engine.WrapText("Hello world", 20);
Assert.Single(result);
Assert.Equal("Hello world", result[0]);
}
[Fact]
public void WrapText_LongText_MultipleLines()
{
var result = _engine.WrapText("The quick brown fox jumps over the lazy dog", 20);
Assert.True(result.Count > 1);
foreach (var line in result)
{
Assert.True(line.TrimStart().Length <= 20);
}
}
[Fact]
public void WrapText_WithIndent()
{
var result = _engine.WrapText("The quick brown fox jumps over the lazy dog", 20, indent: 4);
Assert.True(result.Count > 1);
// First line no indent
Assert.False(result[0].StartsWith(" "));
// Subsequent lines indented
for (int i = 1; i < result.Count; i++)
{
Assert.StartsWith(" ", result[i]);
}
}
[Fact]
public void CreateSeparator()
{
var result = _engine.CreateSeparator('-', 42);
Assert.Equal(new string('-', 42), result);
}
[Fact]
public void CreateSeparator_CustomChar()
{
var result = _engine.CreateSeparator('*', 20);
Assert.Equal(new string('*', 20), result);
}
[Fact]
public void FormatMultiColumn_BasicLayout()
{
var columns = new List<(string text, int width, string align)>
{
("Col1", 10, "left"),
("Col2", 10, "right"),
("Col3", 10, "center")
};
var result = _engine.FormatMultiColumn(columns, 30);
Assert.Equal(30, result.Length);
}
[Fact]
public void FormatTable_BasicTable()
{
var columns = new List<(string text, int width, string align)>
{
("Name", 10, "left"),
("Value", 10, "right")
};
var headers = new List<List<string>> { new() { "Name", "Value" } };
var data = new List<List<string>>
{
new() { "Item1", "100" },
new() { "Item2", "200" }
};
var result = _engine.FormatTable(columns, headers, data, 23);
// Should have: top border, header row, separator, 2 data rows, bottom border
Assert.Equal(6, result.Count);
Assert.StartsWith("+", result[0]);
Assert.StartsWith("|", result[1]);
Assert.StartsWith("+", result[2]);
Assert.StartsWith("|", result[3]);
Assert.StartsWith("|", result[4]);
Assert.StartsWith("+", result[5]);
}
}

View File

@@ -0,0 +1,294 @@
using Inspectron.Epson.TemplateEngine.Parsing;
namespace Inspectron.Epson.TemplateEngine.Tests;
public class TemplateParserTests
{
private readonly TemplateParser _parser = new();
[Fact]
public void Parse_EmptyReceipt()
{
var result = _parser.Parse("<receipt></receipt>");
Assert.NotNull(result);
Assert.Empty(result.Children);
}
[Fact]
public void Parse_InvalidXml_Throws()
{
Assert.Throws<TemplateParsingException>(() => _parser.Parse("not xml"));
}
[Fact]
public void Parse_WrongRoot_Throws()
{
Assert.Throws<TemplateParsingException>(() => _parser.Parse("<div></div>"));
}
[Fact]
public void Parse_UnknownElement_Throws()
{
Assert.Throws<TemplateParsingException>(() =>
_parser.Parse("<receipt><unknown /></receipt>"));
}
[Fact]
public void Parse_Line_BasicText()
{
var result = _parser.Parse("<receipt><line>Hello</line></receipt>");
Assert.Single(result.Children);
var line = Assert.IsType<LineNode>(result.Children[0]);
Assert.Equal("Hello", line.Text);
}
[Fact]
public void Parse_Line_WithFormatting()
{
var result = _parser.Parse(
"""<receipt><line bold="true" big="true" tall="true" red="true" align="center">Text</line></receipt>""");
var line = Assert.IsType<LineNode>(result.Children[0]);
Assert.True(line.Bold);
Assert.True(line.Big);
Assert.True(line.Tall);
Assert.True(line.Red);
Assert.Equal("center", line.Align);
}
[Fact]
public void Parse_Line_WithWrap()
{
var result = _parser.Parse(
"""<receipt><line wrap="true" wrapIndent="4">Long text</line></receipt>""");
var line = Assert.IsType<LineNode>(result.Children[0]);
Assert.True(line.Wrap);
Assert.Equal(4, line.WrapIndent);
}
[Fact]
public void Parse_Line_WithLineSpacing()
{
var result = _parser.Parse(
"""<receipt><line lineSpacing="50">Text</line></receipt>""");
var line = Assert.IsType<LineNode>(result.Children[0]);
Assert.Equal(50, line.LineSpacing);
}
[Fact]
public void Parse_EmptyLine()
{
var result = _parser.Parse("<receipt><line /></receipt>");
var line = Assert.IsType<LineNode>(result.Children[0]);
Assert.Equal("", line.Text);
}
[Fact]
public void Parse_Columns()
{
var result = _parser.Parse(
"""<receipt><columns left="Name" right="Price" bold="true" /></receipt>""");
var col = Assert.IsType<ColumnsNode>(result.Children[0]);
Assert.Equal("Name", col.Left);
Assert.Equal("Price", col.Right);
Assert.True(col.Bold);
}
[Fact]
public void Parse_Row_WithColumns()
{
var result = _parser.Parse("""
<receipt>
<row bold="true">
<col width="10" align="left">Name</col>
<col width="10" align="right">Value</col>
</row>
</receipt>
""");
var row = Assert.IsType<RowNode>(result.Children[0]);
Assert.True(row.Bold);
Assert.Equal(2, row.Columns.Count);
Assert.Equal("Name", row.Columns[0].Text);
Assert.Equal(10, row.Columns[0].Width);
Assert.Equal("left", row.Columns[0].Align);
Assert.Equal("Value", row.Columns[1].Text);
Assert.Equal(10, row.Columns[1].Width);
Assert.Equal("right", row.Columns[1].Align);
}
[Fact]
public void Parse_Separator()
{
var result = _parser.Parse("<receipt><separator /></receipt>");
var sep = Assert.IsType<SeparatorNode>(result.Children[0]);
Assert.Equal('-', sep.Character);
}
[Fact]
public void Parse_Separator_CustomChar()
{
var result = _parser.Parse("""<receipt><separator char="*" /></receipt>""");
var sep = Assert.IsType<SeparatorNode>(result.Children[0]);
Assert.Equal('*', sep.Character);
}
[Fact]
public void Parse_Cut()
{
var result = _parser.Parse("<receipt><cut /></receipt>");
Assert.IsType<CutNode>(result.Children[0]);
}
[Fact]
public void Parse_Feed()
{
var result = _parser.Parse("""<receipt><feed lines="3" /></receipt>""");
var feed = Assert.IsType<FeedNode>(result.Children[0]);
Assert.Equal(3, feed.Lines);
}
[Fact]
public void Parse_Feed_Default()
{
var result = _parser.Parse("<receipt><feed /></receipt>");
var feed = Assert.IsType<FeedNode>(result.Children[0]);
Assert.Equal(1, feed.Lines);
}
[Fact]
public void Parse_Foreach()
{
var result = _parser.Parse("""
<receipt>
<foreach items="Items" var="item">
<line>{{item.Name}}</line>
</foreach>
</receipt>
""");
var fe = Assert.IsType<ForeachNode>(result.Children[0]);
Assert.Equal("Items", fe.Items);
Assert.Equal("item", fe.Var);
Assert.Single(fe.Children);
Assert.IsType<LineNode>(fe.Children[0]);
}
[Fact]
public void Parse_Foreach_MissingItems_Throws()
{
Assert.Throws<TemplateParsingException>(() =>
_parser.Parse("""<receipt><foreach var="item"><line /></foreach></receipt>"""));
}
[Fact]
public void Parse_Foreach_MissingVar_Throws()
{
Assert.Throws<TemplateParsingException>(() =>
_parser.Parse("""<receipt><foreach items="Items"><line /></foreach></receipt>"""));
}
[Fact]
public void Parse_If()
{
var result = _parser.Parse("""
<receipt>
<if test="Name">
<line>Has name</line>
</if>
</receipt>
""");
var ifNode = Assert.IsType<IfNode>(result.Children[0]);
Assert.Equal("Name", ifNode.Test);
Assert.Single(ifNode.Children);
}
[Fact]
public void Parse_If_MissingTest_Throws()
{
Assert.Throws<TemplateParsingException>(() =>
_parser.Parse("<receipt><if><line /></if></receipt>"));
}
[Fact]
public void Parse_IfElse()
{
var result = _parser.Parse("""
<receipt>
<if test="Name">
<line>Yes</line>
</if>
<else>
<line>No</line>
</else>
</receipt>
""");
Assert.Equal(2, result.Children.Count);
Assert.IsType<IfNode>(result.Children[0]);
Assert.IsType<ElseNode>(result.Children[1]);
}
[Fact]
public void Parse_NestedForeach()
{
var result = _parser.Parse("""
<receipt>
<foreach items="Gangs" var="gang">
<foreach items="gang.Dishes" var="dish">
<line>{{dish.Name}}</line>
</foreach>
</foreach>
</receipt>
""");
var outer = Assert.IsType<ForeachNode>(result.Children[0]);
Assert.Equal("Gangs", outer.Items);
var inner = Assert.IsType<ForeachNode>(outer.Children[0]);
Assert.Equal("gang.Dishes", inner.Items);
}
[Fact]
public void Parse_Table()
{
var result = _parser.Parse("""
<receipt>
<table items="Data" var="row">
<col width="10" align="left">Header1</col>
<col width="10" align="right">Header2</col>
</table>
</receipt>
""");
var table = Assert.IsType<TableNode>(result.Children[0]);
Assert.Equal("Data", table.Items);
Assert.Equal("row", table.Var);
Assert.Equal(2, table.Columns.Count);
}
[Fact]
public void Parse_ComplexTemplate()
{
var result = _parser.Parse("""
<receipt>
<line align="center" big="true">Title</line>
<separator />
<line />
<foreach items="Items" var="item">
<line bold="true">{{item.Name}}</line>
<if test="item.HasDiscount">
<line red="true">DISCOUNT</line>
</if>
</foreach>
<cut />
</receipt>
""");
Assert.Equal(5, result.Children.Count);
Assert.IsType<LineNode>(result.Children[0]);
Assert.IsType<SeparatorNode>(result.Children[1]);
Assert.IsType<LineNode>(result.Children[2]);
Assert.IsType<ForeachNode>(result.Children[3]);
Assert.IsType<CutNode>(result.Children[4]);
}
}

View File

@@ -0,0 +1,445 @@
namespace Inspectron.Epson.TemplateEngine.Tests;
public class TemplateRendererTests
{
private readonly ReceiptTemplateEngine _engine = new();
[Fact]
public void Render_EmptyReceipt()
{
var commands = _engine.Render("<receipt></receipt>", "{}", 42, 22);
Assert.Empty(commands);
}
[Fact]
public void Render_SimpleLine()
{
var commands = _engine.Render(
"<receipt><line>Hello World</line></receipt>",
"{}", 42, 22);
Assert.Single(commands);
Assert.Equal("Hello World", commands[0].Text);
}
[Fact]
public void Render_LineWithDataBinding()
{
var commands = _engine.Render(
"<receipt><line>Hello {{Name}}</line></receipt>",
"""{"Name":"John"}""", 42, 22);
Assert.Single(commands);
Assert.Equal("Hello John", commands[0].Text);
}
[Fact]
public void Render_LineWithFormatting()
{
var commands = _engine.Render(
"""<receipt><line bold="true" big="true" tall="true" red="true">Text</line></receipt>""",
"{}", 42, 22);
var cmd = commands[0];
Assert.True(cmd.IsBold);
Assert.True(cmd.IsBig);
Assert.True(cmd.IsTall);
Assert.True(cmd.IsRed);
}
[Fact]
public void Render_LineWithCenterAlignment()
{
var commands = _engine.Render(
"""<receipt><line align="center">Test</line></receipt>""",
"{}", 42, 22);
Assert.Equal(" Test", commands[0].Text);
}
[Fact]
public void Render_LineWithCenterAlignment_BigFont()
{
var commands = _engine.Render(
"""<receipt><line align="center" big="true">Test</line></receipt>""",
"{}", 42, 22);
Assert.Equal(" Test", commands[0].Text);
}
[Fact]
public void Render_EmptyLine()
{
var commands = _engine.Render(
"<receipt><line /></receipt>",
"{}", 42, 22);
Assert.Single(commands);
Assert.Equal("", commands[0].Text);
}
[Fact]
public void Render_Separator()
{
var commands = _engine.Render(
"<receipt><separator /></receipt>",
"{}", 42, 22);
Assert.Single(commands);
Assert.Equal(new string('-', 42), commands[0].Text);
}
[Fact]
public void Render_Cut()
{
var commands = _engine.Render(
"<receipt><cut /></receipt>",
"{}", 42, 22);
Assert.Single(commands);
Assert.True(commands[0].IsCut);
}
[Fact]
public void Render_Feed()
{
var commands = _engine.Render(
"""<receipt><feed lines="3" /></receipt>""",
"{}", 42, 22);
Assert.Equal(3, commands.Count);
Assert.All(commands, c => Assert.Equal("", c.Text));
}
[Fact]
public void Render_Columns()
{
var commands = _engine.Render(
"""<receipt><columns left="Name" right="Price" /></receipt>""",
"{}", 42, 22);
Assert.Single(commands);
Assert.Equal(42, commands[0].Text.Length);
Assert.StartsWith("Name", commands[0].Text);
Assert.EndsWith("Price", commands[0].Text);
}
[Fact]
public void Render_ColumnsWithDataBinding()
{
var commands = _engine.Render(
"""<receipt><columns left="{{Label}}" right="{{Value}}" /></receipt>""",
"""{"Label":"Total","Value":"100.00"}""", 42, 22);
Assert.Single(commands);
Assert.Contains("Total", commands[0].Text);
Assert.Contains("100.00", commands[0].Text);
}
[Fact]
public void Render_Foreach()
{
var commands = _engine.Render("""
<receipt>
<foreach items="Items" var="item">
<line>{{item.Name}}</line>
</foreach>
</receipt>
""",
"""{"Items":[{"Name":"Pizza"},{"Name":"Pasta"},{"Name":"Salad"}]}""",
42, 22);
Assert.Equal(3, commands.Count);
Assert.Equal("Pizza", commands[0].Text);
Assert.Equal("Pasta", commands[1].Text);
Assert.Equal("Salad", commands[2].Text);
}
[Fact]
public void Render_Foreach_EmptyArray()
{
var commands = _engine.Render("""
<receipt>
<foreach items="Items" var="item">
<line>{{item.Name}}</line>
</foreach>
</receipt>
""",
"""{"Items":[]}""", 42, 22);
Assert.Empty(commands);
}
[Fact]
public void Render_Foreach_WithLoopVariables()
{
var commands = _engine.Render("""
<receipt>
<foreach items="Items" var="item">
<line>{{$index}}: {{item.Name}}</line>
</foreach>
</receipt>
""",
"""{"Items":[{"Name":"A"},{"Name":"B"},{"Name":"C"}]}""",
42, 22);
Assert.Equal(3, commands.Count);
Assert.Equal("0: A", commands[0].Text);
Assert.Equal("1: B", commands[1].Text);
Assert.Equal("2: C", commands[2].Text);
}
[Fact]
public void Render_NestedForeach()
{
var commands = _engine.Render("""
<receipt>
<foreach items="Groups" var="group">
<line>{{group.Name}}</line>
<foreach items="group.Items" var="item">
<line> {{item.Name}}</line>
</foreach>
</foreach>
</receipt>
""",
"""{"Groups":[{"Name":"G1","Items":[{"Name":"A"},{"Name":"B"}]},{"Name":"G2","Items":[{"Name":"C"}]}]}""",
42, 22);
Assert.Equal(5, commands.Count);
Assert.Equal("G1", commands[0].Text);
Assert.Equal(" A", commands[1].Text);
Assert.Equal(" B", commands[2].Text);
Assert.Equal("G2", commands[3].Text);
Assert.Equal(" C", commands[4].Text);
}
[Fact]
public void Render_If_True()
{
var commands = _engine.Render("""
<receipt>
<if test="Name">
<line>Has name: {{Name}}</line>
</if>
</receipt>
""",
"""{"Name":"John"}""", 42, 22);
Assert.Single(commands);
Assert.Equal("Has name: John", commands[0].Text);
}
[Fact]
public void Render_If_False()
{
var commands = _engine.Render("""
<receipt>
<if test="Name">
<line>Has name</line>
</if>
</receipt>
""",
"""{}""", 42, 22);
Assert.Empty(commands);
}
[Fact]
public void Render_IfElse_True()
{
var commands = _engine.Render("""
<receipt>
<if test="Name">
<line>Yes</line>
</if>
<else>
<line>No</line>
</else>
</receipt>
""",
"""{"Name":"John"}""", 42, 22);
Assert.Single(commands);
Assert.Equal("Yes", commands[0].Text);
}
[Fact]
public void Render_IfElse_False()
{
var commands = _engine.Render("""
<receipt>
<if test="Name">
<line>Yes</line>
</if>
<else>
<line>No</line>
</else>
</receipt>
""",
"""{}""", 42, 22);
Assert.Single(commands);
Assert.Equal("No", commands[0].Text);
}
[Fact]
public void Render_If_NegatedCondition()
{
var commands = _engine.Render("""
<receipt>
<if test="!$last">
<cut />
</if>
</receipt>
""",
"""{}""", 42, 22);
// $last is not set so not a boolean false, but the variable doesn't exist
// When there's no foreach context, $last is null, so !null = true
Assert.Single(commands);
Assert.True(commands[0].IsCut);
}
[Fact]
public void Render_Foreach_WithIfNotLast()
{
var commands = _engine.Render("""
<receipt>
<foreach items="Items" var="item">
<line>{{item.Name}}</line>
<if test="!$last">
<separator />
</if>
</foreach>
</receipt>
""",
"""{"Items":[{"Name":"A"},{"Name":"B"},{"Name":"C"}]}""",
42, 22);
// A, sep, B, sep, C = 5
Assert.Equal(5, commands.Count);
Assert.Equal("A", commands[0].Text);
Assert.Equal(new string('-', 42), commands[1].Text);
Assert.Equal("B", commands[2].Text);
Assert.Equal(new string('-', 42), commands[3].Text);
Assert.Equal("C", commands[4].Text);
}
[Fact]
public void Render_LineWithWrap()
{
var commands = _engine.Render(
"""<receipt><line wrap="true">The quick brown fox jumps over the lazy dog and more text here</line></receipt>""",
"{}", 30, 15);
Assert.True(commands.Count > 1);
}
[Fact]
public void Render_LineSpacing()
{
var commands = _engine.Render(
"""<receipt><line lineSpacing="50">Text</line></receipt>""",
"{}", 42, 22);
Assert.Equal(50, commands[0].SetLineSpacing);
}
[Fact]
public void Render_FormatString()
{
var commands = _engine.Render(
"<receipt><line>{{Price:F2}}</line></receipt>",
"""{"Price":9.5}""", 42, 22);
Assert.Equal("9.50", commands[0].Text);
}
[Fact]
public void Render_DateTimeFormat()
{
var commands = _engine.Render(
"<receipt><line>{{Date:dd.MM.yyyy}}</line></receipt>",
"""{"Date":"2024-03-15T14:30:00Z"}""", 42, 22);
Assert.Equal("15.03.2024", commands[0].Text);
}
[Fact]
public void Render_Row()
{
var commands = _engine.Render("""
<receipt>
<row>
<col width="14">Left</col>
<col width="14" align="center">Center</col>
<col width="14" align="right">Right</col>
</row>
</receipt>
""",
"{}", 42, 22);
Assert.Single(commands);
Assert.Equal(42, commands[0].Text.Length);
}
[Fact]
public void Render_InvalidXml_ThrowsParsingException()
{
Assert.Throws<TemplateParsingException>(() =>
_engine.Render("not xml", "{}", 42, 22));
}
[Fact]
public void Render_InvalidJson_ThrowsRenderingException()
{
Assert.Throws<TemplateRenderingException>(() =>
_engine.Render("<receipt></receipt>", "not json", 42, 22));
}
[Fact]
public void Render_ComplexTemplate()
{
var template = """
<receipt>
<line align="center" big="true" red="true">Restaurant</line>
<separator />
<line />
<line align="center">{{DateTime:dd-MMM-yy HH:mm}} Nr.:{{Number}}</line>
<line align="center" big="true" bold="true">Tisch: {{Table}}</line>
<separator />
<foreach items="Items" var="item">
<line bold="true">{{item.Qty}}x {{item.Name}}</line>
</foreach>
<separator />
<columns left="Total:" right="{{Total:F2}} CHF" bold="true" />
<cut />
</receipt>
""";
var json = """
{
"DateTime": "2024-03-15T14:30:00Z",
"Number": "42",
"Table": "5",
"Items": [
{"Qty": 2, "Name": "Margherita"},
{"Qty": 1, "Name": "Tiramisu"}
],
"Total": 45.50
}
""";
var commands = _engine.Render(template, json, 42, 22);
Assert.True(commands.Count >= 10);
// Header
Assert.True(commands[0].IsRed);
Assert.True(commands[0].IsBig);
Assert.Contains("Restaurant", commands[0].Text);
// Last command is cut
Assert.True(commands[^1].IsCut);
}
}

View File

@@ -0,0 +1,115 @@
<receipt>
<line align="center">{{CompanyName}}</line>
<line align="center">{{Address1}}</line>
<line align="center">{{Address2}}</line>
<line align="center">{{Phone}}</line>
<line />
<line />
<if test="IsDebtor">
<line>Debitorenrechnung</line>
</if>
<columns left="{{ReceiptLabel}}" right="{{DateTime:HH:mm dd.MM.yyyy}}" bold="true" />
<line>Guests: {{Guests}}</line>
<line />
<foreach items="Items" var="item">
<columns left="{{item.Quantity}}x {{item.Description}}" right="{{item.PriceDisplay}}" wrap="true" />
<foreach items="item.SubItems" var="sub">
<line> - {{sub}}</line>
</foreach>
</foreach>
<line />
<line align="right">---------</line>
<line />
<line align="center" big="true" bold="true">Summe: {{Total:F2}} {{Currency}}</line>
<line />
<if test="TotalInAlternateCurrency">
<line align="right">{{TotalInAlternateCurrency:F2}} {{AlternateCurrency}}</line>
<line />
</if>
<if test="DiscountInfo">
<columns left="{{DiscountInfo.Description}}" right="{{DiscountInfo.Amount:F2}} {{DiscountInfo.Currency}}" wrap="true" />
<line />
</if>
<foreach items="SplitPayments" var="sp">
<line align="right">{{sp.PaymentMethod}}: {{sp.Amount:F2}} {{sp.Currency}}</line>
</foreach>
<if test="HasSplitPayments">
<line />
</if>
<columns left="{{PaymentMethod}}" right="{{PaymentAmount:F2}} {{Currency}}" bold="true" />
<line />
<foreach items="TaxBreakdown" var="tax">
<if test="$first">
<row>
<col width="10" align="left">MwSt %</col>
<col width="10" align="right"> Brutto</col>
<col width="10" align="right"> Netto</col>
<col align="right">MwSt</col>
</row>
</if>
<row>
<col width="10" align="left">{{tax.Category}}:{{tax.Rate}}%</col>
<col width="10" align="right">{{tax.Gross:F2}} {{tax.Currency}}</col>
<col width="10" align="right">{{tax.Net:F2}} {{tax.Currency}}</col>
<col align="right">{{tax.TaxAmount:F2}} {{tax.Currency}}</col>
</row>
</foreach>
<if test="!HasTaxableCategories">
<line>Nicht mehrwertsteuerpflichtig</line>
</if>
<line />
<columns left="Bedient von:" right="{{WaiterName}}" />
<if test="Terminal">
<columns left="Terminal:" right="{{Terminal}}" />
</if>
<columns left="Tisch:" right="{{TableNumber}}" />
<line />
<line />
<line align="center">{{VatNumber}}</line>
<line />
<foreach items="TerminalReceipts" var="tr">
<line align="center">{{tr.ReceiptType}}</line>
<line align="center">{{tr.BookingType}}</line>
<line align="center">{{tr.PaymentSystem}}</line>
<line>{{tr.TransactionNumber}}</line>
<columns left="{{tr.TransactionDateTime:dd.MM.yyyy}}" right="{{tr.TransactionDateTime:HH:mm:ss}}" />
<columns left="Trm-Id:" right="{{tr.TerminalId}}" />
<columns left="AID:" right="{{tr.AID}}" />
<columns left="Trx. Seq-Cnt:" right="{{tr.TransactionSeqCount}}" />
<columns left="Trx. Ref-No:" right="{{tr.TransactionRefNo}}" />
<columns left="Auth. Code:" right="{{tr.AuthCode}}" />
<columns left="Acq-Id:" right="{{tr.AcquirerId}}" />
<columns left="EFT {{tr.Currency}}:" right="{{tr.EftAmount:F2}}" />
<columns left="Trinkgeld {{tr.Currency}}:" right="{{tr.TipAmount:F2}}" />
<columns left="Total-EFT {{tr.Currency}}:" right="{{tr.TotalEftAmount:F2}}" />
<separator />
<line />
</foreach>
<line align="center">{{ThankYouMessage}}</line>
<line align="center">{{GoodbyeMessageLine1}}</line>
<line align="center">{{GoodbyeMessageLine2}}</line>
<if test="IsDebtor">
<line />
<line />
<line />
<separator />
<line>Unterschrift</line>
</if>
</receipt>

View File

@@ -0,0 +1,65 @@
<receipt>
<line align="center" big="true" tall="true" red="true">{{Title}}</line>
<separator />
<line />
<line align="center">{{TransactionDateTime:dd-MMM-yy HH:mm}} Nr.:{{ReceiptNumber}}</line>
<line align="center">{{WaiterName}}</line>
<line align="center" big="true" bold="true">Tisch: {{TableNumber}}</line>
<if test="SpecialInstruction">
<line />
<line align="center" big="true" bold="true">{{SpecialInstruction}}</line>
<line />
</if>
<separator />
<foreach items="Gangs" var="gang">
<line align="center" big="true" tall="true" red="true">{{gang.Id}}. {{gang.Name}}</line>
<foreach items="gang.Dishes" var="dish">
<line tall="true" wrap="true">{{dish.GuestPrefix}}{{dish.Number}}x {{dish.Name}}</line>
<if test="dish.Modifications.HasModifications">
<foreach items="dish.Modifications.Removed" var="removed">
<line tall="true" bold="true"> - {{removed}}</line>
</foreach>
<foreach items="dish.Modifications.Added" var="added">
<line tall="true" bold="true"> + {{added}}</line>
</foreach>
</if>
<if test="dish.Comment">
<line tall="true" bold="true">Comment: {{dish.Comment}}</line>
</if>
</foreach>
<if test="!$last">
<cut />
<line />
<line />
</if>
</foreach>
<if test="HasGangs">
<separator />
</if>
<foreach items="Dishes" var="dish">
<line tall="true" wrap="true">{{dish.GuestPrefix}}{{dish.Number}}x {{dish.Name}}</line>
<if test="dish.Modifications.HasModifications">
<foreach items="dish.Modifications.Removed" var="removed">
<line tall="true" bold="true"> - {{removed}}</line>
</foreach>
<foreach items="dish.Modifications.Added" var="added">
<line tall="true" bold="true"> + {{added}}</line>
</foreach>
</if>
<if test="dish.Comment">
<line tall="true" bold="true">Comment: {{dish.Comment}}</line>
</if>
</foreach>
<if test="HasDishes">
<line />
<separator />
<line />
</if>
</receipt>

View File

@@ -0,0 +1,34 @@
<receipt>
<table items="Items" var="item">
<col width="8" align="center">Order:</col>
<col align="center">QR:</col>
<col width="12" align="center">{{DateTime:dd.MM.yyyy}}</col>
</table>
<if test="ClientNotes">
<line>|Client Notes: {{ClientNotes}}</line>
</if>
<separator />
<foreach items="Items" var="item">
<line />
<columns left="{{item.Number}} {{item.Name}}" right="{{item.SizePart}}x{{item.Quantity}}" />
<foreach items="item.SubItems" var="sub">
<line> - {{sub}}</line>
</foreach>
<if test="item.Modifications.HasModifications">
<line> Change of</line>
<line> Ingredients:</line>
<foreach items="item.Modifications.Removed" var="removed">
<line> - {{removed}}</line>
</foreach>
<foreach items="item.Modifications.Added" var="added">
<line> + {{added}}</line>
</foreach>
</if>
<if test="item.Comment">
<line> SpecialInstruction: {{item.Comment}}</line>
</if>
</foreach>
</receipt>

View File

@@ -0,0 +1,123 @@
using System.Text.Json;
namespace Inspectron.Epson.TemplateEngine.DataBinding;
public class DataContext
{
private readonly JsonElement _root;
private readonly DataContext? _parent;
private readonly Dictionary<string, JsonElement> _variables = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, object> _loopVariables = new(StringComparer.OrdinalIgnoreCase);
public DataContext(JsonElement root)
{
_root = root;
}
private DataContext(JsonElement root, DataContext parent)
{
_root = root;
_parent = parent;
}
public DataContext CreateChildScope()
{
return new DataContext(_root, this);
}
public void SetVariable(string name, JsonElement value)
{
_variables[name] = value;
}
public void SetLoopVariable(string name, object value)
{
_loopVariables[name] = value;
}
public object? GetLoopVariable(string name)
{
if (_loopVariables.TryGetValue(name, out var value))
return value;
return _parent?.GetLoopVariable(name);
}
public JsonElement? Resolve(string expression)
{
if (string.IsNullOrWhiteSpace(expression))
return null;
var parts = expression.Split('.');
var firstPart = parts[0];
// Check local variables first
JsonElement? current = null;
if (_variables.TryGetValue(firstPart, out var varValue))
{
current = varValue;
}
else if (_parent != null)
{
// Walk up scope chain for variables
var ctx = _parent;
while (ctx != null)
{
if (ctx._variables.TryGetValue(firstPart, out var parentVar))
{
current = parentVar;
break;
}
ctx = ctx._parent;
}
}
// If not found in variables, try root
if (current == null)
{
current = GetProperty(_root, firstPart);
}
if (current == null)
return null;
// Navigate remaining parts
for (int i = 1; i < parts.Length; i++)
{
current = GetProperty(current.Value, parts[i]);
if (current == null)
return null;
}
return current;
}
private static JsonElement? GetProperty(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object)
return null;
// Case-insensitive property lookup
foreach (var prop in element.EnumerateObject())
{
if (string.Equals(prop.Name, propertyName, StringComparison.OrdinalIgnoreCase))
return prop.Value;
}
return null;
}
public static string GetStringValue(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString() ?? "",
JsonValueKind.Number => element.TryGetInt64(out var l) ? l.ToString() : element.GetDouble().ToString(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => "",
JsonValueKind.Undefined => "",
_ => element.GetRawText()
};
}
}

View File

@@ -0,0 +1,218 @@
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Inspectron.Epson.TemplateEngine.DataBinding;
public class ExpressionEvaluator
{
private static readonly Regex ExpressionPattern = new(@"\{\{(.+?)\}\}", RegexOptions.Compiled);
private readonly DataContext _context;
public ExpressionEvaluator(DataContext context)
{
_context = context;
}
public string Evaluate(string template)
{
if (string.IsNullOrEmpty(template))
return "";
return ExpressionPattern.Replace(template, match =>
{
var expression = match.Groups[1].Value.Trim();
return ResolveExpression(expression);
});
}
private string ResolveExpression(string expression)
{
// Loop variables: $index, $first, $last
if (expression.StartsWith("$"))
{
var loopVar = _context.GetLoopVariable(expression);
return loopVar?.ToString() ?? "";
}
// Format string: PropertyName:format
string? format = null;
var colonIndex = expression.IndexOf(':');
if (colonIndex > 0)
{
format = expression[(colonIndex + 1)..];
expression = expression[..colonIndex];
}
var element = _context.Resolve(expression);
if (element == null)
return "";
if (format != null)
return FormatValue(element.Value, format);
return DataContext.GetStringValue(element.Value);
}
private static string FormatValue(JsonElement element, string format)
{
if (element.ValueKind == JsonValueKind.String)
{
var str = element.GetString();
if (str != null && DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt))
{
return dt.ToString(format, CultureInfo.InvariantCulture);
}
return str ?? "";
}
if (element.ValueKind == JsonValueKind.Number)
{
if (element.TryGetDecimal(out var d))
return d.ToString(format, CultureInfo.InvariantCulture);
}
return DataContext.GetStringValue(element);
}
public bool EvaluateCondition(string test)
{
if (string.IsNullOrWhiteSpace(test))
return false;
// Negation
bool negate = false;
var expr = test.Trim();
if (expr.StartsWith("!"))
{
negate = true;
expr = expr[1..].Trim();
}
// Loop variables: $first, $last
if (expr.StartsWith("$"))
{
var loopVar = _context.GetLoopVariable(expr);
bool loopResult = loopVar is bool b ? b : loopVar != null;
return negate ? !loopResult : loopResult;
}
// Comparison operators
var comparisonOps = new[] { "==", "!=", ">=", "<=", ">", "<" };
foreach (var op in comparisonOps)
{
var parts = SplitComparison(expr, op);
if (parts != null)
{
bool compResult = EvaluateComparison(parts.Value.left, op, parts.Value.right);
return negate ? !compResult : compResult;
}
}
// Truthiness check - property exists and has a value
var result = IsTruthy(expr);
return negate ? !result : result;
}
private (string left, string right)? SplitComparison(string expr, string op)
{
var idx = expr.IndexOf(op, StringComparison.Ordinal);
if (idx < 0) return null;
// Make sure we don't confuse == with = or != with !
if (op == "=" && idx > 0 && expr[idx - 1] == '!') return null;
if (op == ">" && idx > 0 && (expr[idx - 1] == '>' || expr[idx - 1] == '<')) return null;
var left = expr[..idx].Trim();
var right = expr[(idx + op.Length)..].Trim();
if (string.IsNullOrEmpty(left) || string.IsNullOrEmpty(right))
return null;
return (left, right);
}
private bool EvaluateComparison(string left, string op, string right)
{
var leftVal = ResolveComparisonValue(left);
var rightVal = ResolveComparisonValue(right);
// Try numeric comparison
if (decimal.TryParse(leftVal, CultureInfo.InvariantCulture, out var leftNum)
&& decimal.TryParse(rightVal, CultureInfo.InvariantCulture, out var rightNum))
{
return op switch
{
"==" => leftNum == rightNum,
"!=" => leftNum != rightNum,
">" => leftNum > rightNum,
"<" => leftNum < rightNum,
">=" => leftNum >= rightNum,
"<=" => leftNum <= rightNum,
_ => false
};
}
// String comparison
int cmp = string.Compare(leftVal, rightVal, StringComparison.OrdinalIgnoreCase);
return op switch
{
"==" => cmp == 0,
"!=" => cmp != 0,
">" => cmp > 0,
"<" => cmp < 0,
">=" => cmp >= 0,
"<=" => cmp <= 0,
_ => false
};
}
private string ResolveComparisonValue(string value)
{
// Quoted string literal
if ((value.StartsWith("'") && value.EndsWith("'")) ||
(value.StartsWith("\"") && value.EndsWith("\"")))
{
return value[1..^1];
}
// Numeric literal
if (decimal.TryParse(value, CultureInfo.InvariantCulture, out _))
{
return value;
}
// Loop variable
if (value.StartsWith("$"))
{
var loopVar = _context.GetLoopVariable(value);
return loopVar?.ToString() ?? "";
}
// Property resolution
var element = _context.Resolve(value);
if (element == null)
return "";
return DataContext.GetStringValue(element.Value);
}
private bool IsTruthy(string expression)
{
var element = _context.Resolve(expression);
if (element == null)
return false;
return element.Value.ValueKind switch
{
JsonValueKind.Null => false,
JsonValueKind.Undefined => false,
JsonValueKind.False => false,
JsonValueKind.String => !string.IsNullOrEmpty(element.Value.GetString()),
JsonValueKind.Number => element.Value.GetDouble() != 0,
JsonValueKind.Array => element.Value.GetArrayLength() > 0,
_ => true
};
}
}

View File

@@ -0,0 +1,13 @@
namespace Inspectron.Epson.TemplateEngine;
public class TemplateParsingException : Exception
{
public TemplateParsingException(string message) : base(message) { }
public TemplateParsingException(string message, Exception innerException) : base(message, innerException) { }
}
public class TemplateRenderingException : Exception
{
public TemplateRenderingException(string message) : base(message) { }
public TemplateRenderingException(string message, Exception innerException) : base(message, innerException) { }
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Inspectron.Epson\Inspectron.Epson.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,84 @@
namespace Inspectron.Epson.TemplateEngine.Parsing;
public abstract class TemplateNode
{
public List<TemplateNode> Children { get; set; } = new();
}
public class ReceiptNode : TemplateNode { }
public class LineNode : TemplateNode
{
public string? Text { get; set; }
public bool Bold { get; set; }
public bool Big { get; set; }
public bool Tall { get; set; }
public bool Red { get; set; }
public string Align { get; set; } = "left";
public int? LineSpacing { get; set; }
public bool Wrap { get; set; }
public int WrapIndent { get; set; }
}
public class ColumnsNode : TemplateNode
{
public string? Left { get; set; }
public string? Right { get; set; }
public bool Bold { get; set; }
public bool Big { get; set; }
public bool Tall { get; set; }
public bool Red { get; set; }
public int? LineSpacing { get; set; }
public bool Wrap { get; set; }
public int WrapIndent { get; set; }
}
public class RowNode : TemplateNode
{
public List<ColumnDef> Columns { get; set; } = new();
public bool Bold { get; set; }
public bool Big { get; set; }
public bool Tall { get; set; }
public bool Red { get; set; }
public int? LineSpacing { get; set; }
}
public class ColumnDef
{
public string? Text { get; set; }
public int? Width { get; set; }
public string Align { get; set; } = "left";
}
public class SeparatorNode : TemplateNode
{
public char Character { get; set; } = '-';
}
public class CutNode : TemplateNode { }
public class FeedNode : TemplateNode
{
public int Lines { get; set; } = 1;
}
public class ForeachNode : TemplateNode
{
public string Items { get; set; } = "";
public string Var { get; set; } = "";
}
public class IfNode : TemplateNode
{
public string Test { get; set; } = "";
}
public class ElseNode : TemplateNode { }
public class TableNode : TemplateNode
{
public List<ColumnDef> Columns { get; set; } = new();
public string? HeaderItems { get; set; }
public string? Items { get; set; }
public string? Var { get; set; }
}

View File

@@ -0,0 +1,250 @@
using System.Xml.Linq;
namespace Inspectron.Epson.TemplateEngine.Parsing;
public class TemplateParser
{
public ReceiptNode Parse(string xml)
{
XDocument doc;
try
{
doc = XDocument.Parse(xml);
}
catch (Exception ex)
{
throw new TemplateParsingException($"Invalid XML: {ex.Message}", ex);
}
var root = doc.Root;
if (root == null || root.Name.LocalName != "receipt")
throw new TemplateParsingException("Root element must be <receipt>");
var receiptNode = new ReceiptNode();
ParseChildren(root, receiptNode.Children);
return receiptNode;
}
private void ParseChildren(XElement parent, List<TemplateNode> children)
{
foreach (var element in parent.Elements())
{
var node = ParseElement(element);
if (node != null)
children.Add(node);
}
}
private TemplateNode? ParseElement(XElement element)
{
return element.Name.LocalName switch
{
"line" => ParseLine(element),
"columns" => ParseColumns(element),
"row" => ParseRow(element),
"separator" => ParseSeparator(element),
"cut" => new CutNode(),
"feed" => ParseFeed(element),
"foreach" => ParseForeach(element),
"if" => ParseIf(element),
"else" => ParseElse(element),
"table" => ParseTable(element),
_ => throw new TemplateParsingException($"Unknown element: <{element.Name.LocalName}>")
};
}
private LineNode ParseLine(XElement element)
{
var node = new LineNode();
ApplyFormatting(element, node);
// Text content: either inner text (with {{}} expressions) or empty line
var text = GetTextContent(element);
node.Text = text;
node.Align = GetAttr(element, "align", "left");
node.Wrap = GetBoolAttr(element, "wrap");
node.WrapIndent = GetIntAttr(element, "wrapIndent", 0);
return node;
}
private ColumnsNode ParseColumns(XElement element)
{
var node = new ColumnsNode();
ApplyFormatting(element, node);
node.Left = GetAttr(element, "left", "");
node.Right = GetAttr(element, "right", "");
node.Wrap = GetBoolAttr(element, "wrap");
node.WrapIndent = GetIntAttr(element, "wrapIndent", 0);
return node;
}
private RowNode ParseRow(XElement element)
{
var node = new RowNode();
ApplyFormatting(element, node);
foreach (var colElement in element.Elements("col"))
{
var col = new ColumnDef
{
Text = GetTextContent(colElement),
Width = GetNullableIntAttr(colElement, "width"),
Align = GetAttr(colElement, "align", "left")
};
node.Columns.Add(col);
}
return node;
}
private SeparatorNode ParseSeparator(XElement element)
{
var charAttr = GetAttr(element, "char", "-");
return new SeparatorNode
{
Character = string.IsNullOrEmpty(charAttr) ? '-' : charAttr[0]
};
}
private FeedNode ParseFeed(XElement element)
{
return new FeedNode
{
Lines = GetIntAttr(element, "lines", 1)
};
}
private ForeachNode ParseForeach(XElement element)
{
var items = GetRequiredAttr(element, "items", "foreach")!;
var var_ = GetRequiredAttr(element, "var", "foreach")!;
var node = new ForeachNode { Items = items, Var = var_ };
ParseChildren(element, node.Children);
return node;
}
private IfNode ParseIf(XElement element)
{
var test = GetRequiredAttr(element, "test", "if")!;
var node = new IfNode { Test = test };
ParseChildren(element, node.Children);
return node;
}
private ElseNode ParseElse(XElement element)
{
var node = new ElseNode();
ParseChildren(element, node.Children);
return node;
}
private TableNode ParseTable(XElement element)
{
var node = new TableNode
{
Items = GetAttr(element, "items", null),
Var = GetAttr(element, "var", null),
HeaderItems = GetAttr(element, "headerItems", null)
};
foreach (var colElement in element.Elements("col"))
{
var col = new ColumnDef
{
Text = GetTextContent(colElement),
Width = GetNullableIntAttr(colElement, "width"),
Align = GetAttr(colElement, "align", "left")
};
node.Columns.Add(col);
}
// Parse non-col children (col elements are consumed above as column definitions)
foreach (var child in element.Elements().Where(e => e.Name.LocalName != "col"))
{
var childNode = ParseElement(child);
if (childNode != null)
node.Children.Add(childNode);
}
return node;
}
private static void ApplyFormatting(XElement element, LineNode node)
{
node.Bold = GetBoolAttr(element, "bold");
node.Big = GetBoolAttr(element, "big");
node.Tall = GetBoolAttr(element, "tall");
node.Red = GetBoolAttr(element, "red");
node.LineSpacing = GetNullableIntAttr(element, "lineSpacing");
}
private static void ApplyFormatting(XElement element, ColumnsNode node)
{
node.Bold = GetBoolAttr(element, "bold");
node.Big = GetBoolAttr(element, "big");
node.Tall = GetBoolAttr(element, "tall");
node.Red = GetBoolAttr(element, "red");
node.LineSpacing = GetNullableIntAttr(element, "lineSpacing");
}
private static void ApplyFormatting(XElement element, RowNode node)
{
node.Bold = GetBoolAttr(element, "bold");
node.Big = GetBoolAttr(element, "big");
node.Tall = GetBoolAttr(element, "tall");
node.Red = GetBoolAttr(element, "red");
node.LineSpacing = GetNullableIntAttr(element, "lineSpacing");
}
private static string GetTextContent(XElement element)
{
// Get all inner text content (may include {{}} expressions)
// Use element.Value to get concatenated text of all text nodes
if (!element.HasElements)
return element.Value;
// If element has child elements, only get direct text nodes
return string.Concat(element.Nodes().OfType<XText>().Select(t => t.Value));
}
private static string? GetRequiredAttr(XElement element, string name, string elementName)
{
var attr = element.Attribute(name);
if (attr == null)
throw new TemplateParsingException($"<{elementName}> requires '{name}' attribute");
return attr.Value;
}
private static string GetAttr(XElement element, string name, string? defaultValue)
{
var attr = element.Attribute(name);
return attr?.Value ?? defaultValue ?? "";
}
private static bool GetBoolAttr(XElement element, string name)
{
var attr = element.Attribute(name);
if (attr == null) return false;
return attr.Value.Equals("true", StringComparison.OrdinalIgnoreCase);
}
private static int GetIntAttr(XElement element, string name, int defaultValue)
{
var attr = element.Attribute(name);
if (attr == null) return defaultValue;
return int.TryParse(attr.Value, out var v) ? v : defaultValue;
}
private static int? GetNullableIntAttr(XElement element, string name)
{
var attr = element.Attribute(name);
if (attr == null) return null;
return int.TryParse(attr.Value, out var v) ? v : null;
}
}

View File

@@ -0,0 +1,38 @@
using System.Text.Json;
using Inspectron.Epson.PrintServer.Printers.Utils;
using Inspectron.Epson.TemplateEngine.DataBinding;
using Inspectron.Epson.TemplateEngine.Parsing;
using Inspectron.Epson.TemplateEngine.Rendering;
namespace Inspectron.Epson.TemplateEngine;
public class ReceiptTemplateEngine
{
public List<PrintCommand> Render(
string xmlTemplate,
string jsonData,
int lineWidth = 48,
int bigFontLineWidth = 24)
{
// Parse XML template into node tree
var parser = new TemplateParser();
var receiptNode = parser.Parse(xmlTemplate);
// Parse JSON data into DataContext
JsonElement root;
try
{
root = JsonDocument.Parse(jsonData).RootElement;
}
catch (JsonException ex)
{
throw new TemplateRenderingException($"Invalid JSON data: {ex.Message}", ex);
}
var context = new DataContext(root);
// Render node tree to PrintCommands
var renderer = new TemplateRenderer(lineWidth, bigFontLineWidth);
return renderer.Render(receiptNode, context);
}
}

View File

@@ -0,0 +1,275 @@
namespace Inspectron.Epson.TemplateEngine.Rendering;
public class LayoutEngine
{
public string AlignText(string text, string alignment, int lineWidth)
{
if (string.IsNullOrEmpty(text))
return text ?? "";
if (text.Length >= lineWidth)
return text;
return alignment.ToLowerInvariant() switch
{
"center" => CenterText(text, lineWidth),
"right" => text.PadLeft(lineWidth),
_ => text // left-aligned is default (no padding)
};
}
private static string CenterText(string text, int lineWidth)
{
int totalPadding = lineWidth - text.Length;
int leftPadding = totalPadding / 2;
return new string(' ', leftPadding) + text;
}
public string FormatTwoColumns(string left, string right, int lineWidth)
{
left ??= "";
right ??= "";
int halfWidth = lineWidth / 2;
return left.PadRight(halfWidth) + right.PadLeft(lineWidth - halfWidth);
}
public List<string> FormatTwoColumnsWithWrap(string left, string right, int lineWidth)
{
left ??= "";
right ??= "";
int halfWidth = lineWidth / 2;
// Try simple format first
if (left.Length + right.Length <= lineWidth)
{
int spaces = lineWidth - left.Length - right.Length;
if (spaces < 1) spaces = 1;
return new List<string> { left + new string(' ', spaces) + right };
}
// Wrap left side, right-align right on first line
var leftLines = WrapText(left, halfWidth);
var result = new List<string>();
for (int i = 0; i < leftLines.Count; i++)
{
if (i == 0)
{
int spaces = lineWidth - leftLines[i].Length - right.Length;
if (spaces < 1)
{
result.Add(leftLines[i]);
result.Add(right.PadLeft(lineWidth));
}
else
{
result.Add(leftLines[i] + new string(' ', spaces) + right);
}
}
else
{
result.Add(leftLines[i]);
}
}
return result;
}
public string FormatMultiColumn(List<(string text, int width, string align)> columns, int lineWidth)
{
var parts = new List<string>();
// Calculate widths: distribute remaining width among columns without explicit width
int totalExplicit = columns.Where(c => c.width > 0).Sum(c => c.width);
int unspecifiedCount = columns.Count(c => c.width <= 0);
int remaining = lineWidth - totalExplicit;
int defaultWidth = unspecifiedCount > 0 ? remaining / unspecifiedCount : 0;
foreach (var (text, width, align) in columns)
{
int colWidth = width > 0 ? width : defaultWidth;
if (colWidth <= 0) colWidth = 1;
string formatted = align.ToLowerInvariant() switch
{
"center" => CenterInWidth(text ?? "", colWidth),
"right" => (text ?? "").PadLeft(colWidth),
_ => (text ?? "").PadRight(colWidth)
};
// Truncate if too long
if (formatted.Length > colWidth)
formatted = formatted[..colWidth];
parts.Add(formatted);
}
return string.Concat(parts);
}
public List<string> WrapText(string text, int maxWidth, int indent = 0)
{
var lines = new List<string>();
if (string.IsNullOrEmpty(text) || maxWidth <= 0)
{
lines.Add(text ?? "");
return lines;
}
if (text.Length <= maxWidth)
{
lines.Add(text);
return lines;
}
var words = text.Split(' ');
string currentLine = "";
bool isFirst = true;
foreach (var word in words)
{
int available = isFirst ? maxWidth : maxWidth - indent;
if (available <= 0) available = 1;
if (currentLine.Length == 0)
{
currentLine = word;
}
else if (currentLine.Length + 1 + word.Length <= available)
{
currentLine += " " + word;
}
else
{
if (isFirst)
{
lines.Add(currentLine);
isFirst = false;
}
else
{
lines.Add(new string(' ', indent) + currentLine);
}
currentLine = word;
}
}
if (currentLine.Length > 0)
{
if (isFirst)
lines.Add(currentLine);
else
lines.Add(new string(' ', indent) + currentLine);
}
return lines;
}
public string CreateSeparator(char character, int lineWidth)
{
return new string(character, lineWidth);
}
public List<string> FormatTable(
List<(string text, int width, string align)> columns,
List<List<string>> headerRows,
List<List<string>> dataRows,
int lineWidth)
{
var result = new List<string>();
// Calculate column widths
var colWidths = CalculateTableColumnWidths(columns, lineWidth);
// Top border
result.Add(FormatTableBorder(colWidths));
// Header rows
foreach (var row in headerRows)
{
result.Add(FormatTableRow(row, colWidths, columns));
}
// Separator between header and data
if (headerRows.Count > 0 && dataRows.Count > 0)
{
result.Add(FormatTableBorder(colWidths));
}
// Data rows
foreach (var row in dataRows)
{
result.Add(FormatTableRow(row, colWidths, columns));
}
// Bottom border
result.Add(FormatTableBorder(colWidths));
return result;
}
private List<int> CalculateTableColumnWidths(List<(string text, int width, string align)> columns, int lineWidth)
{
// Account for borders: |col1|col2|col3| = columns.Count + 1 border chars
int availableWidth = lineWidth - columns.Count - 1;
int totalExplicit = columns.Where(c => c.width > 0).Sum(c => c.width);
int unspecifiedCount = columns.Count(c => c.width <= 0);
int remaining = availableWidth - totalExplicit;
int defaultWidth = unspecifiedCount > 0 ? remaining / unspecifiedCount : 0;
var widths = new List<int>();
foreach (var (_, width, _) in columns)
{
widths.Add(width > 0 ? width : Math.Max(defaultWidth, 1));
}
return widths;
}
private string FormatTableBorder(List<int> colWidths)
{
return "+" + string.Join("+", colWidths.Select(w => new string('-', w))) + "+";
}
private string FormatTableRow(List<string> cells, List<int> colWidths, List<(string text, int width, string align)> columns)
{
var parts = new List<string>();
for (int i = 0; i < colWidths.Count; i++)
{
string cell = i < cells.Count ? cells[i] : "";
string align = i < columns.Count ? columns[i].align : "left";
parts.Add(FormatCellContent(cell, colWidths[i], align));
}
return "|" + string.Join("|", parts) + "|";
}
private string FormatCellContent(string text, int width, string align)
{
if (text.Length > width)
text = text[..width];
return align.ToLowerInvariant() switch
{
"center" => CenterInWidth(text, width),
"right" => text.PadLeft(width),
_ => text.PadRight(width)
};
}
private static string CenterInWidth(string text, int width)
{
if (string.IsNullOrEmpty(text))
return new string(' ', width);
if (text.Length >= width)
return text[..width];
int totalPadding = width - text.Length;
int leftPadding = totalPadding / 2;
int rightPadding = totalPadding - leftPadding;
return new string(' ', leftPadding) + text + new string(' ', rightPadding);
}
}

View File

@@ -0,0 +1,286 @@
using System.Text.Json;
using Inspectron.Epson.PrintServer.Printers.Utils;
using Inspectron.Epson.TemplateEngine.DataBinding;
using Inspectron.Epson.TemplateEngine.Parsing;
namespace Inspectron.Epson.TemplateEngine.Rendering;
public class TemplateRenderer
{
private readonly int _lineWidth;
private readonly int _bigFontLineWidth;
private readonly LayoutEngine _layout = new();
public TemplateRenderer(int lineWidth, int bigFontLineWidth)
{
_lineWidth = lineWidth;
_bigFontLineWidth = bigFontLineWidth;
}
public List<PrintCommand> Render(ReceiptNode receipt, DataContext context)
{
var commands = new List<PrintCommand>();
RenderChildren(receipt.Children, context, commands);
return commands;
}
private void RenderChildren(List<TemplateNode> children, DataContext context, List<PrintCommand> commands)
{
for (int i = 0; i < children.Count; i++)
{
var node = children[i];
if (node is IfNode ifNode)
{
var evaluator = new ExpressionEvaluator(context);
bool condition = evaluator.EvaluateCondition(ifNode.Test);
if (condition)
{
RenderChildren(ifNode.Children, context, commands);
// Skip following else
if (i + 1 < children.Count && children[i + 1] is ElseNode)
i++;
}
else
{
// Check for following else
if (i + 1 < children.Count && children[i + 1] is ElseNode elseNode)
{
RenderChildren(elseNode.Children, context, commands);
i++;
}
}
}
else
{
RenderNode(node, context, commands);
}
}
}
private void RenderNode(TemplateNode node, DataContext context, List<PrintCommand> commands)
{
switch (node)
{
case LineNode line:
RenderLine(line, context, commands);
break;
case ColumnsNode columns:
RenderColumns(columns, context, commands);
break;
case RowNode row:
RenderRow(row, context, commands);
break;
case SeparatorNode separator:
RenderSeparator(separator, commands);
break;
case CutNode:
commands.Add(new PrintCommand("") { IsCut = true });
break;
case FeedNode feed:
RenderFeed(feed, commands);
break;
case ForeachNode foreachNode:
RenderForeach(foreachNode, context, commands);
break;
case TableNode table:
RenderTable(table, context, commands);
break;
case ElseNode:
// Handled by IfNode processing in RenderChildren
break;
}
}
private void RenderLine(LineNode line, DataContext context, List<PrintCommand> commands)
{
var evaluator = new ExpressionEvaluator(context);
string text = evaluator.Evaluate(line.Text ?? "");
int effectiveWidth = line.Big ? _bigFontLineWidth : _lineWidth;
if (line.Wrap && text.Length > effectiveWidth)
{
var wrappedLines = _layout.WrapText(text, effectiveWidth, line.WrapIndent);
foreach (var wrappedLine in wrappedLines)
{
var cmd = CreateCommand(wrappedLine, line.Bold, line.Big, line.Tall, line.Red, line.LineSpacing);
commands.Add(cmd);
}
}
else
{
text = _layout.AlignText(text, line.Align, effectiveWidth);
var cmd = CreateCommand(text, line.Bold, line.Big, line.Tall, line.Red, line.LineSpacing);
commands.Add(cmd);
}
}
private void RenderColumns(ColumnsNode columns, DataContext context, List<PrintCommand> commands)
{
var evaluator = new ExpressionEvaluator(context);
string left = evaluator.Evaluate(columns.Left ?? "");
string right = evaluator.Evaluate(columns.Right ?? "");
int effectiveWidth = columns.Big ? _bigFontLineWidth : _lineWidth;
if (columns.Wrap)
{
var lines = _layout.FormatTwoColumnsWithWrap(left, right, effectiveWidth);
foreach (var line in lines)
{
var cmd = CreateCommand(line, columns.Bold, columns.Big, columns.Tall, columns.Red, columns.LineSpacing);
commands.Add(cmd);
}
}
else
{
string text = _layout.FormatTwoColumns(left, right, effectiveWidth);
var cmd = CreateCommand(text, columns.Bold, columns.Big, columns.Tall, columns.Red, columns.LineSpacing);
commands.Add(cmd);
}
}
private void RenderRow(RowNode row, DataContext context, List<PrintCommand> commands)
{
var evaluator = new ExpressionEvaluator(context);
int effectiveWidth = row.Big ? _bigFontLineWidth : _lineWidth;
var columnData = row.Columns.Select(c => (
text: evaluator.Evaluate(c.Text ?? ""),
width: c.Width ?? 0,
align: c.Align
)).ToList();
string text = _layout.FormatMultiColumn(columnData, effectiveWidth);
var cmd = CreateCommand(text, row.Bold, row.Big, row.Tall, row.Red, row.LineSpacing);
commands.Add(cmd);
}
private void RenderSeparator(SeparatorNode separator, List<PrintCommand> commands)
{
string text = _layout.CreateSeparator(separator.Character, _lineWidth);
commands.Add(new PrintCommand(text));
}
private void RenderFeed(FeedNode feed, List<PrintCommand> commands)
{
for (int i = 0; i < feed.Lines; i++)
{
commands.Add(new PrintCommand(""));
}
}
private void RenderForeach(ForeachNode foreachNode, DataContext context, List<PrintCommand> commands)
{
var evaluator = new ExpressionEvaluator(context);
// Resolve the items collection
var itemsElement = context.Resolve(foreachNode.Items);
if (itemsElement == null || itemsElement.Value.ValueKind != JsonValueKind.Array)
return;
var array = itemsElement.Value;
int count = array.GetArrayLength();
for (int i = 0; i < count; i++)
{
var item = array[i];
var childContext = context.CreateChildScope();
childContext.SetVariable(foreachNode.Var, item);
childContext.SetLoopVariable("$index", i);
childContext.SetLoopVariable("$first", i == 0);
childContext.SetLoopVariable("$last", i == count - 1);
RenderChildren(foreachNode.Children, childContext, commands);
}
}
private void RenderTable(TableNode table, DataContext context, List<PrintCommand> commands)
{
var evaluator = new ExpressionEvaluator(context);
var columnDefs = table.Columns.Select(c => (
text: evaluator.Evaluate(c.Text ?? ""),
width: c.Width ?? 0,
align: c.Align
)).ToList();
// Header rows from headerItems or column text
var headerRows = new List<List<string>>();
if (!string.IsNullOrEmpty(table.HeaderItems))
{
var headerArray = context.Resolve(table.HeaderItems);
if (headerArray != null && headerArray.Value.ValueKind == JsonValueKind.Array)
{
foreach (var headerItem in headerArray.Value.EnumerateArray())
{
var row = new List<string>();
foreach (var col in table.Columns)
{
var childContext = context.CreateChildScope();
childContext.SetVariable(table.Var ?? "item", headerItem);
var childEval = new ExpressionEvaluator(childContext);
row.Add(childEval.Evaluate(col.Text ?? ""));
}
headerRows.Add(row);
}
}
}
else if (columnDefs.Any(c => !string.IsNullOrEmpty(c.text)))
{
headerRows.Add(columnDefs.Select(c => c.text).ToList());
}
// Data rows
var dataRows = new List<List<string>>();
if (!string.IsNullOrEmpty(table.Items))
{
var itemsArray = context.Resolve(table.Items);
if (itemsArray != null && itemsArray.Value.ValueKind == JsonValueKind.Array)
{
foreach (var dataItem in itemsArray.Value.EnumerateArray())
{
var childContext = context.CreateChildScope();
childContext.SetVariable(table.Var ?? "item", dataItem);
var childEval = new ExpressionEvaluator(childContext);
var row = new List<string>();
foreach (var child in table.Children)
{
if (child is LineNode lineChild)
{
row.Add(childEval.Evaluate(lineChild.Text ?? ""));
}
}
// If no line children, use column defs
if (row.Count == 0)
{
foreach (var col in table.Columns)
{
row.Add(childEval.Evaluate(col.Text ?? ""));
}
}
dataRows.Add(row);
}
}
}
var lines = _layout.FormatTable(columnDefs, headerRows, dataRows, _lineWidth);
foreach (var line in lines)
{
commands.Add(new PrintCommand(line));
}
}
private static PrintCommand CreateCommand(string text, bool bold, bool big, bool tall, bool red, int? lineSpacing)
{
return new PrintCommand(text, isBig: big, isBold: bold)
{
IsTall = tall,
IsRed = red,
SetLineSpacing = lineSpacing
};
}
}

View File

@@ -0,0 +1,642 @@
# XML Receipt Template Syntax
## Overview
Templates are XML documents that combine static text, data binding expressions, and control flow to produce a list of `PrintCommand` objects for Epson thermal receipt printers.
```csharp
var engine = new ReceiptTemplateEngine();
List<PrintCommand> commands = engine.Render(
xmlTemplate, // XML template string
jsonData, // JSON data string
lineWidth: 42, // characters per line (normal font)
bigFontLineWidth: 22 // characters per line (big font)
);
```
---
## Root Element
### `<receipt>`
Required root element. All other elements must be nested inside it.
```xml
<receipt>
<!-- template content here -->
</receipt>
```
---
## Content Elements
### `<line>`
Outputs a single line of text. The most common element.
**Attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `align` | `left` / `center` / `right` | `left` | Text alignment within the line width |
| `bold` | `true` / `false` | `false` | Bold text |
| `big` | `true` / `false` | `false` | Double-width font (uses `bigFontLineWidth` for alignment) |
| `tall` | `true` / `false` | `false` | Double-height font |
| `red` | `true` / `false` | `false` | Red text (on supported printers) |
| `lineSpacing` | int | _(none)_ | Override line spacing in dots |
| `wrap` | `true` / `false` | `false` | Word-wrap text that exceeds line width |
| `wrapIndent` | int | `0` | Indent (in characters) for continuation lines when wrapping |
**Examples:**
```xml
<!-- Simple text -->
<line>Hello World</line>
<!-- Empty line -->
<line />
<!-- Centered bold header -->
<line align="center" bold="true">RECEIPT</line>
<!-- Big red title -->
<line align="center" big="true" tall="true" red="true">{{Title}}</line>
<!-- Right-aligned -->
<line align="right">---------</line>
<!-- Long text with word wrap -->
<line wrap="true">{{dish.Number}}x {{dish.Name}}</line>
<!-- Wrap with indent for continuation lines -->
<line wrap="true" wrapIndent="4">1x Very Long Dish Name That Will Wrap To Next Line</line>
<!-- Output:
1x Very Long Dish Name That
Will Wrap To Next Line -->
<!-- Custom line spacing -->
<line lineSpacing="50">Spaced out text</line>
```
When `big="true"`, alignment uses `bigFontLineWidth` instead of `lineWidth`.
---
### `<columns>`
Two-column layout: left-aligned left text, right-aligned right text. The line width is split in half.
**Attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `left` | string | `""` | Left column content (supports `{{}}` expressions) |
| `right` | string | `""` | Right column content (supports `{{}}` expressions) |
| `bold` | `true` / `false` | `false` | Bold text |
| `big` | `true` / `false` | `false` | Double-width font |
| `tall` | `true` / `false` | `false` | Double-height font |
| `red` | `true` / `false` | `false` | Red text |
| `lineSpacing` | int | _(none)_ | Override line spacing |
| `wrap` | `true` / `false` | `false` | Wrap left column if combined text exceeds line width |
| `wrapIndent` | int | `0` | Indent for wrapped continuation lines |
**Examples:**
```xml
<!-- Label-value pair -->
<columns left="Tisch:" right="{{TableNumber}}" />
<!-- Bold receipt info -->
<columns left="Rechnung Nr. {{ReceiptNumber}}" right="{{DateTime:HH:mm dd.MM.yyyy}}" bold="true" />
<!-- Price line with wrapping for long descriptions -->
<columns left="{{item.Quantity}}x {{item.Description}}" right="{{item.PriceDisplay}}" wrap="true" />
<!-- Payment line -->
<columns left="{{PaymentMethod}}" right="{{PaymentAmount:F2}} {{Currency}}" bold="true" />
```
Without `wrap`, the left column is padded to half the line width and the right column is right-padded to fill the remaining space. With `wrap="true"`, if the combined text exceeds the line width, the left column wraps and the right column appears right-aligned on the first line.
---
### `<row>`
Multi-column layout with explicit column definitions. Each column is defined by a nested `<col>` element.
**Row attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `bold` | `true` / `false` | `false` | Bold text |
| `big` | `true` / `false` | `false` | Double-width font |
| `tall` | `true` / `false` | `false` | Double-height font |
| `red` | `true` / `false` | `false` | Red text |
| `lineSpacing` | int | _(none)_ | Override line spacing |
**`<col>` attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `width` | int | _(auto)_ | Column width in characters. Unspecified columns share remaining space equally. |
| `align` | `left` / `center` / `right` | `left` | Text alignment within the column |
**Examples:**
```xml
<!-- Tax breakdown header -->
<row>
<col width="10" align="left">MwSt %</col>
<col width="10" align="right">Brutto</col>
<col width="10" align="right">Netto</col>
<col align="right">MwSt</col>
</row>
<!-- Tax breakdown data row -->
<row>
<col width="10" align="left">{{tax.Category}}:{{tax.Rate}}%</col>
<col width="10" align="right">{{tax.Gross:F2}} {{tax.Currency}}</col>
<col width="10" align="right">{{tax.Net:F2}} {{tax.Currency}}</col>
<col align="right">{{tax.TaxAmount:F2}} {{tax.Currency}}</col>
</row>
```
In this example, the first three columns are 10 characters wide. The fourth column gets all remaining space (`lineWidth - 30`).
---
### `<separator>`
Outputs a line of repeated characters spanning the full line width.
**Attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `char` | single char | `-` | Character to repeat |
**Examples:**
```xml
<!-- Default dashed line: ------------------------------------------ -->
<separator />
<!-- Asterisk line: ****************************************** -->
<separator char="*" />
<!-- Equals line: ========================================== -->
<separator char="=" />
```
Always uses `lineWidth` (not `bigFontLineWidth`).
---
### `<cut />`
Triggers a paper cut. Produces a `PrintCommand` with `IsCut = true`.
```xml
<cut />
```
---
### `<feed>`
Outputs one or more empty lines.
**Attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `lines` | int | `1` | Number of empty lines |
**Examples:**
```xml
<!-- Single empty line (same as <line />) -->
<feed />
<!-- Three empty lines -->
<feed lines="3" />
```
---
### `<table>`
Renders an ASCII box table with borders (`+`, `-`, `|`). Column definitions are provided via nested `<col>` elements.
**Attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `items` | string | _(none)_ | JSON array path for data rows |
| `var` | string | `"item"` | Loop variable name for each data row |
| `headerItems` | string | _(none)_ | JSON array path for header rows (optional) |
If `headerItems` is not set, the text content of `<col>` elements is used as the header row. Columns without text produce no header.
**`<col>` attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `width` | int | _(auto)_ | Column width in characters (excluding border characters) |
| `align` | `left` / `center` / `right` | `left` | Cell content alignment |
Column widths exclude border characters. With 3 columns, 4 border characters (`|`) are used, so the available content width is `lineWidth - 4`.
**Examples:**
```xml
<!-- Simple header-only table -->
<table>
<col width="8" align="center">Order:</col>
<col align="center">QR Info</col>
<col width="12" align="center">Date</col>
</table>
<!-- Output:
+--------+------------------+------------+
| Order: | QR Info | Date |
+--------+------------------+------------+ -->
<!-- Table with data rows -->
<table items="Products" var="p">
<col width="20" align="left">Name</col>
<col width="10" align="right">Price</col>
</table>
<!-- Output:
+--------------------+----------+
|Name | Price|
+--------------------+----------+
|Margherita | 12.50|
|Tiramisu | 8.00|
+--------------------+----------+ -->
```
---
## Control Flow
### `<foreach>`
Iterates over a JSON array. For each item, the child elements are rendered with the loop variable available in the data context.
**Attributes (both required):**
| Attribute | Type | Description |
|-----------|------|-------------|
| `items` | string | Path to the JSON array (supports dot notation for nested arrays) |
| `var` | string | Variable name to bind each array element to |
**Loop variables** (available inside `<foreach>`):
| Variable | Type | Description |
|----------|------|-------------|
| `{{$index}}` | int | Zero-based index of the current item |
| `{{$first}}` | bool | `true` for the first item |
| `{{$last}}` | bool | `true` for the last item |
**Examples:**
```xml
<!-- Simple list -->
<foreach items="Items" var="item">
<line>{{item.Name}}: {{item.Price:F2}}</line>
</foreach>
<!-- Nested loops -->
<foreach items="Gangs" var="gang">
<line big="true" red="true">{{gang.Id}}. {{gang.Name}}</line>
<foreach items="gang.Dishes" var="dish">
<line tall="true">{{dish.Number}}x {{dish.Name}}</line>
</foreach>
</foreach>
<!-- Using $index -->
<foreach items="Items" var="item">
<line>{{$index}}: {{item.Name}}</line>
</foreach>
<!-- Output:
0: Pizza
1: Pasta
2: Salad -->
<!-- Cut between gangs, but not after the last one -->
<foreach items="Gangs" var="gang">
<line>{{gang.Name}}</line>
<if test="!$last">
<cut />
</if>
</foreach>
```
If `items` resolves to `null`, an empty array, or a non-array value, the loop body is skipped entirely.
---
### `<if>` / `<else>`
Conditionally renders child elements. An `<else>` block is optional and must immediately follow its corresponding `<if>`.
**Attributes:**
| Attribute | Type | Description |
|-----------|------|-------------|
| `test` | string | Condition expression (see Condition Expressions below) |
**Examples:**
```xml
<!-- Simple truthiness check -->
<if test="SpecialInstruction">
<line bold="true">{{SpecialInstruction}}</line>
</if>
<!-- With else branch -->
<if test="DiscountInfo">
<line>Discount: {{DiscountInfo.Description}}</line>
</if>
<else>
<line>No discount applied</line>
</else>
<!-- Negated condition -->
<if test="!$last">
<cut />
</if>
<!-- Comparison -->
<if test="Total>100">
<line>Large order!</line>
</if>
<!-- String comparison -->
<if test="Status=='active'">
<line>Active order</line>
</if>
<!-- Check nested property exists -->
<if test="dish.Modifications.HasModifications">
<line>Modified</line>
</if>
<!-- Loop variable condition -->
<if test="$first">
<line bold="true">First item header</line>
</if>
```
---
## Data Binding
### Expression Syntax `{{}}`
Expressions are enclosed in double curly braces and can appear in text content, `left`/`right` attributes of `<columns>`, and `<col>` text content.
**Property access:**
```xml
<!-- Simple property -->
<line>{{Name}}</line>
<!-- Nested property -->
<line>{{Person.Address.City}}</line>
<!-- Multiple expressions in one line -->
<line>{{FirstName}} {{LastName}}</line>
<!-- Mixed static text and expressions -->
<line>Order #{{OrderNumber}} - Table {{TableNumber}}</line>
<!-- Loop variable access -->
<foreach items="Items" var="item">
<line>{{item.Name}} - {{item.Price}}</line>
</foreach>
```
Property lookup is **case-insensitive**. Both `{{name}}` and `{{Name}}` resolve the same JSON property.
If a property is missing or `null`, the expression resolves to an empty string.
### Format Strings
Use a colon after the property name to apply a format string:
```
{{PropertyName:format}}
```
**DateTime formatting** (the JSON value must be an ISO 8601 date string):
```xml
<line>{{DateTime:dd.MM.yyyy}}</line> <!-- 15.03.2024 -->
<line>{{DateTime:HH:mm:ss}}</line> <!-- 14:30:00 -->
<line>{{DateTime:dd-MMM-yy HH:mm}}</line> <!-- 15-Mar-24 14:30 -->
```
**Number formatting** (standard .NET format strings):
```xml
<line>{{Price:F2}}</line> <!-- 9.50 -->
<line>{{Amount:N0}}</line> <!-- 1,234 -->
```
### Loop Variables
Available only inside `<foreach>` blocks:
```xml
<foreach items="Items" var="item">
<line>{{$index}}: {{item.Name}}</line> <!-- 0: Pizza -->
</foreach>
```
| Variable | Type | Description |
|----------|------|-------------|
| `{{$index}}` | int | Zero-based index |
| `{{$first}}` | bool | `true` on first iteration |
| `{{$last}}` | bool | `true` on last iteration |
`$first` and `$last` are primarily useful in `<if>` conditions rather than text output.
---
## Condition Expressions
Used in the `test` attribute of `<if>`. The following forms are supported:
### Truthiness
A property path by itself checks whether the value is "truthy":
```xml
<if test="PropertyName">
```
| JSON value | Truthy? |
|------------|---------|
| `"hello"` | yes |
| `""` | no |
| `42` | yes |
| `0` | no |
| `true` | yes |
| `false` | no |
| `[1, 2]` | yes |
| `[]` | no |
| `null` | no |
| _(missing)_ | no |
| `{ ... }` | yes |
### Negation
Prefix with `!` to negate:
```xml
<if test="!PropertyName"> <!-- true when property is falsy or missing -->
<if test="!$last"> <!-- true when NOT the last loop iteration -->
```
### Comparisons
Six comparison operators are supported. Operands can be property paths, numeric literals, or quoted string literals:
```xml
<if test="Count==5"> <!-- numeric equality -->
<if test="Count!=0"> <!-- numeric inequality -->
<if test="Count>3"> <!-- greater than -->
<if test="Count<10"> <!-- less than -->
<if test="Count>=1"> <!-- greater or equal -->
<if test="Count<=100"> <!-- less or equal -->
<if test="Status=='active'"> <!-- string equality (single quotes) -->
```
If both operands parse as numbers, numeric comparison is used. Otherwise, case-insensitive string comparison is used.
Comparisons can also be negated:
```xml
<if test="!Status=='inactive'">
```
### Loop Variables in Conditions
```xml
<if test="$first"> <!-- first iteration -->
<if test="$last"> <!-- last iteration -->
<if test="!$last"> <!-- not the last iteration -->
```
---
## Complete Example
### Kitchen Receipt Template
```xml
<receipt>
<line align="center" big="true" tall="true" red="true">{{Title}}</line>
<separator />
<line />
<line align="center">{{TransactionDateTime:dd-MMM-yy HH:mm}} Nr.:{{ReceiptNumber}}</line>
<line align="center">{{WaiterName}}</line>
<line align="center" big="true" bold="true">Tisch: {{TableNumber}}</line>
<if test="SpecialInstruction">
<line />
<line align="center" big="true" bold="true">{{SpecialInstruction}}</line>
<line />
</if>
<separator />
<foreach items="Gangs" var="gang">
<line align="center" big="true" tall="true" red="true">{{gang.Id}}. {{gang.Name}}</line>
<foreach items="gang.Dishes" var="dish">
<line tall="true" wrap="true">{{dish.Number}}x {{dish.Name}}</line>
<if test="dish.Comment">
<line tall="true" bold="true">Comment: {{dish.Comment}}</line>
</if>
</foreach>
<if test="!$last">
<cut />
<line />
<line />
</if>
</foreach>
<separator />
</receipt>
```
### Sample JSON Data
```json
{
"Title": "Ristorante Bella",
"TransactionDateTime": "2024-03-15T14:30:00Z",
"ReceiptNumber": "42",
"WaiterName": "Max Mustermann",
"TableNumber": "5",
"SpecialInstruction": null,
"Gangs": [
{
"Id": 1,
"Name": "Vorspeise",
"Dishes": [
{ "Number": 2, "Name": "Caesar Salad", "Comment": null }
]
},
{
"Id": 2,
"Name": "Hauptgang",
"Dishes": [
{ "Number": 1, "Name": "Wiener Schnitzel", "Comment": "Well done" }
]
}
]
}
```
### Printed Output (42 char width)
```
Ristorante Bella
------------------------------------------
15-Mar-24 14:30 Nr.:42
Max Mustermann
Tisch: 5
------------------------------------------
1. Vorspeise
2x Caesar Salad
------------------------------------------
2. Hauptgang
1x Wiener Schnitzel
Comment: Well done
------------------------------------------
```
---
## Quick Reference
| Element | Produces | Key Attributes |
|---------|----------|----------------|
| `<receipt>` | _(root)_ | - |
| `<line>` | 1 PrintCommand (or N if wrapping) | `align`, `bold`, `big`, `tall`, `red`, `wrap`, `wrapIndent`, `lineSpacing` |
| `<columns>` | 1 PrintCommand (or N if wrapping) | `left`, `right`, `bold`, `big`, `tall`, `red`, `wrap`, `wrapIndent`, `lineSpacing` |
| `<row>` | 1 PrintCommand | `bold`, `big`, `tall`, `red`, `lineSpacing` + nested `<col>` |
| `<separator>` | 1 PrintCommand | `char` |
| `<cut />` | 1 PrintCommand (IsCut=true) | - |
| `<feed />` | N PrintCommands (empty lines) | `lines` |
| `<foreach>` | N x children | `items` (required), `var` (required) |
| `<if>` | 0 or children | `test` (required) |
| `<else>` | 0 or children | _(must follow `<if>`)_ |
| `<table>` | Multiple PrintCommands (bordered) | `items`, `var`, `headerItems` + nested `<col>` |

View File

@@ -1,7 +1,10 @@
<Solution> <Solution>
<Folder Name="/Utils/"> <Folder Name="/Utils/">
<Project Path="EpsonTest/EpsonTest.csproj" /> <Project Path="EpsonTest/EpsonTest.csproj" />
<Project Path="EpsonTest.Templates/EpsonTest.Templates.csproj" />
</Folder> </Folder>
<Project Path="EpsonPrintService/EpsonPrintService.csproj" /> <Project Path="EpsonPrintService/EpsonPrintService.csproj" />
<Project Path="Inspectron.Epson/Inspectron.Epson.csproj" /> <Project Path="Inspectron.Epson/Inspectron.Epson.csproj" />
<Project Path="Inspectron.Epson.TemplateEngine/Inspectron.Epson.TemplateEngine.csproj" />
<Project Path="Inspectron.Epson.TemplateEngine.Tests/Inspectron.Epson.TemplateEngine.Tests.csproj" />
</Solution> </Solution>

View File

@@ -1,946 +0,0 @@
# Receipt Template Language (RTL) Syntax Documentation
This document describes the syntax for creating receipt templates used by the Epson thermal printer template engine.
## Table of Contents
1. [Overview](#overview)
2. [Basic Text](#basic-text)
3. [Data Bindings](#data-bindings)
4. [Styled Text](#styled-text)
5. [Separators](#separators)
6. [Comments](#comments)
7. [Conditionals](#conditionals)
8. [Loops](#loops)
9. [Rows and Columns](#rows-and-columns)
10. [Complete Example](#complete-example)
11. [Printer Profiles](#printer-profiles)
---
## Overview
RTL is a line-based template language designed for thermal receipt printers. Templates are plain text files with embedded directives, bindings, and style markers that get transformed into printer commands.
**File Extension:** `.template`
**Key Concepts:**
- Templates are processed line by line
- Data is provided as JSON and accessed via bindings `{path}`
- Styles are applied using `#style# text #` syntax
- Control flow uses `@directive` syntax
---
## Basic Text
Plain text is output directly to the printer.
```
Hello World
This is plain text
```
**Output:**
```
Hello World
This is plain text
```
### Empty Lines
Empty lines in templates produce empty lines on the receipt.
```
Line 1
Line 3
```
---
## Data Bindings
Bindings insert values from the JSON data into the output.
### Simple Binding
```
{PropertyName}
```
**JSON:**
```json
{"PropertyName": "Hello"}
```
**Output:**
```
Hello
```
### Nested Properties
Use dot notation to access nested objects.
```
{Customer.Name}
{Order.Items.0.Name}
```
**JSON:**
```json
{
"Customer": {"Name": "John Doe"},
"Order": {"Items": [{"Name": "Coffee"}]}
}
```
**Output:**
```
John Doe
Coffee
```
### Format Specifiers
Add a format string after a colon to format numbers and dates.
#### Numeric Formats
```
Total: {Amount:F2}
Order #: {OrderNumber:D4}
```
**JSON:**
```json
{"Amount": 19.5, "OrderNumber": 42}
```
**Output:**
```
Total: 19.50
Order #: 0042
```
| Format | Description | Example Input | Output |
|--------|-------------|---------------|--------|
| `F2` | Fixed-point, 2 decimals | 19.5 | 19.50 |
| `F0` | Fixed-point, no decimals | 19.5 | 20 |
| `D4` | Decimal, padded to 4 digits | 42 | 0042 |
| `N2` | Number with grouping | 1234.5 | 1,234.50 |
#### Date/Time Formats
```
Date: {TransactionDateTime:dd-MMM-yy}
Time: {TransactionDateTime:HH:mm}
Full: {TransactionDateTime:yyyy-MM-dd HH:mm:ss}
```
**JSON:**
```json
{"TransactionDateTime": "2024-03-15T14:30:00"}
```
**Output:**
```
Date: 15-Mar-24
Time: 14:30
Full: 2024-03-15 14:30:00
```
| Format | Description | Example Output |
|--------|-------------|----------------|
| `dd-MMM-yy` | Day-Month-Year | 15-Mar-24 |
| `dd/MM/yyyy` | European date | 15/03/2024 |
| `MM/dd/yyyy` | US date | 03/15/2024 |
| `HH:mm` | 24-hour time | 14:30 |
| `hh:mm tt` | 12-hour time | 02:30 PM |
### Array Count
Access the count/length of an array.
```
Items: {Items.count}
```
**JSON:**
```json
{"Items": ["A", "B", "C"]}
```
**Output:**
```
Items: 3
```
### Missing Values
If a binding path doesn't exist or is null, an empty string is output.
```
Name: {MissingProperty}
```
**Output:**
```
Name:
```
---
## Styled Text
Apply formatting styles to text using the `#styles# content #` syntax.
### Basic Syntax
```
#style1,style2# text content #
```
The styles are comma-separated and applied to all content between the markers.
### Available Styles
| Style | Description | PrintCommand Property |
|-------|-------------|----------------------|
| `bold` | Bold text | `IsBold = true` |
| `big` | Double-width and double-height | `IsBig = true` |
| `tall` | Double-height only | `IsTall = true` |
| `red` | Red color (if printer supports) | `IsRed = true` |
| `center` | Center-align text | Text padded with spaces |
| `right` | Right-align text | Text padded with spaces |
| `left` | Left-align text (default) | No padding |
| `spacing:N` | Set line spacing to N | `SetLineSpacing = N` |
### Examples
#### Bold Text
```
#bold# Important Notice #
```
#### Centered Title
```
#center# RECEIPT #
```
#### Combined Styles
```
#bold,big,center# RESTAURANT NAME #
```
#### Red Text (Impact Printers)
```
#red,bold# WARNING #
```
#### With Bindings
```
#bold,center# {Title} #
#big# Table: {TableNumber} #
```
#### Line Spacing
```
#spacing:50# Spaced text #
```
### Style Scope
Styles apply only to content within the markers on the same line.
```
#bold# This is bold # but this is not
```
---
## Separators
Create horizontal lines using repeated characters.
### Dash Separator
```
---
```
Output: `------------------------------------------------` (full line width)
### Equals Separator
```
===
```
Output: `================================================`
### Star Separator
```
***
```
Output: `************************************************`
### Tilde Separator
```
~~~
```
Output: `~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~`
**Note:** Separator width automatically matches the printer's line width (48 chars for TM-T30III, 33 chars for TM-U220II).
---
## Comments
Add comments to templates for documentation purposes. Comments are stripped during parsing and do not appear in the output.
### Single-Line Comment
Use `@*` at the start of a line to comment out the entire line.
```
@* This is a single-line comment
Regular text here
```
**Output:**
```
Regular text here
```
### Inline Comment
Use `@* ... *@` to create a comment that can have content after it on the same line.
```
@* This is a comment *@
@* Header section *@
#bold,center# Title #
```
**Output:**
```
Title
```
### Multi-Line Comment
Use `@*` on its own line to start a multi-line comment block, and `*@` to close it.
```
@*
This is a multi-line comment.
All these lines are ignored.
Directives like @if are also ignored here.
*@
This text appears in output
```
**Output:**
```
This text appears in output
```
### Use Cases
#### Document Template Sections
```
@* === HEADER SECTION === *@
#bold,center# {Title} #
---
@* === ITEMS SECTION === *@
@foreach item in Items
{item.Name}
@end
```
#### Temporarily Disable Code
```
@*
@if DebugMode
Debug info: {DebugData}
@end
*@
```
#### Add Notes for Maintainers
```
@* Note: This section only shows for orders over $100 *@
@if Total > 100
#bold# Large Order #
@end
```
### Important Notes
- Comments must start at the beginning of a line (after optional whitespace)
- `@*` mid-line in content is treated as regular text, not a comment
- Star separators (`***`) are not confused with comments
- Unclosed multi-line comments produce a lexer error
---
## Conditionals
Control which content is rendered based on data values.
### Basic If
```
@if Condition
Content shown when true
@end
```
### If-Else
```
@if HasDiscount
Discount Applied!
@else
No Discount
@end
```
### If-ElseIf-Else
```
@if Status == "pending"
Order Pending
@elseif Status == "complete"
Order Complete
@else
Unknown Status
@end
```
### Truthy/Falsy Values
The following are considered **falsy**:
- `null` or missing property
- `false`
- Empty string `""`
- Number `0`
- Empty array `[]`
Everything else is **truthy**.
### Comparison Operators
| Operator | Description | Example |
|----------|-------------|---------|
| `==` | Equal | `Status == "active"` |
| `!=` | Not equal | `Type != "void"` |
| `>` | Greater than | `Amount > 100` |
| `<` | Less than | `Count < 5` |
| `>=` | Greater or equal | `Total >= 50` |
| `<=` | Less or equal | `Qty <= 10` |
### Negation
```
@if !IsVoided
Valid Order
@end
```
### String Literals
Use quotes for string comparisons.
```
@if Status == "active"
Active
@end
```
### Numeric Comparisons
```
@if Amount > 100
Large Order
@end
@if Items.count > 0
Has Items
@end
```
### Examples
#### Check for Optional Field
```
@if SpecialInstruction
#bold# Note: {SpecialInstruction} #
@end
```
#### Check Array Has Items
```
@if Modifications.count > 0
Modifications:
@foreach mod in Modifications
- {mod}
@end
@end
```
#### Conditional Separator
```
@if Items.count > 0
---
@end
```
---
## Loops
Iterate over arrays in the data.
### Basic Foreach
```
@foreach item in Items
{item}
@end
```
**JSON:**
```json
{"Items": ["Apple", "Banana", "Cherry"]}
```
**Output:**
```
Apple
Banana
Cherry
```
### Object Properties
```
@foreach product in Products
{product.Name} - {product.Price:F2}
@end
```
**JSON:**
```json
{
"Products": [
{"Name": "Coffee", "Price": 3.50},
{"Name": "Tea", "Price": 2.50}
]
}
```
**Output:**
```
Coffee - 3.50
Tea - 2.50
```
### Nested Loops
```
@foreach category in Categories
#bold# {category.Name} #
@foreach item in category.Items
{item.Name}
@end
@end
```
### Loop Metadata Variables
Inside a loop, these special variables are available:
| Variable | Description | Example Value |
|----------|-------------|---------------|
| `_index` | Zero-based index | 0, 1, 2, ... |
| `_number` | One-based number | 1, 2, 3, ... |
| `_first` | True if first item | true/false |
| `_last` | True if last item | true/false |
| `_count` | Total items in collection | 5 |
#### Examples
```
@foreach item in Items
{_number}. {item.Name}
@end
```
**Output:**
```
1. Apple
2. Banana
3. Cherry
```
```
@foreach item in Items
{item}
@if !_last
---
@end
@end
```
**Output:**
```
Apple
---
Banana
---
Cherry
```
### Nested Path Collections
```
@foreach dish in Order.Kitchen.Dishes
{dish.Name}
@end
```
---
## Rows and Columns
Create tabular layouts with fixed-width columns.
### Basic Row
```
@row
|width|content|width|content
@endrow
```
### Row Styles
Apply styles to an entire row by adding style names after `@row`:
```
@row bold
|width|content|width|content
@endrow
```
Multiple styles can be combined with commas:
```
@row bold, big
|width|content|width|content
@endrow
```
#### Available Row Styles
| Style | Description |
|-------|-------------|
| `bold` | Bold text for entire row |
| `big` | Double-width and double-height |
| `tall` | Double-height only |
| `red` | Red color (if printer supports) |
| `spacing:N` | Set line spacing to N |
#### Row Style Examples
```
@row bold
|30|{Name}|10,right|{Price:F2}
@endrow
```
```
@row bold, big
|15|TOTAL|9,right|{Total:F2}
@endrow
```
```
@row red, bold
|40|*** WARNING ***
@endrow
```
### Column Syntax
```
|width|content
|width,alignment|content
```
- **width**: Number of characters for the column
- **alignment**: `left` (default), `right`, or `center`
### Examples
#### Two Columns
```
@row
|30|{Name}|10,right|{Price:F2}
@endrow
```
**JSON:**
```json
{"Name": "Coffee", "Price": 3.50}
```
**Output:**
```
Coffee 3.50
```
#### Three Columns
```
@row
|5,right|{Qty}|25|{Name}|10,right|{Total:F2}
@endrow
```
**Output:**
```
2 Espresso 7.00
```
#### Header Row
```
@row
|5|Qty|25|Item|10,right|Price
@endrow
===
```
### Columns in Loops
```
@foreach item in Items
@row
|5,right|{item.Quantity}|25|{item.Name}|10,right|{item.Price:F2}
@endrow
@end
```
### Text Truncation
If content exceeds the column width, it is truncated.
```
@row
|10|VeryLongProductNameHere
@endrow
```
**Output:**
```
VeryLongPr
```
---
## Complete Example
Here's a complete kitchen receipt template:
```
#red,big,tall,center# {Title} #
---
#center# {TransactionDateTime:dd-MMM-yy HH:mm} Nr.:{ReceiptNumber} #
#center# {WaiterName} #
#center# {WaiterId} #
#big,bold,center# Tisch: {TableNumber} #
@if SpecialInstruction
#big,bold,center# {SpecialInstruction} #
@end
---
@foreach gang in Gangs
#red,big,tall,center# {gang.Id}. {gang.Name} #
@foreach dish in gang.Dishes
#tall# {dish.Number}x {dish.Name} #
@if dish.Modifications.Removed.count > 0
@foreach removed in dish.Modifications.Removed
#bold,tall# - {removed} #
@end
@end
@if dish.Modifications.Added.count > 0
@foreach added in dish.Modifications.Added
#bold,tall# + {added} #
@end
@end
@if dish.Comment
#bold,tall# Comment: {dish.Comment} #
@end
@end
@end
@if Gangs.count > 0
---
@end
@foreach dish in Dishes
#tall# {dish.Number}x {dish.Name} #
@if dish.Modifications.Removed.count > 0
@foreach removed in dish.Modifications.Removed
#bold,tall# - {removed} #
@end
@end
@if dish.Modifications.Added.count > 0
@foreach added in dish.Modifications.Added
#bold,tall# + {added} #
@end
@end
@if dish.Comment
#bold,tall# Comment: {dish.Comment} #
@end
@end
@if Dishes.count > 0
---
@end
```
**Sample JSON:**
```json
{
"Title": "Restaurant Kitchen",
"TransactionDateTime": "2024-03-15T14:30:00",
"ReceiptNumber": "12345",
"WaiterName": "John Doe",
"WaiterId": "W001",
"TableNumber": "5",
"SpecialInstruction": "Rush Order",
"Gangs": [
{
"Id": 1,
"Name": "Starters",
"Dishes": [
{
"Number": 2,
"Name": "Caesar Salad",
"Modifications": {
"Removed": ["Croutons"],
"Added": ["Extra Dressing"]
},
"Comment": "No anchovies"
}
]
}
],
"Dishes": []
}
```
---
## Printer Profiles
Templates adapt to different printer capabilities using profiles.
### Built-in Profiles
| Printer | Profile ID | Line Width | Big Width | Red Support |
|---------|------------|------------|-----------|-------------|
| TM-T30III | `tm-t30iii` | 48 | 24 | No |
| TM-U220II | `tm-u220ii` | 33 | 20 | Yes |
### How Profiles Affect Output
1. **Line Width**: Separators and centered text use the profile's line width
2. **Big Width**: When `big` style is applied, centering uses the reduced width
3. **Red Support**: The `red` style only produces red output on supported printers
### Template Assignment
Templates are assigned to receipt types and printer profiles in `assignments.json`:
```json
{
"assignments": [
{
"receiptType": 1,
"profileId": "tm-t30iii",
"template": "kitchen-default.template"
},
{
"receiptType": 1,
"profileId": "tm-u220ii",
"template": "kitchen-u220.template"
},
{
"receiptType": 1,
"profileId": null,
"template": "kitchen-default.template"
}
],
"fallbackTemplate": "fallback.template"
}
```
**Resolution Priority:**
1. Exact match (receiptType + profileId)
2. Type-only match (receiptType, no profileId)
3. Fallback template
---
## Quick Reference
### Syntax Summary
| Syntax | Description |
|--------|-------------|
| `{path}` | Data binding |
| `{path:format}` | Formatted binding |
| `#style# text #` | Styled text |
| `---` | Dash separator |
| `===` | Equals separator |
| `***` | Star separator |
| `~~~` | Tilde separator |
| `@* comment` | Single-line comment |
| `@* comment *@` | Inline comment |
| `@*` ... `*@` | Multi-line comment block |
| `@if condition` | Start conditional |
| `@elseif condition` | Else-if branch |
| `@else` | Else branch |
| `@end` | End block |
| `@foreach var in collection` | Start loop |
| `@row` | Start row |
| `@row style1, style2` | Start row with styles |
| `@endrow` | End row |
| `\|width\|content` | Column definition |
| `\|width,align\|content` | Column with alignment |
### Style Reference
| Style | Effect |
|-------|--------|
| `bold` | Bold text |
| `big` | Double size |
| `tall` | Double height |
| `red` | Red color |
| `center` | Center align |
| `right` | Right align |
| `spacing:N` | Line spacing |
### Loop Variables
| Variable | Value |
|----------|-------|
| `_index` | 0, 1, 2, ... |
| `_number` | 1, 2, 3, ... |
| `_first` | true/false |
| `_last` | true/false |
| `_count` | total count |