diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4a1edf1..88fe641 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,13 @@ { "permissions": { "allow": [ - "Bash(grep:*)" + "Bash(grep:*)", + "Bash(xargs:*)", + "Bash(find:*)", + "Bash(tree:*)", + "Bash(wc:*)", + "Bash(dotnet build:*)", + "Bash(dotnet test:*)" ] } } diff --git a/EpsonTemplatesTest/EpsonTemplatesTest.csproj b/EpsonTemplatesTest/EpsonTemplatesTest.csproj new file mode 100644 index 0000000..4a4c1b5 --- /dev/null +++ b/EpsonTemplatesTest/EpsonTemplatesTest.csproj @@ -0,0 +1,21 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + PreserveNewest + + + + diff --git a/EpsonTemplatesTest/Program.cs b/EpsonTemplatesTest/Program.cs new file mode 100644 index 0000000..b082c11 --- /dev/null +++ b/EpsonTemplatesTest/Program.cs @@ -0,0 +1,100 @@ +using Inspectron.Epson; +using Inspectron.Epson.PrintServer.Printers.Utils; +using Inspectron.Epson.PrintServer.Printers.Utils.KitchenReceipt; +using Inspectron.Epson.Templates.Configuration; +using Inspectron.Epson.Templates.Interpreter; +using Inspectron.Epson.Templates.Language; +using System.Text.Json; + +var receipt = new KitchenReceipt +{ + Title = "Warme Küche", + TransactionDateTime = new DateTime(2025, 10, 23, 12, 18, 0), + ReceiptNumber = "132018166", + WaiterName = "Mariano Amato", + WaiterId = "32 (VK Restaurant)", + TableNumber = "22", + SpecialInstruction = "Next dish" +}; + +// Add dishes (this section can be empty if no dishes were ordered) + +receipt.Gangs.Add(new Gang(2, "Gang")); + +receipt.Gangs[0].Dishes.Add(new Dish(1, "Avocado Sashimi") +{ + Modifications = new DishModifications + { + Removed = new List { "Wasabi" }, + Added = new List { "Extra Ginger" } + }, + Comment = "No soy sauce" +}); +receipt.Gangs[0].Dishes.Add(new Dish(1, "Baby Spinach Salad with Truffl")); +receipt.Gangs[0].Dishes.Add(new Dish(1, "Beef Tataki") +{ + Modifications = new DishModifications + { + Removed = new List { "Onion" }, + Added = new List { "Extra Sauce" } + }, + Comment = "Medium rare" +}); +receipt.Gangs[0].Dishes.Add(new Dish(1, "Salmon Taco") +{ + Modifications = new DishModifications + { + Added = new List { "Extra Lime" } + } +}); + +// Add additional info + +var serializedReceipt = JsonSerializer.Serialize(receipt, new JsonSerializerOptions { WriteIndented = true }); + +var lexer = new Lexer(File.ReadAllText("kitchen-u220.template")); +var parser = new Parser(lexer.Tokenize().Tokens); +var template = parser.Parse().Template; +PrinterProfile _profileT30 = new("tm-t30iii", "TM-T30III", 48, 24, false); +PrinterProfile _profile220 = new("tm-220", "TM-220", 33, 18, true); + +var interpreter = new TemplateInterpreter(_profile220); +var printCommands = interpreter.Interpret(template, serializedReceipt); +Console.WriteLine("=== PRINT COMMANDS ===\n"); +var printer = new HtmlPrinter(@".\test.html", paperWidth: 258); +await printer.ConnectAsync(""); +bool useDelay = false; +//var printer = new EpsonPrinter(); +//await printer.ConnectAsync("127.0.0.1", 8888); +if (useDelay) await Task.Delay(200); +await printer.FeedLinesAsync(1); +//await printer.SetCustomLineSpacing(22); +foreach (var command in printCommands) +{ + string attributes = ""; + if (command.IsBig) attributes += "[BIG] "; + if (command.IsBold) attributes += "[BOLD] "; + if (command.IsRed) attributes += "[RED] "; + + Console.WriteLine($"{attributes}{command.Text}"); + await printer.SetBiggerFontTM220(command.IsBig, command.IsTall , secondaryFont: false); + + + + if (useDelay) await Task.Delay(200); + + await printer.SetRedColor(command.IsRed); + + if (useDelay) await Task.Delay(200); + + await printer.SetEmphasized(command.IsBold); + if (useDelay) await Task.Delay(200); + + await printer.PrintTextAsync(command.Text + "\n"); + if (useDelay) await Task.Delay(200); + +} + +await printer.FeedLinesAsync(5); +if (useDelay) await Task.Delay(200 * 5); +await printer.CutAsync(); \ No newline at end of file diff --git a/EpsonTemplatesTest/kitchen-u220.template b/EpsonTemplatesTest/kitchen-u220.template new file mode 100644 index 0000000..fe52d4a --- /dev/null +++ b/EpsonTemplatesTest/kitchen-u220.template @@ -0,0 +1,57 @@ +#red,big,center# {Title} # +--- + +#center# {TransactionDateTime:dd-MMM-yy HH:mm} Nr.:{ReceiptNumber} # +#center# {WaiterName} # +#center# {WaiterId} # +#big,bold,center# Tisch: {TableNumber} # + +@if SpecialInstruction + +#big,bold,center# {SpecialInstruction} # + +@end +--- +@foreach gang in Gangs +#red,big,center# {gang.Id}. {gang.Name} # +@foreach dish in gang.Dishes +#tall# {dish.Number}x {dish.Name} # +@if dish.Modifications.Removed.count > 0 +@foreach removed in dish.Modifications.Removed +#bold,tall# - {removed} # +@end +@end +@if dish.Modifications.Added.count > 0 +@foreach added in dish.Modifications.Added +#bold,tall# + {added} # +@end +@end +@if dish.Comment +#bold,tall# Comment: {dish.Comment} # +@end +@end +@end +@if Gangs.count > 0 +--- +@end +@foreach dish in Dishes +#tall# {dish.Number}x {dish.Name} # +@if dish.Modifications.Removed.count > 0 +@foreach removed in dish.Modifications.Removed +#bold,tall# - {removed} # +@end +@end +@if dish.Modifications.Added.count > 0 +@foreach added in dish.Modifications.Added +#bold,tall# + {added} # +@end +@end +@if dish.Comment +#bold,tall# Comment: {dish.Comment} # +@end +@end +@if Dishes.count > 0 + +--- + +@end diff --git a/EpsonTest/Program.cs b/EpsonTest/Program.cs index bcd250d..fe1a323 100644 --- a/EpsonTest/Program.cs +++ b/EpsonTest/Program.cs @@ -378,7 +378,7 @@ receipt.Gangs[0].Dishes.Add(new Dish(1, "Salmon Taco") var serializedReceipt = JsonSerializer.Serialize(receipt, new JsonSerializerOptions { WriteIndented = true }); var deserializedReceipt = JsonSerializer.Deserialize(serializedReceipt); -KitchenReceiptConverter converter = new KitchenReceiptConverter(bigLineWidth: 12, lineWidth: 33); +KitchenReceiptConverter converter = new KitchenReceiptConverter(bigLineWidth: 18, lineWidth: 33); var printCommands = converter.ConvertToPrintCommands(deserializedReceipt); Console.WriteLine("=== PRINT COMMANDS ===\n"); @@ -398,7 +398,7 @@ foreach (var command in printCommands) if (command.IsRed) attributes += "[RED] "; Console.WriteLine($"{attributes}{command.Text}"); - await printer.SetBiggerFontTM220(command.IsBig, command.IsTall, secondaryFont: false); + await printer.SetBiggerFontTM220(command.IsBig, command.IsTall| command.IsBig, secondaryFont: false); diff --git a/Inspectron.Epson.Templates.Tests/Configuration/PrinterProfileTests.cs b/Inspectron.Epson.Templates.Tests/Configuration/PrinterProfileTests.cs new file mode 100644 index 0000000..21a1455 --- /dev/null +++ b/Inspectron.Epson.Templates.Tests/Configuration/PrinterProfileTests.cs @@ -0,0 +1,95 @@ +using Inspectron.Epson.Templates.Configuration; + +namespace Inspectron.Epson.Templates.Tests.Configuration; + +public class PrinterProfileTests +{ + [Fact] + public void Default_Profile_HasExpectedValues() + { + var profile = PrinterProfile.Default; + + Assert.Equal("default", profile.Id); + Assert.Equal(48, profile.LineWidth); + Assert.Equal(24, profile.BigLineWidth); + Assert.False(profile.SupportsRed); + } +} + +public class PrinterProfileRegistryTests +{ + [Fact] + public void GetProfile_TmT30III_ReturnsCorrectProfile() + { + var registry = new PrinterProfileRegistry(); + var profile = registry.GetProfile(0x01); + + Assert.Equal("tm-t30iii", profile.Id); + Assert.Equal(48, profile.LineWidth); + Assert.Equal(24, profile.BigLineWidth); + Assert.False(profile.SupportsRed); + } + + [Fact] + public void GetProfile_TmU220II_0x0D_ReturnsCorrectProfile() + { + var registry = new PrinterProfileRegistry(); + var profile = registry.GetProfile(0x0D); + + Assert.Equal("tm-u220ii", profile.Id); + Assert.Equal(33, profile.LineWidth); + Assert.Equal(20, profile.BigLineWidth); + Assert.True(profile.SupportsRed); + } + + [Fact] + public void GetProfile_TmU220II_0x13_ReturnsCorrectProfile() + { + var registry = new PrinterProfileRegistry(); + var profile = registry.GetProfile(0x13); + + Assert.Equal("tm-u220ii", profile.Id); + Assert.Equal(33, profile.LineWidth); + Assert.Equal(20, profile.BigLineWidth); + Assert.True(profile.SupportsRed); + } + + [Fact] + public void GetProfile_UnknownPrinter_ReturnsDefault() + { + var registry = new PrinterProfileRegistry(); + var profile = registry.GetProfile(0xFF); + + Assert.Equal(PrinterProfile.Default.LineWidth, profile.LineWidth); + } + + [Fact] + public void GetProfile_ByName_ReturnsCorrectProfile() + { + var registry = new PrinterProfileRegistry(); + var profile = registry.GetProfile("tm-t30iii"); + + Assert.Equal("tm-t30iii", profile.Id); + } + + [Fact] + public void Register_CustomProfile_CanBeRetrieved() + { + var registry = new PrinterProfileRegistry(); + var customProfile = new PrinterProfile("custom", "Custom Printer", 40, 20, true); + registry.Register(0xAA, customProfile); + + var retrieved = registry.GetProfile(0xAA); + Assert.Equal("custom", retrieved.Id); + } + + [Fact] + public void GetAllProfiles_ReturnsDistinctProfiles() + { + var registry = new PrinterProfileRegistry(); + var profiles = registry.GetAllProfiles().ToList(); + + // Should have TM-T30III and TM-U220II (distinct) + Assert.Equal(2, profiles.Count); + } +} diff --git a/Inspectron.Epson.Templates.Tests/Configuration/TemplateResolverTests.cs b/Inspectron.Epson.Templates.Tests/Configuration/TemplateResolverTests.cs new file mode 100644 index 0000000..26098bd --- /dev/null +++ b/Inspectron.Epson.Templates.Tests/Configuration/TemplateResolverTests.cs @@ -0,0 +1,100 @@ +using Inspectron.Epson.Templates.Configuration; + +namespace Inspectron.Epson.Templates.Tests.Configuration; + +public class TemplateResolverTests +{ + [Fact] + public void Resolve_ExactMatch_ReturnsExactTemplate() + { + var assignments = new[] + { + new TemplateAssignment(1, "tm-t30iii", "kitchen-t30.template"), + new TemplateAssignment(1, null, "kitchen-default.template") + }; + var resolver = new TemplateResolver(assignments); + + var result = resolver.Resolve(1, "tm-t30iii"); + + Assert.Equal("kitchen-t30.template", result); + } + + [Fact] + public void Resolve_TypeOnlyMatch_ReturnsTypeTemplate() + { + var assignments = new[] + { + new TemplateAssignment(1, null, "kitchen-default.template"), + new TemplateAssignment(2, null, "bar-default.template") + }; + var resolver = new TemplateResolver(assignments); + + var result = resolver.Resolve(1, "unknown-profile"); + + Assert.Equal("kitchen-default.template", result); + } + + [Fact] + public void Resolve_NoMatch_ReturnsFallback() + { + var assignments = new[] + { + new TemplateAssignment(1, null, "kitchen-default.template") + }; + var resolver = new TemplateResolver(assignments, "fallback.template"); + + var result = resolver.Resolve(99, "unknown-profile"); + + Assert.Equal("fallback.template", result); + } + + [Fact] + public void Resolve_PreferExactOverTypeOnly() + { + var assignments = new[] + { + new TemplateAssignment(1, null, "type-only.template"), + new TemplateAssignment(1, "tm-t30iii", "exact.template") + }; + var resolver = new TemplateResolver(assignments); + + var result = resolver.Resolve(1, "tm-t30iii"); + + Assert.Equal("exact.template", result); + } + + [Fact] + public void Resolve_WithRegistry_MapsProfileCorrectly() + { + var registry = new PrinterProfileRegistry(); + var assignments = new[] + { + new TemplateAssignment(1, "tm-t30iii", "kitchen-t30.template"), + new TemplateAssignment(1, "tm-u220ii", "kitchen-u220.template") + }; + var resolver = new TemplateResolver(assignments); + + var result = resolver.Resolve(1, 0x01, registry); + + Assert.Equal("kitchen-t30.template", result); + } + + [Fact] + public void LoadConfiguration_ParsesCorrectly() + { + var json = """ + { + "assignments": [ + {"receiptType": 1, "profileId": "tm-t30iii", "template": "kitchen.template"}, + {"receiptType": 2, "profileId": null, "template": "bar.template"} + ], + "fallbackTemplate": "default.template" + } + """; + + var config = TemplateConfiguration.LoadFromJson(json); + + Assert.Equal(2, config.Assignments.Count); + Assert.Equal("default.template", config.FallbackTemplate); + } +} diff --git a/Inspectron.Epson.Templates.Tests/Helpers/PrintCommandAssertions.cs b/Inspectron.Epson.Templates.Tests/Helpers/PrintCommandAssertions.cs new file mode 100644 index 0000000..e93a14a --- /dev/null +++ b/Inspectron.Epson.Templates.Tests/Helpers/PrintCommandAssertions.cs @@ -0,0 +1,83 @@ +using Inspectron.Epson.PrintServer.Printers.Utils; + +namespace Inspectron.Epson.Templates.Tests.Helpers; + +public static class PrintCommandAssertions +{ + public static void AssertCommandExists( + List commands, + string textContains, + bool? isBold = null, + bool? isBig = null, + bool? isTall = null, + bool? isRed = null) + { + var matching = commands.Where(c => c.Text.Contains(textContains)).ToList(); + + if (matching.Count == 0) + { + throw new Xunit.Sdk.XunitException($"No command found containing text: '{textContains}'"); + } + + foreach (var cmd in matching) + { + if (isBold.HasValue && cmd.IsBold != isBold.Value) + { + throw new Xunit.Sdk.XunitException( + $"Command with text '{textContains}' has IsBold={cmd.IsBold}, expected {isBold.Value}"); + } + if (isBig.HasValue && cmd.IsBig != isBig.Value) + { + throw new Xunit.Sdk.XunitException( + $"Command with text '{textContains}' has IsBig={cmd.IsBig}, expected {isBig.Value}"); + } + if (isTall.HasValue && cmd.IsTall != isTall.Value) + { + throw new Xunit.Sdk.XunitException( + $"Command with text '{textContains}' has IsTall={cmd.IsTall}, expected {isTall.Value}"); + } + if (isRed.HasValue && cmd.IsRed != isRed.Value) + { + throw new Xunit.Sdk.XunitException( + $"Command with text '{textContains}' has IsRed={cmd.IsRed}, expected {isRed.Value}"); + } + } + } + + public static void AssertSequence(List commands, params string[] expectedTexts) + { + var commandTexts = commands.Select(c => c.Text).ToList(); + int searchIndex = 0; + + foreach (var expected in expectedTexts) + { + var foundIndex = -1; + for (int i = searchIndex; i < commandTexts.Count; i++) + { + if (commandTexts[i].Contains(expected)) + { + foundIndex = i; + break; + } + } + + if (foundIndex < 0) + { + throw new Xunit.Sdk.XunitException( + $"Expected text '{expected}' not found in sequence after index {searchIndex}"); + } + + searchIndex = foundIndex + 1; + } + } + + public static void AssertCommandCount(List commands, string textContains, int expectedCount) + { + var count = commands.Count(c => c.Text.Contains(textContains)); + if (count != expectedCount) + { + throw new Xunit.Sdk.XunitException( + $"Expected {expectedCount} commands containing '{textContains}', found {count}"); + } + } +} diff --git a/Inspectron.Epson.Templates.Tests/Inspectron.Epson.Templates.Tests.csproj b/Inspectron.Epson.Templates.Tests/Inspectron.Epson.Templates.Tests.csproj new file mode 100644 index 0000000..cc9a729 --- /dev/null +++ b/Inspectron.Epson.Templates.Tests/Inspectron.Epson.Templates.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + diff --git a/Inspectron.Epson.Templates.Tests/Integration/KitchenReceiptTests.cs b/Inspectron.Epson.Templates.Tests/Integration/KitchenReceiptTests.cs new file mode 100644 index 0000000..f3d9e45 --- /dev/null +++ b/Inspectron.Epson.Templates.Tests/Integration/KitchenReceiptTests.cs @@ -0,0 +1,193 @@ +using Inspectron.Epson.Templates.Configuration; +using Inspectron.Epson.Templates.Interpreter; +using Inspectron.Epson.Templates.Language; +using Inspectron.Epson.Templates.Language.Nodes; +using Inspectron.Epson.Templates.Tests.Helpers; + +namespace Inspectron.Epson.Templates.Tests.Integration; + +public class KitchenReceiptTests +{ + private readonly PrinterProfile _profile = new("tm-t30iii", "TM-T30III", 48, 24, false); + + private const string KitchenReceiptJson = """ + { + "Title": "Restaurant Test", + "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": [ + { + "Number": 1, + "Name": "Grilled Salmon", + "Modifications": { + "Removed": [], + "Added": [] + }, + "Comment": null + } + ] + } + """; + + private const string KitchenTemplate = """ + #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} # + @end + """; + + private TemplateNode Parse(string source) + { + var lexer = new Lexer(source); + var parser = new Parser(lexer.Tokenize().Tokens); + return parser.Parse().Template; + } + + [Fact] + public void KitchenReceipt_RendersFully() + { + var template = Parse(KitchenTemplate); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, KitchenReceiptJson); + + // Verify key elements are present + Assert.Contains(commands, c => c.Text.Contains("Restaurant Test")); + Assert.Contains(commands, c => c.Text.Contains("John Doe")); + Assert.Contains(commands, c => c.Text.Contains("Tisch: 5")); + Assert.Contains(commands, c => c.Text.Contains("Rush Order")); + Assert.Contains(commands, c => c.Text.Contains("1. Starters")); + Assert.Contains(commands, c => c.Text.Contains("2x Caesar Salad")); + Assert.Contains(commands, c => c.Text.Contains("- Croutons")); + Assert.Contains(commands, c => c.Text.Contains("+ Extra Dressing")); + Assert.Contains(commands, c => c.Text.Contains("Comment: No anchovies")); + Assert.Contains(commands, c => c.Text.Contains("1x Grilled Salmon")); + } + + [Fact] + public void KitchenReceipt_AppliesStyles() + { + var template = Parse(KitchenTemplate); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, KitchenReceiptJson); + + // Title should be big and tall + var titleCmd = commands.FirstOrDefault(c => c.Text.Contains("Restaurant Test")); + Assert.NotNull(titleCmd); + Assert.True(titleCmd.IsBig); + Assert.True(titleCmd.IsTall); + + // Table should be bold and big + var tableCmd = commands.FirstOrDefault(c => c.Text.Contains("Tisch: 5")); + Assert.NotNull(tableCmd); + Assert.True(tableCmd.IsBold); + Assert.True(tableCmd.IsBig); + + // Modifications should be bold and tall + var modCmd = commands.FirstOrDefault(c => c.Text.Contains("- Croutons")); + Assert.NotNull(modCmd); + Assert.True(modCmd.IsBold); + Assert.True(modCmd.IsTall); + } + + [Fact] + public void KitchenReceipt_GeneratesSeparators() + { + var template = Parse(KitchenTemplate); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, KitchenReceiptJson); + + var separatorCount = commands.Count(c => c.Text.All(ch => ch == '-') && c.Text.Length == 48); + Assert.True(separatorCount >= 2, $"Expected at least 2 separators, found {separatorCount}"); + } + + [Fact] + public void KitchenReceipt_FormatsDateTime() + { + var template = Parse(KitchenTemplate); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, KitchenReceiptJson); + + Assert.Contains(commands, c => c.Text.Contains("15-Mar-24") || c.Text.Contains("15-Mär-24")); + } + + [Fact] + public void KitchenReceipt_WithoutSpecialInstruction_OmitsSection() + { + var jsonWithoutInstruction = """ + { + "Title": "Test", + "TransactionDateTime": "2024-03-15T14:30:00", + "ReceiptNumber": "123", + "WaiterName": "John", + "WaiterId": "W001", + "TableNumber": "1", + "SpecialInstruction": null, + "Gangs": [], + "Dishes": [] + } + """; + + var template = Parse(KitchenTemplate); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, jsonWithoutInstruction); + + // Should not have any content that looks like special instruction + Assert.DoesNotContain(commands, c => c.Text.Contains("Rush")); + } +} diff --git a/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterBindingTests.cs b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterBindingTests.cs new file mode 100644 index 0000000..422bf66 --- /dev/null +++ b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterBindingTests.cs @@ -0,0 +1,132 @@ +using Inspectron.Epson.Templates.Configuration; +using Inspectron.Epson.Templates.Interpreter; +using Inspectron.Epson.Templates.Language; +using Inspectron.Epson.Templates.Language.Nodes; + +namespace Inspectron.Epson.Templates.Tests.Interpreter; + +public class InterpreterBindingTests +{ + private readonly PrinterProfile _profile = new("test", "Test Printer", 48, 24, false); + + private TemplateNode Parse(string source) + { + var lexer = new Lexer(source); + var parser = new Parser(lexer.Tokenize().Tokens); + return parser.Parse().Template; + } + + [Fact] + public void Interpret_SimpleBinding_ResolvesValue() + { + var template = Parse("{Name}"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Name": "John"}"""); + + Assert.Contains(commands, c => c.Text == "John"); + } + + [Fact] + public void Interpret_NestedBinding_ResolvesNestedValue() + { + var template = Parse("{Person.Name}"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Person": {"Name": "Jane"}}"""); + + Assert.Contains(commands, c => c.Text == "Jane"); + } + + [Fact] + public void Interpret_NumericBinding_OutputsNumber() + { + var template = Parse("{Amount}"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Amount": 42}"""); + + Assert.Contains(commands, c => c.Text == "42"); + } + + [Fact] + public void Interpret_NumericBinding_WithFormat_FormatsNumber() + { + var template = Parse("{Price:F2}"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Price": 19.5}"""); + + Assert.Contains(commands, c => c.Text == "19.50"); + } + + [Fact] + public void Interpret_DateBinding_WithFormat_FormatsDate() + { + var template = Parse("{Date:dd-MMM-yy}"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Date": "2024-03-15T14:30:00"}"""); + + var cmd = commands.FirstOrDefault(c => c.Text.Contains("Mar")); + Assert.NotNull(cmd); + Assert.Contains("15", cmd.Text); + } + + [Fact] + public void Interpret_MissingBinding_OutputsEmptyString() + { + var template = Parse("{Missing}"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Other": "value"}"""); + + Assert.Contains(commands, c => c.Text == ""); + } + + [Fact] + public void Interpret_NullBinding_OutputsEmptyString() + { + var template = Parse("{Value}"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Value": null}"""); + + Assert.Contains(commands, c => c.Text == ""); + } + + [Fact] + public void Interpret_ArrayCountBinding_OutputsCount() + { + var template = Parse("{Items.count}"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Items": [1, 2, 3, 4, 5]}"""); + + Assert.Contains(commands, c => c.Text == "5"); + } + + [Fact] + public void Interpret_BoolBinding_OutputsTrue() + { + var template = Parse("{Flag}"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Flag": true}"""); + + Assert.Contains(commands, c => c.Text == "true"); + } + + [Fact] + public void Interpret_BoolBinding_OutputsFalse() + { + var template = Parse("{Flag}"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Flag": false}"""); + + Assert.Contains(commands, c => c.Text == "false"); + } + + [Fact] + public void Interpret_BindingInStyledText_AppliesStyleToResolvedValue() + { + var template = Parse("#bold# {Name} #"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Name": "Test"}"""); + + var cmd = commands.FirstOrDefault(c => c.Text.Contains("Test")); + Assert.NotNull(cmd); + Assert.True(cmd.IsBold); + } +} diff --git a/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterConditionTests.cs b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterConditionTests.cs new file mode 100644 index 0000000..43aabd6 --- /dev/null +++ b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterConditionTests.cs @@ -0,0 +1,161 @@ +using Inspectron.Epson.Templates.Configuration; +using Inspectron.Epson.Templates.Interpreter; +using Inspectron.Epson.Templates.Language; +using Inspectron.Epson.Templates.Language.Nodes; + +namespace Inspectron.Epson.Templates.Tests.Interpreter; + +public class InterpreterConditionTests +{ + private readonly PrinterProfile _profile = new("test", "Test Printer", 48, 24, false); + + private TemplateNode Parse(string source) + { + var lexer = new Lexer(source); + var parser = new Parser(lexer.Tokenize().Tokens); + return parser.Parse().Template; + } + + [Fact] + public void Interpret_IfTruthy_ExecutesBody() + { + var template = Parse("@if HasValue\nYes\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"HasValue": true}"""); + + Assert.Contains(commands, c => c.Text == "Yes"); + } + + [Fact] + public void Interpret_IfFalsy_SkipsBody() + { + var template = Parse("@if HasValue\nYes\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"HasValue": false}"""); + + Assert.DoesNotContain(commands, c => c.Text == "Yes"); + } + + [Fact] + public void Interpret_IfStringEmpty_IsFalsy() + { + var template = Parse("@if Name\nHas Name\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Name": ""}"""); + + Assert.DoesNotContain(commands, c => c.Text == "Has Name"); + } + + [Fact] + public void Interpret_IfStringNotEmpty_IsTruthy() + { + var template = Parse("@if Name\nHas Name\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Name": "John"}"""); + + Assert.Contains(commands, c => c.Text == "Has Name"); + } + + [Fact] + public void Interpret_IfArrayEmpty_IsFalsy() + { + var template = Parse("@if Items\nHas Items\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Items": []}"""); + + Assert.DoesNotContain(commands, c => c.Text == "Has Items"); + } + + [Fact] + public void Interpret_IfArrayNotEmpty_IsTruthy() + { + var template = Parse("@if Items\nHas Items\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Items": [1, 2]}"""); + + Assert.Contains(commands, c => c.Text == "Has Items"); + } + + [Fact] + public void Interpret_IfNegation_InvertsCondition() + { + var template = Parse("@if !HasValue\nNo Value\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"HasValue": false}"""); + + Assert.Contains(commands, c => c.Text == "No Value"); + } + + [Fact] + public void Interpret_IfEqualsComparison_Works() + { + var template = Parse("@if Status == \"active\"\nActive\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Status": "active"}"""); + + Assert.Contains(commands, c => c.Text == "Active"); + } + + [Fact] + public void Interpret_IfNotEqualsComparison_Works() + { + var template = Parse("@if Status != \"inactive\"\nNot Inactive\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Status": "active"}"""); + + Assert.Contains(commands, c => c.Text == "Not Inactive"); + } + + [Fact] + public void Interpret_IfGreaterThanComparison_Works() + { + var template = Parse("@if Count > 5\nMany Items\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Count": 10}"""); + + Assert.Contains(commands, c => c.Text == "Many Items"); + } + + [Fact] + public void Interpret_IfLessThanComparison_Works() + { + var template = Parse("@if Count < 5\nFew Items\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Count": 2}"""); + + Assert.Contains(commands, c => c.Text == "Few Items"); + } + + [Fact] + public void Interpret_IfElse_ExecutesElseWhenFalse() + { + var template = Parse("@if HasValue\nYes\n@else\nNo\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"HasValue": false}"""); + + Assert.DoesNotContain(commands, c => c.Text == "Yes"); + Assert.Contains(commands, c => c.Text == "No"); + } + + [Fact] + public void Interpret_IfElseIf_ExecutesCorrectBranch() + { + var template = Parse("@if Value == 1\nOne\n@elseif Value == 2\nTwo\n@else\nOther\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Value": 2}"""); + + Assert.DoesNotContain(commands, c => c.Text == "One"); + Assert.Contains(commands, c => c.Text == "Two"); + Assert.DoesNotContain(commands, c => c.Text == "Other"); + } + + [Fact] + public void Interpret_ArrayCountComparison_Works() + { + var template = Parse("@if Items.count > 0\nHas Items\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Items": [1, 2, 3]}"""); + + Assert.Contains(commands, c => c.Text == "Has Items"); + } +} diff --git a/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterLoopTests.cs b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterLoopTests.cs new file mode 100644 index 0000000..4016752 --- /dev/null +++ b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterLoopTests.cs @@ -0,0 +1,134 @@ +using Inspectron.Epson.Templates.Configuration; +using Inspectron.Epson.Templates.Interpreter; +using Inspectron.Epson.Templates.Language; +using Inspectron.Epson.Templates.Language.Nodes; + +namespace Inspectron.Epson.Templates.Tests.Interpreter; + +public class InterpreterLoopTests +{ + private readonly PrinterProfile _profile = new("test", "Test Printer", 48, 24, false); + + private TemplateNode Parse(string source) + { + var lexer = new Lexer(source); + var parser = new Parser(lexer.Tokenize().Tokens); + return parser.Parse().Template; + } + + [Fact] + public void Interpret_Foreach_IteratesOverArray() + { + var template = Parse("@foreach item in Items\n{item}\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Items": ["A", "B", "C"]}"""); + + Assert.Contains(commands, c => c.Text == "A"); + Assert.Contains(commands, c => c.Text == "B"); + Assert.Contains(commands, c => c.Text == "C"); + } + + [Fact] + public void Interpret_Foreach_AccessesObjectProperties() + { + var template = Parse("@foreach item in Items\n{item.Name}\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Items": [{"Name": "First"}, {"Name": "Second"}]}"""); + + Assert.Contains(commands, c => c.Text == "First"); + Assert.Contains(commands, c => c.Text == "Second"); + } + + [Fact] + public void Interpret_Foreach_EmptyArray_NoOutput() + { + var template = Parse("@foreach item in Items\n{item}\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Items": []}"""); + + Assert.DoesNotContain(commands, c => !string.IsNullOrWhiteSpace(c.Text)); + } + + [Fact] + public void Interpret_Foreach_IndexMetadata_Works() + { + var template = Parse("@foreach item in Items\n{_index}:{item}\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Items": ["A", "B"]}"""); + + Assert.Contains(commands, c => c.Text == "0"); + Assert.Contains(commands, c => c.Text == "1"); + } + + [Fact] + public void Interpret_Foreach_NumberMetadata_Works() + { + var template = Parse("@foreach item in Items\n{_number}. {item}\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Items": ["A", "B"]}"""); + + Assert.Contains(commands, c => c.Text == "1"); + Assert.Contains(commands, c => c.Text == "2"); + } + + [Fact] + public void Interpret_Foreach_FirstMetadata_Works() + { + var template = Parse("@foreach item in Items\n@if _first\nFirst: {item}\n@end\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Items": ["A", "B", "C"]}"""); + + var firstCmds = commands.Where(c => c.Text.Contains("First")).ToList(); + Assert.Single(firstCmds); + Assert.Contains(commands, c => c.Text == "A"); + } + + [Fact] + public void Interpret_Foreach_LastMetadata_Works() + { + var template = Parse("@foreach item in Items\n@if _last\nLast: {item}\n@end\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Items": ["A", "B", "C"]}"""); + + Assert.Contains(commands, c => c.Text.Contains("Last")); + Assert.Contains(commands, c => c.Text == "C"); + } + + [Fact] + public void Interpret_NestedForeach_Works() + { + var template = Parse("@foreach group in Groups\n@foreach item in group.Items\n{item}\n@end\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, + """{"Groups": [{"Items": ["A", "B"]}, {"Items": ["C", "D"]}]}"""); + + Assert.Contains(commands, c => c.Text == "A"); + Assert.Contains(commands, c => c.Text == "B"); + Assert.Contains(commands, c => c.Text == "C"); + Assert.Contains(commands, c => c.Text == "D"); + } + + [Fact] + public void Interpret_Foreach_AccessesParentContext() + { + var template = Parse("@foreach item in Items\n{Title}: {item}\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Title": "List", "Items": ["A", "B"]}"""); + + Assert.Contains(commands, c => c.Text == "List"); + Assert.Contains(commands, c => c.Text == "A"); + Assert.Contains(commands, c => c.Text == "B"); + } + + [Fact] + public void Interpret_Foreach_NestedPath_Works() + { + var template = Parse("@foreach item in Data.Items\n{item.Name}\n@end"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, + """{"Data": {"Items": [{"Name": "First"}, {"Name": "Second"}]}}"""); + + Assert.Contains(commands, c => c.Text == "First"); + Assert.Contains(commands, c => c.Text == "Second"); + } +} diff --git a/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterRowTests.cs b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterRowTests.cs new file mode 100644 index 0000000..79f15b6 --- /dev/null +++ b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterRowTests.cs @@ -0,0 +1,105 @@ +using Inspectron.Epson.Templates.Configuration; +using Inspectron.Epson.Templates.Interpreter; +using Inspectron.Epson.Templates.Language; +using Inspectron.Epson.Templates.Language.Nodes; + +namespace Inspectron.Epson.Templates.Tests.Interpreter; + +public class InterpreterRowTests +{ + private readonly PrinterProfile _profile = new("test", "Test Printer", 48, 24, false); + + private TemplateNode Parse(string source) + { + var lexer = new Lexer(source); + var parser = new Parser(lexer.Tokenize().Tokens); + return parser.Parse().Template; + } + + [Fact] + public void Interpret_Row_CombinesColumns() + { + var template = Parse("@row\n|20|Name|10|Value\n@endrow"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + // Should have a single command with combined column text + var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Name") && c.Text.Contains("Value")); + Assert.NotNull(rowCmd); + } + + [Fact] + public void Interpret_Row_LeftAlignsPaddsRight() + { + var template = Parse("@row\n|10|Hi\n@endrow"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Hi")); + Assert.NotNull(rowCmd); + Assert.Equal(10, rowCmd.Text.Length); + Assert.StartsWith("Hi", rowCmd.Text); + } + + [Fact] + public void Interpret_Row_RightAlignsPaddsLeft() + { + var template = Parse("@row\n|10,right|Hi\n@endrow"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Hi")); + Assert.NotNull(rowCmd); + Assert.Equal(10, rowCmd.Text.Length); + Assert.EndsWith("Hi", rowCmd.Text); + } + + [Fact] + public void Interpret_Row_CenterAlignsCentersText() + { + var template = Parse("@row\n|10,center|Hi\n@endrow"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Hi")); + Assert.NotNull(rowCmd); + Assert.Equal(10, rowCmd.Text.Length); + Assert.StartsWith(" Hi", rowCmd.Text); + } + + [Fact] + public void Interpret_Row_TruncatesLongText() + { + var template = Parse("@row\n|5|HelloWorld\n@endrow"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Hello")); + Assert.NotNull(rowCmd); + Assert.Equal(5, rowCmd.Text.Length); + Assert.Equal("Hello", rowCmd.Text); + } + + [Fact] + public void Interpret_Row_WithBindings_ResolvesValues() + { + var template = Parse("@row\n|20|{Name}|10,right|{Price:F2}\n@endrow"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, """{"Name": "Item", "Price": 9.99}"""); + + var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("Item") && c.Text.Contains("9.99")); + Assert.NotNull(rowCmd); + } + + [Fact] + public void Interpret_Row_MultipleColumns_CorrectTotalWidth() + { + var template = Parse("@row\n|10|A|10|B|10|C\n@endrow"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var rowCmd = commands.FirstOrDefault(c => c.Text.Contains("A") && c.Text.Contains("B") && c.Text.Contains("C")); + Assert.NotNull(rowCmd); + Assert.Equal(30, rowCmd.Text.Length); + } +} diff --git a/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterTextTests.cs b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterTextTests.cs new file mode 100644 index 0000000..af090c3 --- /dev/null +++ b/Inspectron.Epson.Templates.Tests/Interpreter/InterpreterTextTests.cs @@ -0,0 +1,153 @@ +using Inspectron.Epson.Templates.Configuration; +using Inspectron.Epson.Templates.Interpreter; +using Inspectron.Epson.Templates.Language; +using Inspectron.Epson.Templates.Language.Nodes; + +namespace Inspectron.Epson.Templates.Tests.Interpreter; + +public class InterpreterTextTests +{ + private readonly PrinterProfile _profile = new("test", "Test Printer", 48, 24, false); + + private TemplateNode Parse(string source) + { + var lexer = new Lexer(source); + var parser = new Parser(lexer.Tokenize().Tokens); + return parser.Parse().Template; + } + + [Fact] + public void Interpret_PlainText_OutputsText() + { + var template = Parse("Hello World"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + Assert.Contains(commands, c => c.Text == "Hello World"); + } + + [Fact] + public void Interpret_BoldStyle_SetsBoldFlag() + { + var template = Parse("#bold# text #"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var cmd = commands.FirstOrDefault(c => c.Text.Contains("text")); + Assert.NotNull(cmd); + Assert.True(cmd.IsBold); + } + + [Fact] + public void Interpret_BigStyle_SetsBigFlag() + { + var template = Parse("#big# text #"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var cmd = commands.FirstOrDefault(c => c.Text.Contains("text")); + Assert.NotNull(cmd); + Assert.True(cmd.IsBig); + } + + [Fact] + public void Interpret_TallStyle_SetsTallFlag() + { + var template = Parse("#tall# text #"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var cmd = commands.FirstOrDefault(c => c.Text.Contains("text")); + Assert.NotNull(cmd); + Assert.True(cmd.IsTall); + } + + [Fact] + public void Interpret_RedStyle_SetsRedFlag_WhenSupported() + { + var profileWithRed = new PrinterProfile("test", "Test", 48, 24, true); + var template = Parse("#red# text #"); + var interpreter = new TemplateInterpreter(profileWithRed); + var commands = interpreter.Interpret(template, "{}"); + + var cmd = commands.FirstOrDefault(c => c.Text.Contains("text")); + Assert.NotNull(cmd); + Assert.True(cmd.IsRed); + } + + [Fact] + public void Interpret_RedStyle_IgnoresRedFlag_WhenNotSupported() + { + var profileNoRed = new PrinterProfile("test", "Test", 48, 24, false); + var template = Parse("#red# text #"); + var interpreter = new TemplateInterpreter(profileNoRed); + var commands = interpreter.Interpret(template, "{}"); + + var cmd = commands.FirstOrDefault(c => c.Text.Contains("text")); + Assert.NotNull(cmd); + Assert.False(cmd.IsRed); + } + + [Fact] + public void Interpret_CenterStyle_CentersText() + { + var template = Parse("#center# Hi #"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var cmd = commands.FirstOrDefault(c => c.Text.Contains("Hi")); + Assert.NotNull(cmd); + Assert.StartsWith(" ", cmd.Text); + } + + [Fact] + public void Interpret_MultipleStyles_CombinesFlags() + { + var template = Parse("#bold,big,tall# text #"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var cmd = commands.FirstOrDefault(c => c.Text.Contains("text")); + Assert.NotNull(cmd); + Assert.True(cmd.IsBold); + Assert.True(cmd.IsBig); + Assert.True(cmd.IsTall); + } + + [Fact] + public void Interpret_DashSeparator_OutputsFullWidthLine() + { + var template = Parse("---"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var cmd = commands.FirstOrDefault(c => c.Text.Contains('-')); + Assert.NotNull(cmd); + Assert.Equal(48, cmd.Text.Length); + Assert.True(cmd.Text.All(c => c == '-')); + } + + [Fact] + public void Interpret_EqualsSeparator_OutputsFullWidthLine() + { + var template = Parse("==="); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var cmd = commands.FirstOrDefault(c => c.Text.Contains('=')); + Assert.NotNull(cmd); + Assert.Equal(48, cmd.Text.Length); + } + + [Fact] + public void Interpret_SpacingStyle_SetsLineSpacing() + { + var template = Parse("#spacing:50# text #"); + var interpreter = new TemplateInterpreter(_profile); + var commands = interpreter.Interpret(template, "{}"); + + var cmd = commands.FirstOrDefault(c => c.Text.Contains("text")); + Assert.NotNull(cmd); + Assert.Equal(50, cmd.SetLineSpacing); + } +} diff --git a/Inspectron.Epson.Templates.Tests/Language/CommentTests.cs b/Inspectron.Epson.Templates.Tests/Language/CommentTests.cs new file mode 100644 index 0000000..fe5151b --- /dev/null +++ b/Inspectron.Epson.Templates.Tests/Language/CommentTests.cs @@ -0,0 +1,219 @@ +using Inspectron.Epson.Templates.Language; + +namespace Inspectron.Epson.Templates.Tests.Language; + +public class CommentTests +{ + #region Single-line comments + + [Fact] + public void Tokenize_SingleLineComment_ProducesNoTextTokens() + { + var lexer = new Lexer("@* This is a comment"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text); + } + + [Fact] + public void Tokenize_InlineSingleLineComment_ProducesNoTextTokens() + { + var lexer = new Lexer("@* This is a comment *@"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text); + } + + [Fact] + public void Tokenize_IndentedSingleLineComment_IsRecognizedAsComment() + { + var lexer = new Lexer(" @* indented comment"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text); + } + + [Fact] + public void Tokenize_TextBeforeAndAfterCommentLine_IsPreserved() + { + var lexer = new Lexer("Before\n@* comment\nAfter"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "Before"); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "After"); + Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text && t.Value.Contains("comment")); + } + + [Fact] + public void Tokenize_InlineCommentWithContentAfter_PreservesContentAfter() + { + var lexer = new Lexer("@* comment *@After"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "After"); + Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text && t.Value.Contains("comment")); + } + + #endregion + + #region Multi-line comments + + [Fact] + public void Tokenize_MultiLineComment_ProducesNoTextTokens() + { + var lexer = new Lexer("@*\nThis is multi-line\ncomment text\n*@"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text); + } + + [Fact] + public void Tokenize_MultiLineComment_ContentBeforeAndAfterPreserved() + { + var lexer = new Lexer("Before\n@*\ncomment\n*@\nAfter"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "Before"); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "After"); + Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text && t.Value.Contains("comment")); + } + + [Fact] + public void Tokenize_UnclosedMultiLineComment_ProducesError() + { + var lexer = new Lexer("@*\nThis comment is never closed"); + var result = lexer.Tokenize(); + + Assert.True(result.HasErrors); + Assert.Contains(result.Errors, e => e.Message.Contains("Unclosed multi-line comment")); + } + + [Fact] + public void Tokenize_MultiLineCommentWithContentAfterCloser_PreservesContent() + { + var lexer = new Lexer("@*\ncomment\n*@After"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "After"); + } + + #endregion + + #region Edge cases + + [Fact] + public void Tokenize_StarSeparator_StillWorksNotConfusedWithComment() + { + var lexer = new Lexer("***"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.StarSeparator); + } + + [Fact] + public void Tokenize_DirectivesInsideComment_AreIgnored() + { + var lexer = new Lexer("@*\n@if condition\n@foreach item in items\n*@"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.If); + Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Foreach); + } + + [Fact] + public void Tokenize_BindingsInsideComment_AreIgnored() + { + var lexer = new Lexer("@* {PropertyName} *@"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Binding); + } + + [Fact] + public void Tokenize_CommentWithMultipleStars_HandledCorrectly() + { + var lexer = new Lexer("@*** This has extra stars ***@"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text && t.Value.Contains("stars")); + } + + [Fact] + public void Tokenize_EmptyMultiLineComment_HandledCorrectly() + { + var lexer = new Lexer("@*\n*@"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text); + } + + [Fact] + public void Tokenize_CommentInComplexTemplate_WorksCorrectly() + { + var template = @"#bold,center# Title # +@* This is a header comment *@ +--- +@if HasItems +@* Loop through items *@ +@foreach item in Items +{item.Name} +@end +@end"; + var lexer = new Lexer(template); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.StyleStart); + Assert.Contains(result.Tokens, t => t.Type == TokenType.DashSeparator); + Assert.Contains(result.Tokens, t => t.Type == TokenType.If); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Foreach); + Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text && t.Value.Contains("comment")); + } + + [Fact] + public void Tokenize_MultipleCommentsInTemplate_AllStripped() + { + var template = @"Line 1 +@* Comment 1 *@ +Line 2 +@* Comment 2 +still comment +*@ +Line 3"; + var lexer = new Lexer(template); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "Line 1"); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "Line 2"); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "Line 3"); + Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.Text && t.Value.Contains("Comment")); + } + + [Fact] + public void Tokenize_SeparatorInsideMultiLineComment_IsIgnored() + { + var lexer = new Lexer("@*\n---\n===\n***\n*@"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.DashSeparator); + Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.EqualsSeparator); + Assert.DoesNotContain(result.Tokens, t => t.Type == TokenType.StarSeparator); + } + + #endregion +} diff --git a/Inspectron.Epson.Templates.Tests/Language/LexerTests.cs b/Inspectron.Epson.Templates.Tests/Language/LexerTests.cs new file mode 100644 index 0000000..43ce50c --- /dev/null +++ b/Inspectron.Epson.Templates.Tests/Language/LexerTests.cs @@ -0,0 +1,171 @@ +using Inspectron.Epson.Templates.Language; + +namespace Inspectron.Epson.Templates.Tests.Language; + +public class LexerTests +{ + [Fact] + public void Tokenize_PlainText_ReturnsTextToken() + { + var lexer = new Lexer("Hello World"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == "Hello World"); + } + + [Fact] + public void Tokenize_Binding_ReturnsBindingToken() + { + var lexer = new Lexer("{PropertyName}"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Binding && t.Value == "PropertyName"); + } + + [Fact] + public void Tokenize_BindingWithFormat_ReturnsBindingTokenWithFormat() + { + var lexer = new Lexer("{TransactionDateTime:dd-MMM-yy}"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Binding && t.Value == "TransactionDateTime:dd-MMM-yy"); + } + + [Fact] + public void Tokenize_StyleBlock_ReturnsStyleTokens() + { + var lexer = new Lexer("#bold,center# text #"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.StyleStart && t.Value == "bold,center"); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Text && t.Value == " text "); + Assert.Contains(result.Tokens, t => t.Type == TokenType.StyleEnd); + } + + [Fact] + public void Tokenize_DashSeparator_ReturnsSeparatorToken() + { + var lexer = new Lexer("---"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.DashSeparator); + } + + [Fact] + public void Tokenize_EqualsSeparator_ReturnsSeparatorToken() + { + var lexer = new Lexer("==="); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.EqualsSeparator); + } + + [Fact] + public void Tokenize_StarSeparator_ReturnsSeparatorToken() + { + var lexer = new Lexer("***"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.StarSeparator); + } + + [Fact] + public void Tokenize_IfDirective_ReturnsIfToken() + { + var lexer = new Lexer("@if condition"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.If && t.Value == "condition"); + } + + [Fact] + public void Tokenize_ForeachDirective_ReturnsForeachToken() + { + var lexer = new Lexer("@foreach item in items"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Foreach && t.Value == "item in items"); + } + + [Fact] + public void Tokenize_EndDirective_ReturnsEndToken() + { + var lexer = new Lexer("@end"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.End); + } + + [Fact] + public void Tokenize_RowDirectives_ReturnsRowTokens() + { + var lexer = new Lexer("@row\n@endrow"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Row); + Assert.Contains(result.Tokens, t => t.Type == TokenType.EndRow); + } + + [Fact] + public void Tokenize_Column_ReturnsColumnToken() + { + var lexer = new Lexer("|20|"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Column && t.Value.StartsWith("20")); + } + + [Fact] + public void Tokenize_ColumnWithAlignment_ReturnsColumnTokenWithAlignment() + { + var lexer = new Lexer("|20,right|"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Column && t.Value == "20,right"); + } + + [Fact] + public void Tokenize_MultipleLines_PreservesLineStructure() + { + var lexer = new Lexer("Line 1\nLine 2\nLine 3"); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + var newlines = result.Tokens.Count(t => t.Type == TokenType.NewLine); + Assert.Equal(3, newlines); + } + + [Fact] + public void Tokenize_ComplexTemplate_ParsesCorrectly() + { + var template = @"#bold,center# {Title} # +--- +@if HasItems +@foreach item in Items +{item.Name} +@end +@end"; + var lexer = new Lexer(template); + var result = lexer.Tokenize(); + + Assert.False(result.HasErrors); + Assert.Contains(result.Tokens, t => t.Type == TokenType.StyleStart); + Assert.Contains(result.Tokens, t => t.Type == TokenType.DashSeparator); + Assert.Contains(result.Tokens, t => t.Type == TokenType.If); + Assert.Contains(result.Tokens, t => t.Type == TokenType.Foreach); + Assert.Equal(2, result.Tokens.Count(t => t.Type == TokenType.End)); + } +} diff --git a/Inspectron.Epson.Templates.Tests/Language/ParserTests.cs b/Inspectron.Epson.Templates.Tests/Language/ParserTests.cs new file mode 100644 index 0000000..a079aea --- /dev/null +++ b/Inspectron.Epson.Templates.Tests/Language/ParserTests.cs @@ -0,0 +1,155 @@ +using Inspectron.Epson.Templates.Language; +using Inspectron.Epson.Templates.Language.Nodes; + +namespace Inspectron.Epson.Templates.Tests.Language; + +public class ParserTests +{ + private ParseResult Parse(string source) + { + var lexer = new Lexer(source); + var lexerResult = lexer.Tokenize(); + var parser = new Parser(lexerResult.Tokens); + return parser.Parse(); + } + + [Fact] + public void Parse_PlainText_ReturnsTextNode() + { + var result = Parse("Hello World"); + + Assert.False(result.HasErrors); + Assert.Single(result.Template.ChildNodes.OfType()); + } + + [Fact] + public void Parse_Binding_ReturnsBindingNode() + { + var result = Parse("{Name}"); + + Assert.False(result.HasErrors); + var binding = Assert.Single(result.Template.ChildNodes.OfType()); + Assert.Equal("Name", binding.Path); + Assert.Null(binding.Format); + } + + [Fact] + public void Parse_BindingWithFormat_PreservesFormat() + { + var result = Parse("{Date:yyyy-MM-dd}"); + + Assert.False(result.HasErrors); + var binding = Assert.Single(result.Template.ChildNodes.OfType()); + Assert.Equal("Date", binding.Path); + Assert.Equal("yyyy-MM-dd", binding.Format); + } + + [Fact] + public void Parse_StyledText_ReturnsStyledTextNode() + { + var result = Parse("#bold,center# text #"); + + Assert.False(result.HasErrors); + var styled = Assert.Single(result.Template.ChildNodes.OfType()); + Assert.Contains("bold", styled.Styles); + Assert.Contains("center", styled.Styles); + } + + [Fact] + public void Parse_DashSeparator_ReturnsSeparatorNode() + { + var result = Parse("---"); + + Assert.False(result.HasErrors); + var separator = Assert.Single(result.Template.ChildNodes.OfType()); + Assert.Equal(SeparatorStyle.Dash, separator.Style); + } + + [Fact] + public void Parse_IfBlock_ReturnsIfNode() + { + var result = Parse("@if condition\ntext\n@end"); + + Assert.False(result.HasErrors); + var ifNode = Assert.Single(result.Template.ChildNodes.OfType()); + Assert.Equal("condition", ifNode.IfBranch.Condition); + Assert.NotEmpty(ifNode.IfBranch.Body); + } + + [Fact] + public void Parse_IfElseBlock_ReturnsIfNodeWithElseBranch() + { + var result = Parse("@if condition\nif-text\n@else\nelse-text\n@end"); + + Assert.False(result.HasErrors); + var ifNode = Assert.Single(result.Template.ChildNodes.OfType()); + Assert.NotNull(ifNode.ElseBranch); + } + + [Fact] + public void Parse_IfElseIfElseBlock_ReturnsAllBranches() + { + var result = Parse("@if cond1\ntext1\n@elseif cond2\ntext2\n@else\ntext3\n@end"); + + Assert.False(result.HasErrors); + var ifNode = Assert.Single(result.Template.ChildNodes.OfType()); + Assert.Single(ifNode.ElseIfBranches); + Assert.NotNull(ifNode.ElseBranch); + } + + [Fact] + public void Parse_ForeachBlock_ReturnsForeachNode() + { + var result = Parse("@foreach item in items\n{item}\n@end"); + + Assert.False(result.HasErrors); + var foreach_ = Assert.Single(result.Template.ChildNodes.OfType()); + Assert.Equal("item", foreach_.ItemVariable); + Assert.Equal("items", foreach_.CollectionPath); + } + + [Fact] + public void Parse_NestedBlocks_HandlesNestingCorrectly() + { + var result = Parse("@foreach item in items\n@if item.visible\n{item.name}\n@end\n@end"); + + Assert.False(result.HasErrors); + var foreach_ = Assert.Single(result.Template.ChildNodes.OfType()); + Assert.Single(foreach_.Body.OfType()); + } + + [Fact] + public void Parse_RowWithColumns_ReturnsRowNode() + { + var result = Parse("@row\n|20|Name|10,right|Value\n@endrow"); + + Assert.False(result.HasErrors); + var row = Assert.Single(result.Template.ChildNodes.OfType()); + Assert.Equal(2, row.Columns.Count); + } + + [Fact] + public void Parse_EmptyLine_ReturnsEmptyLineNode() + { + var result = Parse("line1\n\nline2"); + + Assert.False(result.HasErrors); + Assert.Contains(result.Template.ChildNodes, n => n is EmptyLineNode); + } + + [Fact] + public void Parse_UnclosedIfBlock_ReportsError() + { + var result = Parse("@if condition\ntext"); + + Assert.True(result.HasErrors); + } + + [Fact] + public void Parse_UnclosedForeachBlock_ReportsError() + { + var result = Parse("@foreach item in items\ntext"); + + Assert.True(result.HasErrors); + } +} diff --git a/Inspectron.Epson.Templates/Configuration/PrinterProfile.cs b/Inspectron.Epson.Templates/Configuration/PrinterProfile.cs new file mode 100644 index 0000000..0ec8124 --- /dev/null +++ b/Inspectron.Epson.Templates/Configuration/PrinterProfile.cs @@ -0,0 +1,16 @@ +namespace Inspectron.Epson.Templates.Configuration; + +public record PrinterProfile( + string Id, + string Name, + int LineWidth, + int BigLineWidth, + bool SupportsRed) +{ + public static PrinterProfile Default { get; } = new( + Id: "default", + Name: "Default Printer", + LineWidth: 48, + BigLineWidth: 24, + SupportsRed: false); +} diff --git a/Inspectron.Epson.Templates/Configuration/PrinterProfileRegistry.cs b/Inspectron.Epson.Templates/Configuration/PrinterProfileRegistry.cs new file mode 100644 index 0000000..60c67c2 --- /dev/null +++ b/Inspectron.Epson.Templates/Configuration/PrinterProfileRegistry.cs @@ -0,0 +1,70 @@ +namespace Inspectron.Epson.Templates.Configuration; + +public class PrinterProfileRegistry +{ + private readonly Dictionary _profilesById = new(); + private readonly Dictionary _profilesByName = new(); + + public PrinterProfileRegistry() + { + // Register built-in profiles + RegisterBuiltInProfiles(); + } + + private void RegisterBuiltInProfiles() + { + // TM-T30III (default thermal printer) + var tmT30III = new PrinterProfile( + Id: "tm-t30iii", + Name: "TM-T30III", + LineWidth: 48, + BigLineWidth: 24, + SupportsRed: false); + Register(0x01, tmT30III); + + // TM-U220II (impact printer with red support) + var tmU220II = new PrinterProfile( + Id: "tm-u220ii", + Name: "TM-U220II", + LineWidth: 33, + BigLineWidth: 20, + SupportsRed: true); + Register(0x0D, tmU220II); + Register(0x13, tmU220II); + } + + public void Register(byte printerId, PrinterProfile profile) + { + _profilesById[printerId] = profile; + _profilesByName[profile.Id] = profile; + } + + public PrinterProfile GetProfile(byte printerId) + { + return _profilesById.TryGetValue(printerId, out var profile) + ? profile + : PrinterProfile.Default; + } + + public PrinterProfile GetProfile(string profileId) + { + return _profilesByName.TryGetValue(profileId, out var profile) + ? profile + : PrinterProfile.Default; + } + + public IEnumerable GetAllProfiles() + { + return _profilesByName.Values.Distinct(); + } + + public bool TryGetProfile(byte printerId, out PrinterProfile? profile) + { + return _profilesById.TryGetValue(printerId, out profile); + } + + public bool TryGetProfile(string profileId, out PrinterProfile? profile) + { + return _profilesByName.TryGetValue(profileId, out profile); + } +} diff --git a/Inspectron.Epson.Templates/Configuration/TemplateAssignment.cs b/Inspectron.Epson.Templates/Configuration/TemplateAssignment.cs new file mode 100644 index 0000000..addd41e --- /dev/null +++ b/Inspectron.Epson.Templates/Configuration/TemplateAssignment.cs @@ -0,0 +1,19 @@ +namespace Inspectron.Epson.Templates.Configuration; + +public record TemplateAssignment( + int ReceiptType, + string? ProfileId, + string TemplatePath) +{ + public bool MatchesExact(int receiptType, string profileId) + { + return ReceiptType == receiptType && + !string.IsNullOrEmpty(ProfileId) && + ProfileId.Equals(profileId, StringComparison.OrdinalIgnoreCase); + } + + public bool MatchesTypeOnly(int receiptType) + { + return ReceiptType == receiptType && string.IsNullOrEmpty(ProfileId); + } +} diff --git a/Inspectron.Epson.Templates/Configuration/TemplateConfiguration.cs b/Inspectron.Epson.Templates/Configuration/TemplateConfiguration.cs new file mode 100644 index 0000000..1b8499b --- /dev/null +++ b/Inspectron.Epson.Templates/Configuration/TemplateConfiguration.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Inspectron.Epson.Templates.Configuration; + +public class TemplateConfiguration +{ + [JsonPropertyName("assignments")] + public List Assignments { get; set; } = new(); + + [JsonPropertyName("fallbackTemplate")] + public string FallbackTemplate { get; set; } = "fallback.template"; + + public static TemplateConfiguration Load(string jsonPath) + { + var json = File.ReadAllText(jsonPath); + return JsonSerializer.Deserialize(json) + ?? new TemplateConfiguration(); + } + + public static TemplateConfiguration LoadFromJson(string json) + { + return JsonSerializer.Deserialize(json) + ?? new TemplateConfiguration(); + } + + public IEnumerable GetAssignments() + { + return Assignments.Select(a => new TemplateAssignment( + a.ReceiptType, + a.ProfileId, + a.TemplatePath)); + } +} + +public class TemplateAssignmentJson +{ + [JsonPropertyName("receiptType")] + public int ReceiptType { get; set; } + + [JsonPropertyName("profileId")] + public string? ProfileId { get; set; } + + [JsonPropertyName("template")] + public string TemplatePath { get; set; } = string.Empty; +} diff --git a/Inspectron.Epson.Templates/Configuration/TemplateResolver.cs b/Inspectron.Epson.Templates/Configuration/TemplateResolver.cs new file mode 100644 index 0000000..087653c --- /dev/null +++ b/Inspectron.Epson.Templates/Configuration/TemplateResolver.cs @@ -0,0 +1,45 @@ +namespace Inspectron.Epson.Templates.Configuration; + +public class TemplateResolver +{ + private readonly List _assignments; + private readonly string _fallbackTemplate; + + public TemplateResolver(TemplateConfiguration configuration) + { + _assignments = configuration.GetAssignments().ToList(); + _fallbackTemplate = configuration.FallbackTemplate; + } + + public TemplateResolver(IEnumerable assignments, string fallbackTemplate = "fallback.template") + { + _assignments = assignments.ToList(); + _fallbackTemplate = fallbackTemplate; + } + + public string Resolve(int receiptType, string profileId) + { + // Priority 1: Exact match (receiptType + profileId) + var exactMatch = _assignments.FirstOrDefault(a => a.MatchesExact(receiptType, profileId)); + if (exactMatch != null) + { + return exactMatch.TemplatePath; + } + + // Priority 2: Type-only match (receiptType without profileId) + var typeMatch = _assignments.FirstOrDefault(a => a.MatchesTypeOnly(receiptType)); + if (typeMatch != null) + { + return typeMatch.TemplatePath; + } + + // Priority 3: Fallback + return _fallbackTemplate; + } + + public string Resolve(int receiptType, byte printerId, PrinterProfileRegistry registry) + { + var profile = registry.GetProfile(printerId); + return Resolve(receiptType, profile.Id); + } +} diff --git a/Inspectron.Epson.Templates/Inspectron.Epson.Templates.csproj b/Inspectron.Epson.Templates/Inspectron.Epson.Templates.csproj new file mode 100644 index 0000000..75983f3 --- /dev/null +++ b/Inspectron.Epson.Templates/Inspectron.Epson.Templates.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + Inspectron.Epson.Templates + + + + + + + diff --git a/Inspectron.Epson.Templates/Interpreter/DataContext.cs b/Inspectron.Epson.Templates/Interpreter/DataContext.cs new file mode 100644 index 0000000..0e4c7de --- /dev/null +++ b/Inspectron.Epson.Templates/Interpreter/DataContext.cs @@ -0,0 +1,213 @@ +using System.Text.Json; + +namespace Inspectron.Epson.Templates.Interpreter; + +public class DataContext +{ + private readonly JsonElement _root; + private readonly DataContext? _parent; + private readonly Dictionary _localVariables = new(); + private readonly Dictionary _loopMetadata = new(); + + public DataContext(JsonElement root) + { + _root = root; + _parent = null; + } + + private DataContext(JsonElement root, DataContext parent) + { + _root = root; + _parent = parent; + } + + public DataContext CreateChildContext(string variableName, JsonElement value, int index, int count) + { + var child = new DataContext(_root, this); + child._localVariables[variableName] = value; + child._loopMetadata["_index"] = index; + child._loopMetadata["_number"] = index + 1; + child._loopMetadata["_first"] = index == 0; + child._loopMetadata["_last"] = index == count - 1; + child._loopMetadata["_count"] = count; + return child; + } + + public JsonElement? Resolve(string path) + { + if (string.IsNullOrEmpty(path)) + { + return _root; + } + + // Check loop metadata first + if (_loopMetadata.TryGetValue(path, out var metadata)) + { + return JsonSerializer.SerializeToElement(metadata); + } + + var segments = path.Split('.'); + var firstSegment = segments[0]; + + // Check local variables (loop variables) + if (_localVariables.TryGetValue(firstSegment, out var localValue)) + { + return NavigatePath(localValue, segments.Skip(1).ToArray()); + } + + // Check parent context for local variables + if (_parent != null) + { + // First check if parent has this as a local variable + var parentResult = _parent.ResolveLocalOnly(firstSegment); + if (parentResult.HasValue) + { + return NavigatePath(parentResult.Value, segments.Skip(1).ToArray()); + } + + // Also check parent's loop metadata + if (_parent._loopMetadata.TryGetValue(path, out var parentMetadata)) + { + return JsonSerializer.SerializeToElement(parentMetadata); + } + } + + // Navigate from root + return NavigatePath(_root, segments); + } + + private JsonElement? ResolveLocalOnly(string name) + { + if (_localVariables.TryGetValue(name, out var value)) + { + return value; + } + return _parent?.ResolveLocalOnly(name); + } + + private JsonElement? NavigatePath(JsonElement element, string[] segments) + { + var current = element; + + foreach (var segment in segments) + { + if (current.ValueKind == JsonValueKind.Null || current.ValueKind == JsonValueKind.Undefined) + { + return null; + } + + // Handle array access: collection.count or collection.length + if (segment.Equals("count", StringComparison.OrdinalIgnoreCase) || + segment.Equals("length", StringComparison.OrdinalIgnoreCase)) + { + if (current.ValueKind == JsonValueKind.Array) + { + return JsonSerializer.SerializeToElement(current.GetArrayLength()); + } + } + + // Handle array index: collection.0, collection.1 + if (int.TryParse(segment, out var index)) + { + if (current.ValueKind == JsonValueKind.Array && index >= 0 && index < current.GetArrayLength()) + { + current = current[index]; + continue; + } + return null; + } + + // Handle object property + if (current.ValueKind == JsonValueKind.Object) + { + if (current.TryGetProperty(segment, out var property)) + { + current = property; + } + else + { + // Try case-insensitive match + var found = false; + foreach (var prop in current.EnumerateObject()) + { + if (prop.Name.Equals(segment, StringComparison.OrdinalIgnoreCase)) + { + current = prop.Value; + found = true; + break; + } + } + if (!found) return null; + } + } + else + { + return null; + } + } + + return current; + } + + public IEnumerable ResolveCollection(string path) + { + var element = Resolve(path); + if (element.HasValue && element.Value.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.Value.EnumerateArray()) + { + yield return item; + } + } + } + + public string? GetString(string path) + { + var element = Resolve(path); + if (!element.HasValue) return null; + + return element.Value.ValueKind switch + { + JsonValueKind.String => element.Value.GetString(), + JsonValueKind.Number => element.Value.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => null, + _ => element.Value.GetRawText() + }; + } + + public bool IsTruthy(string path) + { + var element = Resolve(path); + if (!element.HasValue) return false; + + return element.Value.ValueKind switch + { + JsonValueKind.Null or JsonValueKind.Undefined => false, + JsonValueKind.False => false, + JsonValueKind.True => true, + JsonValueKind.String => !string.IsNullOrEmpty(element.Value.GetString()), + JsonValueKind.Number => element.Value.GetDouble() != 0, + JsonValueKind.Array => element.Value.GetArrayLength() > 0, + JsonValueKind.Object => true, + _ => false + }; + } + + public object? GetValue(string path) + { + var element = Resolve(path); + if (!element.HasValue) return null; + + return element.Value.ValueKind switch + { + JsonValueKind.String => element.Value.GetString(), + JsonValueKind.Number => element.Value.TryGetInt64(out var l) ? l : element.Value.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => element.Value.GetRawText() + }; + } +} diff --git a/Inspectron.Epson.Templates/Interpreter/ExpressionEvaluator.cs b/Inspectron.Epson.Templates/Interpreter/ExpressionEvaluator.cs new file mode 100644 index 0000000..8070836 --- /dev/null +++ b/Inspectron.Epson.Templates/Interpreter/ExpressionEvaluator.cs @@ -0,0 +1,176 @@ +using System.Text.RegularExpressions; + +namespace Inspectron.Epson.Templates.Interpreter; + +public class ExpressionEvaluator +{ + private static readonly Regex ComparisonRegex = new( + @"^(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)$", + RegexOptions.Compiled); + + public bool Evaluate(string expression, DataContext context) + { + expression = expression.Trim(); + + if (string.IsNullOrEmpty(expression)) + { + return false; + } + + // Handle negation: !property + if (expression.StartsWith("!")) + { + var inner = expression[1..].Trim(); + return !Evaluate(inner, context); + } + + // Handle comparison operators + var match = ComparisonRegex.Match(expression); + if (match.Success) + { + var left = match.Groups[1].Value.Trim(); + var op = match.Groups[2].Value; + var right = match.Groups[3].Value.Trim(); + + return EvaluateComparison(left, op, right, context); + } + + // Handle logical AND: property1 && property2 + if (expression.Contains("&&")) + { + var parts = expression.Split("&&", 2); + return Evaluate(parts[0], context) && Evaluate(parts[1], context); + } + + // Handle logical OR: property1 || property2 + if (expression.Contains("||")) + { + var parts = expression.Split("||", 2); + return Evaluate(parts[0], context) || Evaluate(parts[1], context); + } + + // Simple truthy check + return context.IsTruthy(expression); + } + + private bool EvaluateComparison(string left, string op, string right, DataContext context) + { + var leftValue = ResolveValue(left, context); + var rightValue = ResolveValue(right, context); + + // Handle null comparisons + if (leftValue == null && rightValue == null) + { + return op == "==" || op == ">=" || op == "<="; + } + + if (leftValue == null || rightValue == null) + { + return op switch + { + "==" => false, + "!=" => true, + _ => false + }; + } + + // Try numeric comparison first + if (TryGetNumeric(leftValue, out var leftNum) && TryGetNumeric(rightValue, out var rightNum)) + { + return op switch + { + "==" => Math.Abs(leftNum - rightNum) < 0.0001, + "!=" => Math.Abs(leftNum - rightNum) >= 0.0001, + ">" => leftNum > rightNum, + "<" => leftNum < rightNum, + ">=" => leftNum >= rightNum, + "<=" => leftNum <= rightNum, + _ => false + }; + } + + // Fall back to string comparison + var leftStr = leftValue.ToString() ?? ""; + var rightStr = rightValue.ToString() ?? ""; + + return op switch + { + "==" => leftStr.Equals(rightStr, StringComparison.OrdinalIgnoreCase), + "!=" => !leftStr.Equals(rightStr, StringComparison.OrdinalIgnoreCase), + ">" => string.Compare(leftStr, rightStr, StringComparison.OrdinalIgnoreCase) > 0, + "<" => string.Compare(leftStr, rightStr, StringComparison.OrdinalIgnoreCase) < 0, + ">=" => string.Compare(leftStr, rightStr, StringComparison.OrdinalIgnoreCase) >= 0, + "<=" => string.Compare(leftStr, rightStr, StringComparison.OrdinalIgnoreCase) <= 0, + _ => false + }; + } + + private object? ResolveValue(string expression, DataContext context) + { + expression = expression.Trim(); + + // Check for quoted string literal + if ((expression.StartsWith("\"") && expression.EndsWith("\"")) || + (expression.StartsWith("'") && expression.EndsWith("'"))) + { + return expression[1..^1]; + } + + // Check for numeric literal + if (double.TryParse(expression, out var num)) + { + return num; + } + + // Check for boolean literal + if (expression.Equals("true", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + if (expression.Equals("false", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Check for null literal + if (expression.Equals("null", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + // Resolve as path + return context.GetValue(expression); + } + + private bool TryGetNumeric(object? value, out double result) + { + result = 0; + + if (value == null) return false; + + if (value is double d) + { + result = d; + return true; + } + + if (value is long l) + { + result = l; + return true; + } + + if (value is int i) + { + result = i; + return true; + } + + if (value is string s && double.TryParse(s, out result)) + { + return true; + } + + return false; + } +} diff --git a/Inspectron.Epson.Templates/Interpreter/FormatResolver.cs b/Inspectron.Epson.Templates/Interpreter/FormatResolver.cs new file mode 100644 index 0000000..ffbeb04 --- /dev/null +++ b/Inspectron.Epson.Templates/Interpreter/FormatResolver.cs @@ -0,0 +1,129 @@ +using System.Globalization; +using System.Text.Json; + +namespace Inspectron.Epson.Templates.Interpreter; + +public class FormatResolver +{ + private readonly CultureInfo _culture; + + public FormatResolver(CultureInfo? culture = null) + { + _culture = culture ?? CultureInfo.InvariantCulture; + } + + public string Format(JsonElement? element, string? format) + { + if (!element.HasValue || element.Value.ValueKind == JsonValueKind.Null) + { + return string.Empty; + } + + var value = element.Value; + + if (string.IsNullOrEmpty(format)) + { + return GetDefaultString(value); + } + + return value.ValueKind switch + { + JsonValueKind.Number => FormatNumber(value, format), + JsonValueKind.String => FormatString(value.GetString(), format), + _ => GetDefaultString(value) + }; + } + + private string FormatNumber(JsonElement value, string format) + { + // Try to get as decimal for precision + if (value.TryGetDecimal(out var decimalValue)) + { + try + { + return decimalValue.ToString(format, _culture); + } + catch (FormatException) + { + return decimalValue.ToString(_culture); + } + } + + if (value.TryGetDouble(out var doubleValue)) + { + try + { + return doubleValue.ToString(format, _culture); + } + catch (FormatException) + { + return doubleValue.ToString(_culture); + } + } + + return value.GetRawText(); + } + + private string FormatString(string? value, string format) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + // Try to parse as DateTime + if (DateTime.TryParse(value, out var dateTime)) + { + try + { + return dateTime.ToString(format, _culture); + } + catch (FormatException) + { + return value; + } + } + + // Try to parse as DateTimeOffset (for ISO 8601 strings) + if (DateTimeOffset.TryParse(value, out var dateTimeOffset)) + { + try + { + return dateTimeOffset.ToString(format, _culture); + } + catch (FormatException) + { + return value; + } + } + + // If it's a number in string form + if (decimal.TryParse(value, out var decimalValue)) + { + try + { + return decimalValue.ToString(format, _culture); + } + catch (FormatException) + { + return value; + } + } + + return value; + } + + private string GetDefaultString(JsonElement value) + { + return value.ValueKind switch + { + JsonValueKind.String => value.GetString() ?? string.Empty, + JsonValueKind.Number => value.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => string.Empty, + JsonValueKind.Undefined => string.Empty, + _ => value.GetRawText() + }; + } +} diff --git a/Inspectron.Epson.Templates/Interpreter/InterpreterException.cs b/Inspectron.Epson.Templates/Interpreter/InterpreterException.cs new file mode 100644 index 0000000..841fe55 --- /dev/null +++ b/Inspectron.Epson.Templates/Interpreter/InterpreterException.cs @@ -0,0 +1,20 @@ +using Inspectron.Epson.Templates.Language; + +namespace Inspectron.Epson.Templates.Interpreter; + +public class InterpreterException : Exception +{ + public SourcePosition Position { get; } + + public InterpreterException(string message, SourcePosition position) + : base($"{message} at {position}") + { + Position = position; + } + + public InterpreterException(string message, SourcePosition position, Exception innerException) + : base($"{message} at {position}", innerException) + { + Position = position; + } +} diff --git a/Inspectron.Epson.Templates/Interpreter/TemplateInterpreter.cs b/Inspectron.Epson.Templates/Interpreter/TemplateInterpreter.cs new file mode 100644 index 0000000..85cc0b6 --- /dev/null +++ b/Inspectron.Epson.Templates/Interpreter/TemplateInterpreter.cs @@ -0,0 +1,428 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using Inspectron.Epson.PrintServer.Printers.Utils; +using Inspectron.Epson.Templates.Configuration; +using Inspectron.Epson.Templates.Language; +using Inspectron.Epson.Templates.Language.Nodes; + +namespace Inspectron.Epson.Templates.Interpreter; + +public class TemplateInterpreter +{ + private readonly PrinterProfile _profile; + private readonly ExpressionEvaluator _evaluator; + private readonly FormatResolver _formatter; + + public TemplateInterpreter(PrinterProfile profile, CultureInfo? culture = null) + { + _profile = profile ?? throw new ArgumentNullException(nameof(profile)); + _evaluator = new ExpressionEvaluator(); + _formatter = new FormatResolver(culture); + } + + public List Interpret(TemplateNode template, string jsonData) + { + var document = JsonDocument.Parse(jsonData); + var context = new DataContext(document.RootElement); + return Interpret(template, context); + } + + public List Interpret(TemplateNode template, DataContext context) + { + var commands = new List(); + InterpretNodes(template.ChildNodes, context, commands, new StyleContext()); + return commands; + } + + private void InterpretNodes( + IReadOnlyList nodes, + DataContext context, + List commands, + StyleContext style) + { + foreach (var node in nodes) + { + InterpretNode(node, context, commands, style); + } + } + + private void InterpretNode( + ITemplateNode node, + DataContext context, + List commands, + StyleContext style) + { + switch (node) + { + case TextNode textNode: + InterpretText(textNode, context, commands, style); + break; + + case BindingNode bindingNode: + InterpretBinding(bindingNode, context, commands, style); + break; + + case StyledTextNode styledNode: + InterpretStyledText(styledNode, context, commands, style); + break; + + case SeparatorNode separatorNode: + InterpretSeparator(separatorNode, commands, style); + break; + + case EmptyLineNode: + commands.Add(CreateCommand(string.Empty, style)); + break; + + case IfNode ifNode: + InterpretIf(ifNode, context, commands, style); + break; + + case ForeachNode foreachNode: + InterpretForeach(foreachNode, context, commands, style); + break; + + case RowNode rowNode: + InterpretRow(rowNode, context, commands, style); + break; + + case ColumnNode columnNode: + InterpretColumn(columnNode, context, commands, style); + break; + } + } + + private void InterpretText( + TextNode node, + DataContext context, + List commands, + StyleContext style) + { + var text = ApplyAlignment(node.Text, style); + commands.Add(CreateCommand(text, style)); + } + + private void InterpretBinding( + BindingNode node, + DataContext context, + List commands, + StyleContext style) + { + var element = context.Resolve(node.Path); + var text = _formatter.Format(element, node.Format); + text = ApplyAlignment(text, style); + commands.Add(CreateCommand(text, style)); + } + + private void InterpretStyledText( + StyledTextNode node, + DataContext context, + List commands, + StyleContext style) + { + // Create new style context with inherited and new styles + var newStyle = style.Clone(); + ApplyStyles(node.Styles, newStyle); + + // Build content string from child nodes + var contentBuilder = new StringBuilder(); + foreach (var child in node.ContentNodes) + { + switch (child) + { + case TextNode textNode: + contentBuilder.Append(textNode.Text); + break; + case BindingNode bindingNode: + var element = context.Resolve(bindingNode.Path); + contentBuilder.Append(_formatter.Format(element, bindingNode.Format)); + break; + } + } + + var text = contentBuilder.ToString(); + text = ApplyAlignment(text, newStyle); + commands.Add(CreateCommand(text, newStyle)); + } + + private void InterpretSeparator( + SeparatorNode node, + List commands, + StyleContext style) + { + var width = style.IsBig ? _profile.BigLineWidth : _profile.LineWidth; + var ch = node.Style switch + { + SeparatorStyle.Dash => '-', + SeparatorStyle.Equals => '=', + SeparatorStyle.Star => '*', + SeparatorStyle.Tilde => '~', + _ => '-' + }; + var line = new string(ch, width); + commands.Add(CreateCommand(line, style)); + } + + private void InterpretIf( + IfNode node, + DataContext context, + List commands, + StyleContext style) + { + // Evaluate @if condition + if (_evaluator.Evaluate(node.IfBranch.Condition, context)) + { + InterpretNodes(node.IfBranch.Body, context, commands, style); + return; + } + + // Evaluate @elseif conditions + foreach (var branch in node.ElseIfBranches) + { + if (_evaluator.Evaluate(branch.Condition, context)) + { + InterpretNodes(branch.Body, context, commands, style); + return; + } + } + + // Execute @else branch if present + if (node.ElseBranch != null) + { + InterpretNodes(node.ElseBranch, context, commands, style); + } + } + + private void InterpretForeach( + ForeachNode node, + DataContext context, + List commands, + StyleContext style) + { + var collection = context.ResolveCollection(node.CollectionPath).ToList(); + var count = collection.Count; + + for (int i = 0; i < count; i++) + { + var item = collection[i]; + var childContext = context.CreateChildContext(node.ItemVariable, item, i, count); + InterpretNodes(node.Body, childContext, commands, style); + } + } + + private void InterpretRow( + RowNode node, + DataContext context, + List commands, + StyleContext style) + { + var lineWidth = style.IsBig ? _profile.BigLineWidth : _profile.LineWidth; + var rowBuilder = new StringBuilder(); + + foreach (var column in node.Columns) + { + // Build column content + var columnContent = new StringBuilder(); + foreach (var child in column.ContentNodes) + { + switch (child) + { + case TextNode textNode: + columnContent.Append(textNode.Text); + break; + case BindingNode bindingNode: + var element = context.Resolve(bindingNode.Path); + columnContent.Append(_formatter.Format(element, bindingNode.Format)); + break; + case StyledTextNode styledNode: + foreach (var styledChild in styledNode.ContentNodes) + { + if (styledChild is TextNode st) + columnContent.Append(st.Text); + else if (styledChild is BindingNode sb) + { + var elem = context.Resolve(sb.Path); + columnContent.Append(_formatter.Format(elem, sb.Format)); + } + } + break; + } + } + + var content = columnContent.ToString(); + var width = column.Width > 0 ? column.Width : content.Length; + + // Apply column alignment + var aligned = column.Alignment switch + { + ColumnAlignment.Right => content.PadLeft(width), + ColumnAlignment.Center => CenterText(content, width), + _ => content.PadRight(width) + }; + + // Truncate if too long + if (aligned.Length > width && width > 0) + { + aligned = aligned[..width]; + } + + rowBuilder.Append(aligned); + } + + commands.Add(CreateCommand(rowBuilder.ToString(), style)); + } + + private void InterpretColumn( + ColumnNode node, + DataContext context, + List commands, + StyleContext style) + { + // Standalone column - just output content with width constraints + var contentBuilder = new StringBuilder(); + foreach (var child in node.ContentNodes) + { + switch (child) + { + case TextNode textNode: + contentBuilder.Append(textNode.Text); + break; + case BindingNode bindingNode: + var element = context.Resolve(bindingNode.Path); + contentBuilder.Append(_formatter.Format(element, bindingNode.Format)); + break; + } + } + + var content = contentBuilder.ToString(); + var width = node.Width > 0 ? node.Width : content.Length; + + var aligned = node.Alignment switch + { + ColumnAlignment.Right => content.PadLeft(width), + ColumnAlignment.Center => CenterText(content, width), + _ => content.PadRight(width) + }; + + if (aligned.Length > width && width > 0) + { + aligned = aligned[..width]; + } + + commands.Add(CreateCommand(aligned, style)); + } + + private void ApplyStyles(IReadOnlyList styles, StyleContext styleContext) + { + foreach (var style in styles) + { + var lower = style.ToLowerInvariant(); + + if (lower.StartsWith("spacing:")) + { + if (int.TryParse(lower[8..], out var spacing)) + { + styleContext.LineSpacing = spacing; + } + continue; + } + + switch (lower) + { + case "bold": + styleContext.IsBold = true; + break; + case "big": + styleContext.IsBig = true; + break; + case "tall": + styleContext.IsTall = true; + break; + case "red": + styleContext.IsRed = true; + break; + case "center": + styleContext.Alignment = TextAlignment.Center; + break; + case "right": + styleContext.Alignment = TextAlignment.Right; + break; + case "left": + styleContext.Alignment = TextAlignment.Left; + break; + } + } + } + + private string ApplyAlignment(string text, StyleContext style) + { + if (style.Alignment == TextAlignment.Left) + { + return text; + } + + var width = style.IsBig ? _profile.BigLineWidth : _profile.LineWidth; + + return style.Alignment switch + { + TextAlignment.Center => CenterText(text, width), + TextAlignment.Right => text.PadLeft(width), + _ => text + }; + } + + private string CenterText(string text, int width) + { + if (string.IsNullOrEmpty(text) || text.Length >= width) + { + return text; + } + + var totalPadding = width - text.Length; + var leftPadding = totalPadding / 2; + var rightPadding = totalPadding - leftPadding; + return new string(' ', leftPadding) + text + new string(' ', rightPadding); + } + + private PrintCommand CreateCommand(string text, StyleContext style) + { + var command = new PrintCommand(text, style.IsBig, style.IsBold) + { + IsTall = style.IsTall, + IsRed = style.IsRed && _profile.SupportsRed, + SetLineSpacing = style.LineSpacing + }; + return command; + } +} + +internal enum TextAlignment +{ + Left, + Center, + Right +} + +internal class StyleContext +{ + public bool IsBold { get; set; } + public bool IsBig { get; set; } + public bool IsTall { get; set; } + public bool IsRed { get; set; } + public TextAlignment Alignment { get; set; } = TextAlignment.Left; + public int? LineSpacing { get; set; } + + public StyleContext Clone() + { + return new StyleContext + { + IsBold = IsBold, + IsBig = IsBig, + IsTall = IsTall, + IsRed = IsRed, + Alignment = Alignment, + LineSpacing = LineSpacing + }; + } +} diff --git a/Inspectron.Epson.Templates/Language/Lexer.cs b/Inspectron.Epson.Templates/Language/Lexer.cs new file mode 100644 index 0000000..582f59e --- /dev/null +++ b/Inspectron.Epson.Templates/Language/Lexer.cs @@ -0,0 +1,331 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace Inspectron.Epson.Templates.Language; + +public class Lexer +{ + private readonly string _source; + private readonly List _lines; + private int _lineIndex; + private int _columnIndex; + private readonly List _tokens = new(); + private readonly List _errors = new(); + private bool _inMultiLineComment; + private SourcePosition _multiLineCommentStart; + + private static readonly Regex StyleStartRegex = new(@"^#([a-zA-Z0-9,:]+)#", RegexOptions.Compiled); + private static readonly Regex BindingRegex = new(@"^\{([^}]+)\}", RegexOptions.Compiled); + private static readonly Regex ColumnRegex = new(@"^\|(\d+)(?:,(\w+))?\|", RegexOptions.Compiled); + + public Lexer(string source) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _lines = _source.Split('\n').ToList(); + // Normalize line endings + for (int i = 0; i < _lines.Count; i++) + { + _lines[i] = _lines[i].TrimEnd('\r'); + } + } + + public LexerResult Tokenize() + { + _tokens.Clear(); + _errors.Clear(); + _lineIndex = 0; + _columnIndex = 0; + _inMultiLineComment = false; + _multiLineCommentStart = default; + + while (_lineIndex < _lines.Count) + { + TokenizeLine(_lines[_lineIndex]); + _tokens.Add(new Token(TokenType.NewLine, "\n", new SourcePosition(_lineIndex + 1, _columnIndex + 1))); + _lineIndex++; + _columnIndex = 0; + } + + if (_inMultiLineComment) + { + _errors.Add(new LexerError("Unclosed multi-line comment", _multiLineCommentStart)); + } + + _tokens.Add(Token.Eof(_lineIndex + 1)); + return new LexerResult(_tokens.ToList(), _errors.ToList()); + } + + private void TokenizeLine(string line) + { + int lineNumber = _lineIndex + 1; + + // Handle multi-line comment state + if (_inMultiLineComment) + { + var closeIndex = line.IndexOf("*@", StringComparison.Ordinal); + if (closeIndex >= 0) + { + // Found comment closer - exit comment mode + _inMultiLineComment = false; + var afterCloseIndex = closeIndex + 2; + if (afterCloseIndex < line.Length && !string.IsNullOrWhiteSpace(line[afterCloseIndex..])) + { + // Process content after the closing *@ + _columnIndex = afterCloseIndex; + TokenizeContent(line, lineNumber); + } + else + { + _columnIndex = line.Length; + } + } + else + { + // Still in comment - skip entire line + _columnIndex = line.Length; + } + return; + } + + // Check for comment lines (must be checked before separators) + var trimmed = line.TrimStart(); + var leadingWhitespace = line.Length - trimmed.Length; + + if (trimmed.StartsWith("@*")) + { + // Check for inline comment: @* ... *@ + var closeIndex = trimmed.IndexOf("*@", 2, StringComparison.Ordinal); + if (closeIndex >= 0) + { + // Inline single-line comment - skip entire comment portion + var afterCloseIndex = leadingWhitespace + closeIndex + 2; + if (afterCloseIndex < line.Length && !string.IsNullOrWhiteSpace(line[afterCloseIndex..])) + { + // Process content after the closing *@ + _columnIndex = afterCloseIndex; + TokenizeContent(line, lineNumber); + } + else + { + _columnIndex = line.Length; + } + } + else if (trimmed.TrimEnd() == "@*") + { + // Start of multi-line comment block + _inMultiLineComment = true; + _multiLineCommentStart = new SourcePosition(lineNumber, leadingWhitespace + 1); + _columnIndex = line.Length; + } + else + { + // Single-line comment to end of line + _columnIndex = line.Length; + } + return; + } + + // Check for separator lines (entire line) + if (IsSeparatorLine(line, out var separatorType)) + { + _tokens.Add(new Token(separatorType, line, new SourcePosition(lineNumber, 1))); + _columnIndex = line.Length; + return; + } + + // Check for directive lines + if (trimmed.StartsWith("@")) + { + if (TryTokenizeDirective(trimmed, leadingWhitespace, lineNumber)) + { + return; + } + } + + // Otherwise, process as content line + TokenizeContent(line, lineNumber); + } + + private bool IsSeparatorLine(string line, out TokenType separatorType) + { + var trimmed = line.Trim(); + separatorType = TokenType.Text; + + if (trimmed.Length >= 3) + { + if (trimmed.All(c => c == '-')) + { + separatorType = TokenType.DashSeparator; + return true; + } + if (trimmed.All(c => c == '=')) + { + separatorType = TokenType.EqualsSeparator; + return true; + } + if (trimmed.All(c => c == '*')) + { + separatorType = TokenType.StarSeparator; + return true; + } + if (trimmed.All(c => c == '~')) + { + separatorType = TokenType.TildeSeparator; + return true; + } + } + + return false; + } + + private bool TryTokenizeDirective(string trimmed, int leadingWhitespace, int lineNumber) + { + var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) return false; + + var directive = parts[0].ToLowerInvariant(); + var value = parts.Length > 1 ? string.Join(" ", parts.Skip(1)) : string.Empty; + var position = new SourcePosition(lineNumber, leadingWhitespace + 1); + + switch (directive) + { + case "@if": + _tokens.Add(new Token(TokenType.If, value, position)); + _columnIndex = trimmed.Length + leadingWhitespace; + return true; + + case "@elseif": + _tokens.Add(new Token(TokenType.ElseIf, value, position)); + _columnIndex = trimmed.Length + leadingWhitespace; + return true; + + case "@else": + _tokens.Add(new Token(TokenType.Else, string.Empty, position)); + _columnIndex = trimmed.Length + leadingWhitespace; + return true; + + case "@end": + _tokens.Add(new Token(TokenType.End, string.Empty, position)); + _columnIndex = trimmed.Length + leadingWhitespace; + return true; + + case "@foreach": + _tokens.Add(new Token(TokenType.Foreach, value, position)); + _columnIndex = trimmed.Length + leadingWhitespace; + return true; + + case "@row": + _tokens.Add(new Token(TokenType.Row, string.Empty, position)); + _columnIndex = trimmed.Length + leadingWhitespace; + return true; + + case "@endrow": + _tokens.Add(new Token(TokenType.EndRow, string.Empty, position)); + _columnIndex = trimmed.Length + leadingWhitespace; + return true; + } + + return false; + } + + private void TokenizeContent(string line, int lineNumber) + { + var textBuffer = new StringBuilder(); + int textStartColumn = 1; + bool inStyle = false; + + while (_columnIndex < line.Length) + { + var remaining = line[_columnIndex..]; + var currentColumn = _columnIndex + 1; + + // Check for style start: #styles# + if (!inStyle) + { + var styleMatch = StyleStartRegex.Match(remaining); + if (styleMatch.Success) + { + FlushTextBuffer(textBuffer, lineNumber, textStartColumn); + _tokens.Add(new Token(TokenType.StyleStart, styleMatch.Groups[1].Value, + new SourcePosition(lineNumber, currentColumn))); + _columnIndex += styleMatch.Length; + textStartColumn = _columnIndex + 1; + inStyle = true; + continue; + } + } + + // Check for style end: trailing # + if (inStyle && remaining.StartsWith("#")) + { + FlushTextBuffer(textBuffer, lineNumber, textStartColumn); + _tokens.Add(new Token(TokenType.StyleEnd, "#", + new SourcePosition(lineNumber, currentColumn))); + _columnIndex++; + textStartColumn = _columnIndex + 1; + inStyle = false; + continue; + } + + // Check for binding: {path} or {path:format} + var bindingMatch = BindingRegex.Match(remaining); + if (bindingMatch.Success) + { + FlushTextBuffer(textBuffer, lineNumber, textStartColumn); + _tokens.Add(new Token(TokenType.Binding, bindingMatch.Groups[1].Value, + new SourcePosition(lineNumber, currentColumn))); + _columnIndex += bindingMatch.Length; + textStartColumn = _columnIndex + 1; + continue; + } + + // Check for column: |width| or |width,align| + var columnMatch = ColumnRegex.Match(remaining); + if (columnMatch.Success) + { + FlushTextBuffer(textBuffer, lineNumber, textStartColumn); + var width = columnMatch.Groups[1].Value; + var align = columnMatch.Groups[2].Success ? columnMatch.Groups[2].Value : "left"; + _tokens.Add(new Token(TokenType.Column, $"{width},{align}", + new SourcePosition(lineNumber, currentColumn))); + _columnIndex += columnMatch.Length; + textStartColumn = _columnIndex + 1; + continue; + } + + // Regular text character + if (textBuffer.Length == 0) + { + textStartColumn = currentColumn; + } + textBuffer.Append(line[_columnIndex]); + _columnIndex++; + } + + // Flush remaining text + FlushTextBuffer(textBuffer, lineNumber, textStartColumn); + + // If style wasn't closed on this line, add error + if (inStyle) + { + _errors.Add(new LexerError("Unclosed style block", new SourcePosition(lineNumber, _columnIndex))); + } + } + + private void FlushTextBuffer(StringBuilder buffer, int line, int startColumn) + { + if (buffer.Length > 0) + { + _tokens.Add(new Token(TokenType.Text, buffer.ToString(), + new SourcePosition(line, startColumn))); + buffer.Clear(); + } + } +} + +public record LexerResult(List Tokens, List Errors) +{ + public bool HasErrors => Errors.Count > 0; +} + +public record LexerError(string Message, SourcePosition Position); diff --git a/Inspectron.Epson.Templates/Language/Nodes/BindingNode.cs b/Inspectron.Epson.Templates/Language/Nodes/BindingNode.cs new file mode 100644 index 0000000..c0ae31d --- /dev/null +++ b/Inspectron.Epson.Templates/Language/Nodes/BindingNode.cs @@ -0,0 +1,14 @@ +namespace Inspectron.Epson.Templates.Language.Nodes; + +public record BindingNode(string Path, string? Format, SourcePosition Position) : ITemplateNode +{ + public IReadOnlyList Children => Array.Empty(); + + public static BindingNode Parse(string value, SourcePosition position) + { + var parts = value.Split(':', 2); + var path = parts[0].Trim(); + var format = parts.Length > 1 ? parts[1].Trim() : null; + return new BindingNode(path, format, position); + } +} diff --git a/Inspectron.Epson.Templates/Language/Nodes/ColumnNode.cs b/Inspectron.Epson.Templates/Language/Nodes/ColumnNode.cs new file mode 100644 index 0000000..d813c38 --- /dev/null +++ b/Inspectron.Epson.Templates/Language/Nodes/ColumnNode.cs @@ -0,0 +1,27 @@ +namespace Inspectron.Epson.Templates.Language.Nodes; + +public enum ColumnAlignment +{ + Left, + Right, + Center +} + +public record ColumnNode( + int Width, + ColumnAlignment Alignment, + List ContentNodes, + SourcePosition Position) : ITemplateNode +{ + public IReadOnlyList Children => ContentNodes; + + public static (int Width, ColumnAlignment Alignment) ParseColumnSpec(string value) + { + var parts = value.Split(',', 2); + var width = int.Parse(parts[0]); + var align = parts.Length > 1 + ? Enum.TryParse(parts[1], true, out var a) ? a : ColumnAlignment.Left + : ColumnAlignment.Left; + return (width, align); + } +} diff --git a/Inspectron.Epson.Templates/Language/Nodes/EmptyLineNode.cs b/Inspectron.Epson.Templates/Language/Nodes/EmptyLineNode.cs new file mode 100644 index 0000000..13565c1 --- /dev/null +++ b/Inspectron.Epson.Templates/Language/Nodes/EmptyLineNode.cs @@ -0,0 +1,6 @@ +namespace Inspectron.Epson.Templates.Language.Nodes; + +public record EmptyLineNode(SourcePosition Position) : ITemplateNode +{ + public IReadOnlyList Children => Array.Empty(); +} diff --git a/Inspectron.Epson.Templates/Language/Nodes/ForeachNode.cs b/Inspectron.Epson.Templates/Language/Nodes/ForeachNode.cs new file mode 100644 index 0000000..e8d7e6e --- /dev/null +++ b/Inspectron.Epson.Templates/Language/Nodes/ForeachNode.cs @@ -0,0 +1,21 @@ +namespace Inspectron.Epson.Templates.Language.Nodes; + +public record ForeachNode( + string ItemVariable, + string CollectionPath, + List Body, + SourcePosition Position) : ITemplateNode +{ + public IReadOnlyList Children => Body; + + public static (string ItemVariable, string CollectionPath) ParseForeach(string value) + { + // Expected format: "item in collection" or "item in collection.path" + var parts = value.Split(" in ", 2, StringSplitOptions.TrimEntries); + if (parts.Length != 2) + { + throw new FormatException($"Invalid foreach syntax: '{value}'. Expected 'item in collection'."); + } + return (parts[0], parts[1]); + } +} diff --git a/Inspectron.Epson.Templates/Language/Nodes/ITemplateNode.cs b/Inspectron.Epson.Templates/Language/Nodes/ITemplateNode.cs new file mode 100644 index 0000000..1227139 --- /dev/null +++ b/Inspectron.Epson.Templates/Language/Nodes/ITemplateNode.cs @@ -0,0 +1,7 @@ +namespace Inspectron.Epson.Templates.Language.Nodes; + +public interface ITemplateNode +{ + SourcePosition Position { get; } + IReadOnlyList Children { get; } +} diff --git a/Inspectron.Epson.Templates/Language/Nodes/IfNode.cs b/Inspectron.Epson.Templates/Language/Nodes/IfNode.cs new file mode 100644 index 0000000..ded9d1b --- /dev/null +++ b/Inspectron.Epson.Templates/Language/Nodes/IfNode.cs @@ -0,0 +1,31 @@ +namespace Inspectron.Epson.Templates.Language.Nodes; + +public record ConditionBranch( + string Condition, + List Body, + SourcePosition Position); + +public record IfNode( + ConditionBranch IfBranch, + IReadOnlyList ElseIfBranches, + List? ElseBranch, + SourcePosition Position) : ITemplateNode +{ + public IReadOnlyList Children + { + get + { + var children = new List(); + children.AddRange(IfBranch.Body); + foreach (var branch in ElseIfBranches) + { + children.AddRange(branch.Body); + } + if (ElseBranch != null) + { + children.AddRange(ElseBranch); + } + return children; + } + } +} diff --git a/Inspectron.Epson.Templates/Language/Nodes/RowNode.cs b/Inspectron.Epson.Templates/Language/Nodes/RowNode.cs new file mode 100644 index 0000000..88812c0 --- /dev/null +++ b/Inspectron.Epson.Templates/Language/Nodes/RowNode.cs @@ -0,0 +1,6 @@ +namespace Inspectron.Epson.Templates.Language.Nodes; + +public record RowNode(List Columns, SourcePosition Position) : ITemplateNode +{ + public IReadOnlyList Children => Columns; +} diff --git a/Inspectron.Epson.Templates/Language/Nodes/SeparatorNode.cs b/Inspectron.Epson.Templates/Language/Nodes/SeparatorNode.cs new file mode 100644 index 0000000..abff506 --- /dev/null +++ b/Inspectron.Epson.Templates/Language/Nodes/SeparatorNode.cs @@ -0,0 +1,14 @@ +namespace Inspectron.Epson.Templates.Language.Nodes; + +public enum SeparatorStyle +{ + Dash, // --- + Equals, // === + Star, // *** + Tilde // ~~~ +} + +public record SeparatorNode(SeparatorStyle Style, SourcePosition Position) : ITemplateNode +{ + public IReadOnlyList Children => Array.Empty(); +} diff --git a/Inspectron.Epson.Templates/Language/Nodes/StyledTextNode.cs b/Inspectron.Epson.Templates/Language/Nodes/StyledTextNode.cs new file mode 100644 index 0000000..e033d6c --- /dev/null +++ b/Inspectron.Epson.Templates/Language/Nodes/StyledTextNode.cs @@ -0,0 +1,9 @@ +namespace Inspectron.Epson.Templates.Language.Nodes; + +public record StyledTextNode( + IReadOnlyList Styles, + List ContentNodes, + SourcePosition Position) : ITemplateNode +{ + public IReadOnlyList Children => ContentNodes; +} diff --git a/Inspectron.Epson.Templates/Language/Nodes/TemplateNode.cs b/Inspectron.Epson.Templates/Language/Nodes/TemplateNode.cs new file mode 100644 index 0000000..9b9ba31 --- /dev/null +++ b/Inspectron.Epson.Templates/Language/Nodes/TemplateNode.cs @@ -0,0 +1,7 @@ +namespace Inspectron.Epson.Templates.Language.Nodes; + +public record TemplateNode(List ChildNodes) : ITemplateNode +{ + public SourcePosition Position => new(1, 1); + public IReadOnlyList Children => ChildNodes; +} diff --git a/Inspectron.Epson.Templates/Language/Nodes/TextNode.cs b/Inspectron.Epson.Templates/Language/Nodes/TextNode.cs new file mode 100644 index 0000000..775ef1b --- /dev/null +++ b/Inspectron.Epson.Templates/Language/Nodes/TextNode.cs @@ -0,0 +1,6 @@ +namespace Inspectron.Epson.Templates.Language.Nodes; + +public record TextNode(string Text, SourcePosition Position) : ITemplateNode +{ + public IReadOnlyList Children => Array.Empty(); +} diff --git a/Inspectron.Epson.Templates/Language/Parser.cs b/Inspectron.Epson.Templates/Language/Parser.cs new file mode 100644 index 0000000..40b56a4 --- /dev/null +++ b/Inspectron.Epson.Templates/Language/Parser.cs @@ -0,0 +1,377 @@ +using Inspectron.Epson.Templates.Language.Nodes; + +namespace Inspectron.Epson.Templates.Language; + +public class Parser +{ + private readonly List _tokens; + private int _position; + private readonly List _errors = new(); + + public Parser(List tokens) + { + _tokens = tokens ?? throw new ArgumentNullException(nameof(tokens)); + } + + public ParseResult Parse() + { + _position = 0; + _errors.Clear(); + + var nodes = ParseNodeList(TokenType.EndOfFile); + return new ParseResult(new TemplateNode(nodes), _errors.ToList()); + } + + private List ParseNodeList(params TokenType[] terminators) + { + var nodes = new List(); + + while (!IsAtEnd() && !terminators.Contains(Current.Type)) + { + var node = ParseNode(); + if (node != null) + { + nodes.Add(node); + } + } + + return nodes; + } + + private ITemplateNode? ParseNode() + { + var token = Current; + + switch (token.Type) + { + case TokenType.NewLine: + Advance(); + // Check if this was an empty line (previous was also newline or start) + if (_position >= 2 && _tokens[_position - 2].Type == TokenType.NewLine) + { + return new EmptyLineNode(token.Position); + } + return null; + + case TokenType.Text: + return ParseText(); + + case TokenType.StyleStart: + return ParseStyledText(); + + case TokenType.Binding: + return ParseBinding(); + + case TokenType.DashSeparator: + Advance(); + return new SeparatorNode(SeparatorStyle.Dash, token.Position); + + case TokenType.EqualsSeparator: + Advance(); + return new SeparatorNode(SeparatorStyle.Equals, token.Position); + + case TokenType.StarSeparator: + Advance(); + return new SeparatorNode(SeparatorStyle.Star, token.Position); + + case TokenType.TildeSeparator: + Advance(); + return new SeparatorNode(SeparatorStyle.Tilde, token.Position); + + case TokenType.If: + return ParseIf(); + + case TokenType.Foreach: + return ParseForeach(); + + case TokenType.Row: + return ParseRow(); + + case TokenType.Column: + return ParseColumn(); + + case TokenType.ElseIf: + case TokenType.Else: + case TokenType.End: + case TokenType.EndRow: + // These are handled by parent parsers; don't consume here + return null; + + case TokenType.StyleEnd: + // Orphan style end - skip with error + AddError("Unexpected style end marker '#' without matching style start", token.Position); + Advance(); + return null; + + case TokenType.EndOfFile: + return null; + + default: + AddError($"Unexpected token type: {token.Type}", token.Position); + Advance(); + return null; + } + } + + private TextNode ParseText() + { + var token = Current; + Advance(); + return new TextNode(token.Value, token.Position); + } + + private BindingNode ParseBinding() + { + var token = Current; + Advance(); + return BindingNode.Parse(token.Value, token.Position); + } + + private StyledTextNode ParseStyledText() + { + var startToken = Current; + var styles = startToken.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + Advance(); // consume StyleStart + + var contentNodes = new List(); + + // Parse content until StyleEnd or NewLine + while (!IsAtEnd() && Current.Type != TokenType.StyleEnd && Current.Type != TokenType.NewLine) + { + if (Current.Type == TokenType.Text) + { + contentNodes.Add(ParseText()); + } + else if (Current.Type == TokenType.Binding) + { + contentNodes.Add(ParseBinding()); + } + else + { + // Unexpected token inside styled text + AddError($"Unexpected token inside styled text: {Current.Type}", Current.Position); + Advance(); + } + } + + // Consume StyleEnd if present + if (Current.Type == TokenType.StyleEnd) + { + Advance(); + } + + return new StyledTextNode(styles, contentNodes, startToken.Position); + } + + private IfNode ParseIf() + { + var ifToken = Current; + var ifCondition = ifToken.Value; + Advance(); // consume @if + + // Skip newline after @if + SkipNewlines(); + + var ifBody = ParseNodeList(TokenType.ElseIf, TokenType.Else, TokenType.End, TokenType.EndOfFile); + + var elseIfBranches = new List(); + List? elseBranch = null; + + // Parse @elseif branches + while (Current.Type == TokenType.ElseIf) + { + var elseIfToken = Current; + var elseIfCondition = elseIfToken.Value; + Advance(); // consume @elseif + SkipNewlines(); + + var elseIfBody = ParseNodeList(TokenType.ElseIf, TokenType.Else, TokenType.End, TokenType.EndOfFile); + elseIfBranches.Add(new ConditionBranch(elseIfCondition, elseIfBody, elseIfToken.Position)); + } + + // Parse @else branch + if (Current.Type == TokenType.Else) + { + Advance(); // consume @else + SkipNewlines(); + + elseBranch = ParseNodeList(TokenType.End, TokenType.EndOfFile); + } + + // Expect @end + if (Current.Type == TokenType.End) + { + Advance(); // consume @end + } + else + { + AddError("Expected @end to close @if block", Current.Position); + } + + return new IfNode( + new ConditionBranch(ifCondition, ifBody, ifToken.Position), + elseIfBranches, + elseBranch, + ifToken.Position); + } + + private ForeachNode ParseForeach() + { + var foreachToken = Current; + Advance(); // consume @foreach + + string itemVariable; + string collectionPath; + + try + { + (itemVariable, collectionPath) = ForeachNode.ParseForeach(foreachToken.Value); + } + catch (FormatException ex) + { + AddError(ex.Message, foreachToken.Position); + itemVariable = "item"; + collectionPath = "items"; + } + + SkipNewlines(); + + var body = ParseNodeList(TokenType.End, TokenType.EndOfFile); + + if (Current.Type == TokenType.End) + { + Advance(); // consume @end + } + else + { + AddError("Expected @end to close @foreach block", Current.Position); + } + + return new ForeachNode(itemVariable, collectionPath, body, foreachToken.Position); + } + + private RowNode ParseRow() + { + var rowToken = Current; + Advance(); // consume @row + SkipNewlines(); + + var columns = new List(); + + // Parse columns until @endrow + while (!IsAtEnd() && Current.Type != TokenType.EndRow && Current.Type != TokenType.EndOfFile) + { + if (Current.Type == TokenType.Column) + { + columns.Add(ParseColumnDef()); + } + else if (Current.Type == TokenType.NewLine) + { + Advance(); + } + else + { + // Content outside of column definition - create implicit column + var implicitContent = new List(); + while (!IsAtEnd() && + Current.Type != TokenType.Column && + Current.Type != TokenType.EndRow && + Current.Type != TokenType.NewLine && + Current.Type != TokenType.EndOfFile) + { + var node = ParseNode(); + if (node != null) implicitContent.Add(node); + } + + if (implicitContent.Count > 0) + { + columns.Add(new ColumnNode(0, ColumnAlignment.Left, implicitContent, Current.Position)); + } + } + } + + if (Current.Type == TokenType.EndRow) + { + Advance(); // consume @endrow + } + else + { + AddError("Expected @endrow to close @row block", Current.Position); + } + + return new RowNode(columns, rowToken.Position); + } + + private ColumnNode ParseColumnDef() + { + var columnToken = Current; + var (width, alignment) = ColumnNode.ParseColumnSpec(columnToken.Value); + Advance(); // consume column token + + var content = new List(); + + // Parse content until next column, newline, or endrow + while (!IsAtEnd() && + Current.Type != TokenType.Column && + Current.Type != TokenType.EndRow && + Current.Type != TokenType.NewLine && + Current.Type != TokenType.EndOfFile) + { + var node = ParseNode(); + if (node != null) content.Add(node); + } + + return new ColumnNode(width, alignment, content, columnToken.Position); + } + + private ColumnNode? ParseColumn() + { + // Standalone column outside of @row + var columnToken = Current; + var (width, alignment) = ColumnNode.ParseColumnSpec(columnToken.Value); + Advance(); + + var content = new List(); + + while (!IsAtEnd() && + Current.Type != TokenType.Column && + Current.Type != TokenType.NewLine && + Current.Type != TokenType.EndOfFile) + { + var node = ParseNode(); + if (node != null) content.Add(node); + } + + return new ColumnNode(width, alignment, content, columnToken.Position); + } + + private void SkipNewlines() + { + while (Current.Type == TokenType.NewLine) + { + Advance(); + } + } + + private Token Current => _position < _tokens.Count ? _tokens[_position] : Token.Eof(_tokens.Count); + + private bool IsAtEnd() => _position >= _tokens.Count || Current.Type == TokenType.EndOfFile; + + private void Advance() + { + if (!IsAtEnd()) _position++; + } + + private void AddError(string message, SourcePosition position) + { + _errors.Add(new ParseError(message, position)); + } +} + +public record ParseResult(TemplateNode Template, List Errors) +{ + public bool HasErrors => Errors.Count > 0; +} + +public record ParseError(string Message, SourcePosition Position); diff --git a/Inspectron.Epson.Templates/Language/Token.cs b/Inspectron.Epson.Templates/Language/Token.cs new file mode 100644 index 0000000..47147bc --- /dev/null +++ b/Inspectron.Epson.Templates/Language/Token.cs @@ -0,0 +1,44 @@ +namespace Inspectron.Epson.Templates.Language; + +public enum TokenType +{ + // Text content + Text, + + // Styled text: #style1,style2# text # + StyleStart, // #style1,style2# + StyleEnd, // trailing # + + // Bindings: {path} or {path:format} + Binding, + + // Separators + DashSeparator, // --- + EqualsSeparator, // === + StarSeparator, // *** + TildeSeparator, // ~~~ + + // Directives + If, // @if condition + ElseIf, // @elseif condition + Else, // @else + End, // @end + Foreach, // @foreach item in collection + Row, // @row + EndRow, // @endrow + Column, // |width| or |width,align| + + // Structural + NewLine, + EndOfFile +} + +public record struct SourcePosition(int Line, int Column) +{ + public override string ToString() => $"({Line}:{Column})"; +} + +public record Token(TokenType Type, string Value, SourcePosition Position) +{ + public static Token Eof(int line) => new(TokenType.EndOfFile, string.Empty, new SourcePosition(line, 0)); +} diff --git a/Inspectron.Epson.Templates/Storage/FileTemplateStorage.cs b/Inspectron.Epson.Templates/Storage/FileTemplateStorage.cs new file mode 100644 index 0000000..e7e99d5 --- /dev/null +++ b/Inspectron.Epson.Templates/Storage/FileTemplateStorage.cs @@ -0,0 +1,91 @@ +using System.Collections.Concurrent; + +namespace Inspectron.Epson.Templates.Storage; + +public class FileTemplateStorage : ITemplateStorage +{ + private readonly string _basePath; + private readonly string _templateExtension; + private readonly ConcurrentDictionary _cache = new(); + + public FileTemplateStorage(string basePath, string templateExtension = ".template") + { + _basePath = Path.GetFullPath(basePath); + _templateExtension = templateExtension; + + if (!Directory.Exists(_basePath)) + { + Directory.CreateDirectory(_basePath); + } + } + + public string? Load(string templatePath) + { + var normalizedPath = NormalizePath(templatePath); + + if (_cache.TryGetValue(normalizedPath, out var cached)) + { + return cached; + } + + var fullPath = GetFullPath(normalizedPath); + if (!File.Exists(fullPath)) + { + return null; + } + + var content = File.ReadAllText(fullPath); + _cache[normalizedPath] = content; + return content; + } + + public bool Exists(string templatePath) + { + var normalizedPath = NormalizePath(templatePath); + var fullPath = GetFullPath(normalizedPath); + return File.Exists(fullPath); + } + + public void Reload() + { + _cache.Clear(); + } + + public IEnumerable ListTemplates() + { + if (!Directory.Exists(_basePath)) + { + yield break; + } + + var files = Directory.GetFiles(_basePath, $"*{_templateExtension}", SearchOption.AllDirectories); + foreach (var file in files) + { + var relativePath = Path.GetRelativePath(_basePath, file); + yield return relativePath.Replace('\\', '/'); + } + } + + private string NormalizePath(string path) + { + // Ensure the path has the correct extension + if (!path.EndsWith(_templateExtension, StringComparison.OrdinalIgnoreCase)) + { + path += _templateExtension; + } + + // Normalize path separators + return path.Replace('\\', '/'); + } + + private string GetFullPath(string normalizedPath) + { + // Security: prevent directory traversal + var fullPath = Path.GetFullPath(Path.Combine(_basePath, normalizedPath)); + if (!fullPath.StartsWith(_basePath, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Invalid template path: {normalizedPath}"); + } + return fullPath; + } +} diff --git a/Inspectron.Epson.Templates/Storage/ITemplateStorage.cs b/Inspectron.Epson.Templates/Storage/ITemplateStorage.cs new file mode 100644 index 0000000..5220701 --- /dev/null +++ b/Inspectron.Epson.Templates/Storage/ITemplateStorage.cs @@ -0,0 +1,9 @@ +namespace Inspectron.Epson.Templates.Storage; + +public interface ITemplateStorage +{ + string? Load(string templatePath); + bool Exists(string templatePath); + void Reload(); + IEnumerable ListTemplates(); +} diff --git a/Inspectron.Epson.Templates/TemplateEngine.cs b/Inspectron.Epson.Templates/TemplateEngine.cs new file mode 100644 index 0000000..00e9c53 --- /dev/null +++ b/Inspectron.Epson.Templates/TemplateEngine.cs @@ -0,0 +1,115 @@ +using System.Globalization; +using Inspectron.Epson.PrintServer.Printers.Utils; +using Inspectron.Epson.Templates.Configuration; +using Inspectron.Epson.Templates.Interpreter; +using Inspectron.Epson.Templates.Language; +using Inspectron.Epson.Templates.Language.Nodes; +using Inspectron.Epson.Templates.Storage; +using Inspectron.Epson.Templates.Validation; + +namespace Inspectron.Epson.Templates; + +public class TemplateEngine +{ + private readonly ITemplateStorage _storage; + private readonly PrinterProfileRegistry _profileRegistry; + private readonly TemplateResolver _resolver; + private readonly CultureInfo? _culture; + + public TemplateEngine( + ITemplateStorage storage, + PrinterProfileRegistry profileRegistry, + TemplateResolver resolver, + CultureInfo? culture = null) + { + _storage = storage ?? throw new ArgumentNullException(nameof(storage)); + _profileRegistry = profileRegistry ?? throw new ArgumentNullException(nameof(profileRegistry)); + _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + _culture = culture; + } + + public List Render(int receiptType, byte printerId, string jsonData) + { + var profile = _profileRegistry.GetProfile(printerId); + var templatePath = _resolver.Resolve(receiptType, profile.Id); + return RenderTemplate(templatePath, profile, jsonData); + } + + public List Render(int receiptType, string profileId, string jsonData) + { + var profile = _profileRegistry.GetProfile(profileId); + var templatePath = _resolver.Resolve(receiptType, profileId); + return RenderTemplate(templatePath, profile, jsonData); + } + + public List RenderTemplate(string templatePath, PrinterProfile profile, string jsonData) + { + var templateSource = _storage.Load(templatePath); + if (templateSource == null) + { + throw new FileNotFoundException($"Template not found: {templatePath}"); + } + + return RenderSource(templateSource, profile, jsonData); + } + + public List RenderSource(string templateSource, PrinterProfile profile, string jsonData) + { + var template = Parse(templateSource); + var interpreter = new TemplateInterpreter(profile, _culture); + return interpreter.Interpret(template, jsonData); + } + + public TemplateValidationResult Validate(string templateSource, PrinterProfile? profile = null) + { + var validator = new TemplateValidator(profile); + return validator.Validate(templateSource); + } + + public TemplateValidationResult ValidateTemplate(string templatePath, PrinterProfile? profile = null) + { + var templateSource = _storage.Load(templatePath); + if (templateSource == null) + { + return TemplateValidationResult.WithErrors( + new TemplateError( + TemplateErrorCode.RTL001_UnexpectedToken, + $"Template not found: {templatePath}", + new SourcePosition(0, 0))); + } + + return Validate(templateSource, profile); + } + + public void ReloadTemplates() + { + _storage.Reload(); + } + + private TemplateNode Parse(string templateSource) + { + var lexer = new Lexer(templateSource); + var lexerResult = lexer.Tokenize(); + + if (lexerResult.HasErrors) + { + var firstError = lexerResult.Errors.First(); + throw new InterpreterException( + $"Lexer error: {firstError.Message}", + firstError.Position); + } + + var parser = new Parser(lexerResult.Tokens); + var parseResult = parser.Parse(); + + if (parseResult.HasErrors) + { + var firstError = parseResult.Errors.First(); + throw new InterpreterException( + $"Parse error: {firstError.Message}", + firstError.Position); + } + + return parseResult.Template; + } +} diff --git a/Inspectron.Epson.Templates/TemplateReceiptConverter.cs b/Inspectron.Epson.Templates/TemplateReceiptConverter.cs new file mode 100644 index 0000000..fe8df2b --- /dev/null +++ b/Inspectron.Epson.Templates/TemplateReceiptConverter.cs @@ -0,0 +1,23 @@ +using Inspectron.Epson.PrintServer.Printers.Utils; +using Inspectron.Epson.Templates.Configuration; + +namespace Inspectron.Epson.Templates; + +public class TemplateReceiptConverter : IReceiptConverter +{ + private readonly TemplateEngine _engine; + private readonly int _receiptType; + private readonly PrinterProfile _profile; + + public TemplateReceiptConverter(TemplateEngine engine, int receiptType, PrinterProfile profile) + { + _engine = engine ?? throw new ArgumentNullException(nameof(engine)); + _receiptType = receiptType; + _profile = profile ?? throw new ArgumentNullException(nameof(profile)); + } + + public List Convert(string jsonContent) + { + return _engine.Render(_receiptType, _profile.Id, jsonContent); + } +} diff --git a/Inspectron.Epson.Templates/TemplateReceiptConverterFactory.cs b/Inspectron.Epson.Templates/TemplateReceiptConverterFactory.cs new file mode 100644 index 0000000..7976468 --- /dev/null +++ b/Inspectron.Epson.Templates/TemplateReceiptConverterFactory.cs @@ -0,0 +1,22 @@ +using Inspectron.Epson.PrintServer.Printers.Utils; +using Inspectron.Epson.Templates.Configuration; + +namespace Inspectron.Epson.Templates; + +public class TemplateReceiptConverterFactory : IReceiptConverterFactory +{ + private readonly TemplateEngine _engine; + private readonly PrinterProfileRegistry _profileRegistry; + + public TemplateReceiptConverterFactory(TemplateEngine engine, PrinterProfileRegistry profileRegistry) + { + _engine = engine ?? throw new ArgumentNullException(nameof(engine)); + _profileRegistry = profileRegistry ?? throw new ArgumentNullException(nameof(profileRegistry)); + } + + public IReceiptConverter Create(int receiptType, byte printerId) + { + var profile = _profileRegistry.GetProfile(printerId); + return new TemplateReceiptConverter(_engine, receiptType, profile); + } +} diff --git a/Inspectron.Epson.Templates/Templates/assignments.json b/Inspectron.Epson.Templates/Templates/assignments.json new file mode 100644 index 0000000..d4a0850 --- /dev/null +++ b/Inspectron.Epson.Templates/Templates/assignments.json @@ -0,0 +1,25 @@ +{ + "assignments": [ + { + "receiptType": 1, + "profileId": "tm-t30iii", + "template": "kitchen-default.template" + }, + { + "receiptType": 1, + "profileId": "tm-u220ii", + "template": "kitchen-u220.template" + }, + { + "receiptType": 1, + "profileId": null, + "template": "kitchen-default.template" + }, + { + "receiptType": 2, + "profileId": null, + "template": "bar-default.template" + } + ], + "fallbackTemplate": "fallback.template" +} diff --git a/Inspectron.Epson.Templates/Templates/bar-default.template b/Inspectron.Epson.Templates/Templates/bar-default.template new file mode 100644 index 0000000..89488f1 --- /dev/null +++ b/Inspectron.Epson.Templates/Templates/bar-default.template @@ -0,0 +1,59 @@ +#red,big,center# {Title} # +--- + +#center# {TransactionDateTime:dd-MMM-yy HH:mm} Nr.:{ReceiptNumber} # +#center# {WaiterName} # +#center# {WaiterId} # +#big,bold,center# Tisch: {TableNumber} # + +@if SpecialInstruction + +#big,bold,center# {SpecialInstruction} # + +@end +--- +@foreach gang in Gangs +#red,center# {gang.Id}. {gang.Name} # +@foreach drink in gang.Dishes +{drink.Number}x {drink.Name} +@if drink.Modifications.Removed.count > 0 +@foreach removed in drink.Modifications.Removed +#bold# - {removed} # +@end +@end +@if drink.Modifications.Added.count > 0 +@foreach added in drink.Modifications.Added +#bold# + {added} # +@end +@end +@if drink.Comment +#bold# Comment: {drink.Comment} # +@end + +@end +@end +@if Gangs.count > 0 +--- +@end +@foreach drink in Dishes +{drink.Number}x {drink.Name} +@if drink.Modifications.Removed.count > 0 +@foreach removed in drink.Modifications.Removed +#bold# - {removed} # +@end +@end +@if drink.Modifications.Added.count > 0 +@foreach added in drink.Modifications.Added +#bold# + {added} # +@end +@end +@if drink.Comment +#bold# Comment: {drink.Comment} # +@end + +@end +@if Dishes.count > 0 + +--- + +@end diff --git a/Inspectron.Epson.Templates/Templates/fallback.template b/Inspectron.Epson.Templates/Templates/fallback.template new file mode 100644 index 0000000..c543f18 --- /dev/null +++ b/Inspectron.Epson.Templates/Templates/fallback.template @@ -0,0 +1,9 @@ +#center# Receipt # +--- +@if Title +{Title} +@end +@if TransactionDateTime +{TransactionDateTime:dd-MMM-yy HH:mm} +@end +--- diff --git a/Inspectron.Epson.Templates/Templates/kitchen-default.template b/Inspectron.Epson.Templates/Templates/kitchen-default.template new file mode 100644 index 0000000..d096ebc --- /dev/null +++ b/Inspectron.Epson.Templates/Templates/kitchen-default.template @@ -0,0 +1,57 @@ +#red,big,tall,center# {Title} # +--- + +#center# {TransactionDateTime:dd-MMM-yy HH:mm} Nr.:{ReceiptNumber} # +#center# {WaiterName} # +#center# {WaiterId} # +#big,bold,center# Tisch: {TableNumber} # + +@if SpecialInstruction + +#big,bold,center# {SpecialInstruction} # + +@end +--- +@foreach gang in Gangs +#red,big,tall,center# {gang.Id}. {gang.Name} # +@foreach dish in gang.Dishes +#tall# {dish.Number}x {dish.Name} # +@if dish.Modifications.Removed.count > 0 +@foreach removed in dish.Modifications.Removed +#bold,tall# - {removed} # +@end +@end +@if dish.Modifications.Added.count > 0 +@foreach added in dish.Modifications.Added +#bold,tall# + {added} # +@end +@end +@if dish.Comment +#bold,tall# Comment: {dish.Comment} # +@end +@end +@end +@if Gangs.count > 0 +--- +@end +@foreach dish in Dishes +#tall# {dish.Number}x {dish.Name} # +@if dish.Modifications.Removed.count > 0 +@foreach removed in dish.Modifications.Removed +#bold,tall# - {removed} # +@end +@end +@if dish.Modifications.Added.count > 0 +@foreach added in dish.Modifications.Added +#bold,tall# + {added} # +@end +@end +@if dish.Comment +#bold,tall# Comment: {dish.Comment} # +@end +@end +@if Dishes.count > 0 + +--- + +@end diff --git a/Inspectron.Epson.Templates/Templates/kitchen-u220.template b/Inspectron.Epson.Templates/Templates/kitchen-u220.template new file mode 100644 index 0000000..d096ebc --- /dev/null +++ b/Inspectron.Epson.Templates/Templates/kitchen-u220.template @@ -0,0 +1,57 @@ +#red,big,tall,center# {Title} # +--- + +#center# {TransactionDateTime:dd-MMM-yy HH:mm} Nr.:{ReceiptNumber} # +#center# {WaiterName} # +#center# {WaiterId} # +#big,bold,center# Tisch: {TableNumber} # + +@if SpecialInstruction + +#big,bold,center# {SpecialInstruction} # + +@end +--- +@foreach gang in Gangs +#red,big,tall,center# {gang.Id}. {gang.Name} # +@foreach dish in gang.Dishes +#tall# {dish.Number}x {dish.Name} # +@if dish.Modifications.Removed.count > 0 +@foreach removed in dish.Modifications.Removed +#bold,tall# - {removed} # +@end +@end +@if dish.Modifications.Added.count > 0 +@foreach added in dish.Modifications.Added +#bold,tall# + {added} # +@end +@end +@if dish.Comment +#bold,tall# Comment: {dish.Comment} # +@end +@end +@end +@if Gangs.count > 0 +--- +@end +@foreach dish in Dishes +#tall# {dish.Number}x {dish.Name} # +@if dish.Modifications.Removed.count > 0 +@foreach removed in dish.Modifications.Removed +#bold,tall# - {removed} # +@end +@end +@if dish.Modifications.Added.count > 0 +@foreach added in dish.Modifications.Added +#bold,tall# + {added} # +@end +@end +@if dish.Comment +#bold,tall# Comment: {dish.Comment} # +@end +@end +@if Dishes.count > 0 + +--- + +@end diff --git a/Inspectron.Epson.Templates/Validation/TemplateError.cs b/Inspectron.Epson.Templates/Validation/TemplateError.cs new file mode 100644 index 0000000..deebc6d --- /dev/null +++ b/Inspectron.Epson.Templates/Validation/TemplateError.cs @@ -0,0 +1,28 @@ +using Inspectron.Epson.Templates.Language; + +namespace Inspectron.Epson.Templates.Validation; + +public enum TemplateErrorCode +{ + // Syntax errors (RTL001-RTL010) + RTL001_UnexpectedToken = 1, + RTL002_UnclosedBlock = 2, + RTL003_InvalidDirective = 3, + RTL004_InvalidBinding = 4, + RTL005_InvalidForeachSyntax = 5, + RTL006_InvalidCondition = 6, + RTL007_UnclosedStyleBlock = 7, + RTL008_InvalidColumnSpec = 8, + RTL009_InvalidSeparator = 9, + RTL010_UnexpectedEndOfFile = 10 +} + +public record TemplateError( + TemplateErrorCode Code, + string Message, + SourcePosition Position) +{ + public string CodeString => Code.ToString().Split('_')[0]; + + public override string ToString() => $"{CodeString}: {Message} at {Position}"; +} diff --git a/Inspectron.Epson.Templates/Validation/TemplateValidationResult.cs b/Inspectron.Epson.Templates/Validation/TemplateValidationResult.cs new file mode 100644 index 0000000..8539693 --- /dev/null +++ b/Inspectron.Epson.Templates/Validation/TemplateValidationResult.cs @@ -0,0 +1,35 @@ +namespace Inspectron.Epson.Templates.Validation; + +public record TemplateValidationResult( + List Errors, + List Warnings) +{ + public bool IsValid => Errors.Count == 0; + public bool HasWarnings => Warnings.Count > 0; + + public static TemplateValidationResult Valid() => new(new List(), new List()); + + public static TemplateValidationResult WithErrors(params TemplateError[] errors) => + new(errors.ToList(), new List()); + + public override string ToString() + { + if (IsValid && !HasWarnings) + { + return "Template is valid."; + } + + var lines = new List(); + if (Errors.Count > 0) + { + lines.Add($"Errors ({Errors.Count}):"); + lines.AddRange(Errors.Select(e => $" {e}")); + } + if (Warnings.Count > 0) + { + lines.Add($"Warnings ({Warnings.Count}):"); + lines.AddRange(Warnings.Select(w => $" {w}")); + } + return string.Join(Environment.NewLine, lines); + } +} diff --git a/Inspectron.Epson.Templates/Validation/TemplateValidator.cs b/Inspectron.Epson.Templates/Validation/TemplateValidator.cs new file mode 100644 index 0000000..0ebed22 --- /dev/null +++ b/Inspectron.Epson.Templates/Validation/TemplateValidator.cs @@ -0,0 +1,228 @@ +using Inspectron.Epson.Templates.Configuration; +using Inspectron.Epson.Templates.Language; +using Inspectron.Epson.Templates.Language.Nodes; + +namespace Inspectron.Epson.Templates.Validation; + +public class TemplateValidator +{ + private readonly PrinterProfile? _profile; + private readonly List _errors = new(); + private readonly List _warnings = new(); + private readonly HashSet _definedVariables = new(); + + public TemplateValidator(PrinterProfile? profile = null) + { + _profile = profile; + } + + public TemplateValidationResult Validate(string templateSource) + { + _errors.Clear(); + _warnings.Clear(); + _definedVariables.Clear(); + + // Lexer phase + var lexer = new Lexer(templateSource); + var lexerResult = lexer.Tokenize(); + + foreach (var error in lexerResult.Errors) + { + _errors.Add(new TemplateError( + TemplateErrorCode.RTL007_UnclosedStyleBlock, + error.Message, + error.Position)); + } + + // Parser phase + var parser = new Parser(lexerResult.Tokens); + var parseResult = parser.Parse(); + + foreach (var error in parseResult.Errors) + { + var code = MapParseError(error.Message); + _errors.Add(new TemplateError(code, error.Message, error.Position)); + } + + // Semantic validation + if (_errors.Count == 0) + { + ValidateNode(parseResult.Template); + } + + return new TemplateValidationResult(_errors.ToList(), _warnings.ToList()); + } + + private void ValidateNode(ITemplateNode node) + { + switch (node) + { + case TemplateNode template: + foreach (var child in template.ChildNodes) + { + ValidateNode(child); + } + break; + + case IfNode ifNode: + ValidateCondition(ifNode.IfBranch.Condition, ifNode.Position); + if (ifNode.IfBranch.Body.Count == 0) + { + _warnings.Add(new TemplateWarning( + TemplateWarningCode.RTL100_EmptyBlock, + "Empty @if block body", + ifNode.Position)); + } + foreach (var child in ifNode.IfBranch.Body) + { + ValidateNode(child); + } + foreach (var branch in ifNode.ElseIfBranches) + { + ValidateCondition(branch.Condition, branch.Position); + foreach (var child in branch.Body) + { + ValidateNode(child); + } + } + if (ifNode.ElseBranch != null) + { + foreach (var child in ifNode.ElseBranch) + { + ValidateNode(child); + } + } + break; + + case ForeachNode foreachNode: + _definedVariables.Add(foreachNode.ItemVariable); + if (foreachNode.Body.Count == 0) + { + _warnings.Add(new TemplateWarning( + TemplateWarningCode.RTL100_EmptyBlock, + "Empty @foreach block body", + foreachNode.Position)); + } + foreach (var child in foreachNode.Body) + { + ValidateNode(child); + } + _definedVariables.Remove(foreachNode.ItemVariable); + break; + + case RowNode rowNode: + ValidateRowWidth(rowNode); + foreach (var column in rowNode.Columns) + { + ValidateNode(column); + } + break; + + case ColumnNode columnNode: + foreach (var child in columnNode.ContentNodes) + { + ValidateNode(child); + } + break; + + case StyledTextNode styledNode: + ValidateStyles(styledNode.Styles, styledNode.Position); + foreach (var child in styledNode.ContentNodes) + { + ValidateNode(child); + } + break; + + case BindingNode bindingNode: + ValidateBinding(bindingNode); + break; + + case TextNode: + case SeparatorNode: + case EmptyLineNode: + // No additional validation needed + break; + } + } + + private void ValidateCondition(string condition, SourcePosition position) + { + if (string.IsNullOrWhiteSpace(condition)) + { + _errors.Add(new TemplateError( + TemplateErrorCode.RTL006_InvalidCondition, + "Empty condition expression", + position)); + } + } + + private void ValidateBinding(BindingNode node) + { + if (string.IsNullOrWhiteSpace(node.Path)) + { + _errors.Add(new TemplateError( + TemplateErrorCode.RTL004_InvalidBinding, + "Empty binding path", + node.Position)); + } + } + + private void ValidateStyles(IReadOnlyList styles, SourcePosition position) + { + var validStyles = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "bold", "big", "tall", "red", "center", "right", "left" + }; + + foreach (var style in styles) + { + var styleName = style.ToLowerInvariant(); + if (styleName.StartsWith("spacing:")) + { + var spacingValue = styleName[8..]; + if (!int.TryParse(spacingValue, out _)) + { + _warnings.Add(new TemplateWarning( + TemplateWarningCode.RTL102_PossibleNullReference, + $"Invalid spacing value: '{spacingValue}'", + position)); + } + continue; + } + + if (!validStyles.Contains(styleName)) + { + _warnings.Add(new TemplateWarning( + TemplateWarningCode.RTL102_PossibleNullReference, + $"Unknown style: '{style}'", + position)); + } + } + } + + private void ValidateRowWidth(RowNode row) + { + if (_profile == null) return; + + var totalWidth = row.Columns.Sum(c => c.Width); + if (totalWidth > _profile.LineWidth) + { + _warnings.Add(new TemplateWarning( + TemplateWarningCode.RTL103_LineWidthExceeded, + $"Row total width ({totalWidth}) exceeds printer line width ({_profile.LineWidth})", + row.Position)); + } + } + + private TemplateErrorCode MapParseError(string message) + { + if (message.Contains("@end")) + return TemplateErrorCode.RTL002_UnclosedBlock; + if (message.Contains("foreach")) + return TemplateErrorCode.RTL005_InvalidForeachSyntax; + if (message.Contains("unexpected", StringComparison.OrdinalIgnoreCase)) + return TemplateErrorCode.RTL001_UnexpectedToken; + + return TemplateErrorCode.RTL001_UnexpectedToken; + } +} diff --git a/Inspectron.Epson.Templates/Validation/TemplateWarning.cs b/Inspectron.Epson.Templates/Validation/TemplateWarning.cs new file mode 100644 index 0000000..1d55ec4 --- /dev/null +++ b/Inspectron.Epson.Templates/Validation/TemplateWarning.cs @@ -0,0 +1,22 @@ +using Inspectron.Epson.Templates.Language; + +namespace Inspectron.Epson.Templates.Validation; + +public enum TemplateWarningCode +{ + // Semantic warnings (RTL100-RTL103) + RTL100_EmptyBlock = 100, + RTL101_UnusedVariable = 101, + RTL102_PossibleNullReference = 102, + RTL103_LineWidthExceeded = 103 +} + +public record TemplateWarning( + TemplateWarningCode Code, + string Message, + SourcePosition Position) +{ + public string CodeString => Code.ToString().Split('_')[0]; + + public override string ToString() => $"{CodeString}: {Message} at {Position}"; +} diff --git a/Inspectron.Epson.slnx b/Inspectron.Epson.slnx index 8b399f7..e8c9668 100644 --- a/Inspectron.Epson.slnx +++ b/Inspectron.Epson.slnx @@ -1,12 +1,12 @@ - + - - - - + + + + diff --git a/Inspectron.Epson/HtmlPrinter.cs b/Inspectron.Epson/HtmlPrinter.cs index 914d3a3..517db9f 100644 --- a/Inspectron.Epson/HtmlPrinter.cs +++ b/Inspectron.Epson/HtmlPrinter.cs @@ -486,8 +486,8 @@ public class HtmlPrinter : IEpsonPrinter var (baseWidth, baseHeight) = FontSizes[_currentFont]; // Apply multipliers - int effectiveWidthMultiplier = _fontWidthMultiplier; - int effectiveHeightMultiplier = _fontHeightMultiplier; + float effectiveWidthMultiplier = _fontWidthMultiplier; + float effectiveHeightMultiplier = _fontHeightMultiplier; if (_isQuadrupleMode) { @@ -497,21 +497,21 @@ public class HtmlPrinter : IEpsonPrinter if (_isBiggerFontWidthTM220) { - effectiveWidthMultiplier *= 2; + effectiveWidthMultiplier *= 2f; } if (_isBiggerFontHeightTM220) { - effectiveHeightMultiplier *= 2; + effectiveHeightMultiplier *= 1.2f; } - int fontSize = baseHeight * effectiveHeightMultiplier; + int fontSize = (int)(baseHeight * effectiveHeightMultiplier); styles.Add($"font-size: {fontSize}px"); // Letter spacing for width scaling (approximate) if (effectiveWidthMultiplier > 1) { - int letterSpacing = (effectiveWidthMultiplier) * baseWidth / 2; + int letterSpacing = (int)((effectiveWidthMultiplier) * baseWidth / 2); styles.Add($"letter-spacing: {letterSpacing}px"); } diff --git a/template_syntax.md b/template_syntax.md new file mode 100644 index 0000000..b339a67 --- /dev/null +++ b/template_syntax.md @@ -0,0 +1,897 @@ +# 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 +``` + +### 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 | +| `@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 |