template engine
This commit is contained in:
27
EpsonTest.Templates/EpsonTest.Templates.csproj
Normal file
27
EpsonTest.Templates/EpsonTest.Templates.csproj
Normal 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>
|
||||||
118
EpsonTest.Templates/Program.cs
Normal file
118
EpsonTest.Templates/Program.cs
Normal 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);
|
||||||
115
EpsonTest.Templates/Templates/FinalReceipt.xml
Normal file
115
EpsonTest.Templates/Templates/FinalReceipt.xml
Normal 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>
|
||||||
29
EpsonTest.Templates/Templates/InvoiceReceipt.xml
Normal file
29
EpsonTest.Templates/Templates/InvoiceReceipt.xml
Normal 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>
|
||||||
65
EpsonTest.Templates/Templates/KitchenReceipt.xml
Normal file
65
EpsonTest.Templates/Templates/KitchenReceipt.xml
Normal 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>
|
||||||
34
EpsonTest.Templates/Templates/OrderItems.xml
Normal file
34
EpsonTest.Templates/Templates/OrderItems.xml
Normal 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>
|
||||||
579
EpsonTest.Templates/TestHelpers.cs
Normal file
579
EpsonTest.Templates/TestHelpers.cs
Normal 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
|
||||||
|
}
|
||||||
BIN
EpsonTest.Templates/ristorante-klinglers.ch-logo.png
Normal file
BIN
EpsonTest.Templates/ristorante-klinglers.ch-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
EpsonTest.Templates/ristorante-klinglers.ch-logo_white_bg.png
Normal file
BIN
EpsonTest.Templates/ristorante-klinglers.ch-logo_white_bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -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}}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Inspectron.Epson.TemplateEngine.Tests/GlobalUsings.cs
Normal file
1
Inspectron.Epson.TemplateEngine.Tests/GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
global using Xunit;
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
154
Inspectron.Epson.TemplateEngine.Tests/LayoutEngineTests.cs
Normal file
154
Inspectron.Epson.TemplateEngine.Tests/LayoutEngineTests.cs
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
294
Inspectron.Epson.TemplateEngine.Tests/TemplateParserTests.cs
Normal file
294
Inspectron.Epson.TemplateEngine.Tests/TemplateParserTests.cs
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
445
Inspectron.Epson.TemplateEngine.Tests/TemplateRendererTests.cs
Normal file
445
Inspectron.Epson.TemplateEngine.Tests/TemplateRendererTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
Inspectron.Epson.TemplateEngine.Tests/Templates/FinalReceipt.xml
Normal file
115
Inspectron.Epson.TemplateEngine.Tests/Templates/FinalReceipt.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
123
Inspectron.Epson.TemplateEngine/DataBinding/DataContext.cs
Normal file
123
Inspectron.Epson.TemplateEngine/DataBinding/DataContext.cs
Normal 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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
13
Inspectron.Epson.TemplateEngine/Exceptions.cs
Normal file
13
Inspectron.Epson.TemplateEngine/Exceptions.cs
Normal 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) { }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
84
Inspectron.Epson.TemplateEngine/Parsing/TemplateNode.cs
Normal file
84
Inspectron.Epson.TemplateEngine/Parsing/TemplateNode.cs
Normal 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; }
|
||||||
|
}
|
||||||
250
Inspectron.Epson.TemplateEngine/Parsing/TemplateParser.cs
Normal file
250
Inspectron.Epson.TemplateEngine/Parsing/TemplateParser.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
Inspectron.Epson.TemplateEngine/ReceiptTemplateEngine.cs
Normal file
38
Inspectron.Epson.TemplateEngine/ReceiptTemplateEngine.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
275
Inspectron.Epson.TemplateEngine/Rendering/LayoutEngine.cs
Normal file
275
Inspectron.Epson.TemplateEngine/Rendering/LayoutEngine.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
286
Inspectron.Epson.TemplateEngine/Rendering/TemplateRenderer.cs
Normal file
286
Inspectron.Epson.TemplateEngine/Rendering/TemplateRenderer.cs
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
642
Inspectron.Epson.TemplateEngine/template_syntax.md
Normal file
642
Inspectron.Epson.TemplateEngine/template_syntax.md
Normal 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>` |
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 |
|
|
||||||
Reference in New Issue
Block a user