templates support
This commit is contained in:
@@ -1,7 +1,13 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(grep:*)"
|
"Bash(grep:*)",
|
||||||
|
"Bash(xargs:*)",
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(tree:*)",
|
||||||
|
"Bash(wc:*)",
|
||||||
|
"Bash(dotnet build:*)",
|
||||||
|
"Bash(dotnet test:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
EpsonTemplatesTest/EpsonTemplatesTest.csproj
Normal file
21
EpsonTemplatesTest/EpsonTemplatesTest.csproj
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Inspectron.Epson.Templates\Inspectron.Epson.Templates.csproj" />
|
||||||
|
<ProjectReference Include="..\Inspectron.Epson\Inspectron.Epson.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="kitchen-u220.template">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
100
EpsonTemplatesTest/Program.cs
Normal file
100
EpsonTemplatesTest/Program.cs
Normal file
@@ -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<string> { "Wasabi" },
|
||||||
|
Added = new List<string> { "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<string> { "Onion" },
|
||||||
|
Added = new List<string> { "Extra Sauce" }
|
||||||
|
},
|
||||||
|
Comment = "Medium rare"
|
||||||
|
});
|
||||||
|
receipt.Gangs[0].Dishes.Add(new Dish(1, "Salmon Taco")
|
||||||
|
{
|
||||||
|
Modifications = new DishModifications
|
||||||
|
{
|
||||||
|
Added = new List<string> { "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();
|
||||||
57
EpsonTemplatesTest/kitchen-u220.template
Normal file
57
EpsonTemplatesTest/kitchen-u220.template
Normal file
@@ -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
|
||||||
@@ -378,7 +378,7 @@ receipt.Gangs[0].Dishes.Add(new Dish(1, "Salmon Taco")
|
|||||||
|
|
||||||
var serializedReceipt = JsonSerializer.Serialize(receipt, new JsonSerializerOptions { WriteIndented = true });
|
var serializedReceipt = JsonSerializer.Serialize(receipt, new JsonSerializerOptions { WriteIndented = true });
|
||||||
var deserializedReceipt = JsonSerializer.Deserialize<KitchenReceipt>(serializedReceipt);
|
var deserializedReceipt = JsonSerializer.Deserialize<KitchenReceipt>(serializedReceipt);
|
||||||
KitchenReceiptConverter converter = new KitchenReceiptConverter(bigLineWidth: 12, lineWidth: 33);
|
KitchenReceiptConverter converter = new KitchenReceiptConverter(bigLineWidth: 18, lineWidth: 33);
|
||||||
var printCommands = converter.ConvertToPrintCommands(deserializedReceipt);
|
var printCommands = converter.ConvertToPrintCommands(deserializedReceipt);
|
||||||
|
|
||||||
Console.WriteLine("=== PRINT COMMANDS ===\n");
|
Console.WriteLine("=== PRINT COMMANDS ===\n");
|
||||||
@@ -398,7 +398,7 @@ foreach (var command in printCommands)
|
|||||||
if (command.IsRed) attributes += "[RED] ";
|
if (command.IsRed) attributes += "[RED] ";
|
||||||
|
|
||||||
Console.WriteLine($"{attributes}{command.Text}");
|
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);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PrintCommand> 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<PrintCommand> 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<PrintCommand> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.5.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Inspectron.Epson.Templates\Inspectron.Epson.Templates.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
219
Inspectron.Epson.Templates.Tests/Language/CommentTests.cs
Normal file
219
Inspectron.Epson.Templates.Tests/Language/CommentTests.cs
Normal file
@@ -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
|
||||||
|
}
|
||||||
171
Inspectron.Epson.Templates.Tests/Language/LexerTests.cs
Normal file
171
Inspectron.Epson.Templates.Tests/Language/LexerTests.cs
Normal file
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
155
Inspectron.Epson.Templates.Tests/Language/ParserTests.cs
Normal file
155
Inspectron.Epson.Templates.Tests/Language/ParserTests.cs
Normal file
@@ -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<TextNode>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_Binding_ReturnsBindingNode()
|
||||||
|
{
|
||||||
|
var result = Parse("{Name}");
|
||||||
|
|
||||||
|
Assert.False(result.HasErrors);
|
||||||
|
var binding = Assert.Single(result.Template.ChildNodes.OfType<BindingNode>());
|
||||||
|
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<BindingNode>());
|
||||||
|
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<StyledTextNode>());
|
||||||
|
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<SeparatorNode>());
|
||||||
|
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<IfNode>());
|
||||||
|
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<IfNode>());
|
||||||
|
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<IfNode>());
|
||||||
|
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<ForeachNode>());
|
||||||
|
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<ForeachNode>());
|
||||||
|
Assert.Single(foreach_.Body.OfType<IfNode>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<RowNode>());
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
Inspectron.Epson.Templates/Configuration/PrinterProfile.cs
Normal file
16
Inspectron.Epson.Templates/Configuration/PrinterProfile.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
namespace Inspectron.Epson.Templates.Configuration;
|
||||||
|
|
||||||
|
public class PrinterProfileRegistry
|
||||||
|
{
|
||||||
|
private readonly Dictionary<byte, PrinterProfile> _profilesById = new();
|
||||||
|
private readonly Dictionary<string, PrinterProfile> _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<PrinterProfile> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TemplateAssignmentJson> 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<TemplateConfiguration>(json)
|
||||||
|
?? new TemplateConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TemplateConfiguration LoadFromJson(string json)
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<TemplateConfiguration>(json)
|
||||||
|
?? new TemplateConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<TemplateAssignment> 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;
|
||||||
|
}
|
||||||
45
Inspectron.Epson.Templates/Configuration/TemplateResolver.cs
Normal file
45
Inspectron.Epson.Templates/Configuration/TemplateResolver.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
namespace Inspectron.Epson.Templates.Configuration;
|
||||||
|
|
||||||
|
public class TemplateResolver
|
||||||
|
{
|
||||||
|
private readonly List<TemplateAssignment> _assignments;
|
||||||
|
private readonly string _fallbackTemplate;
|
||||||
|
|
||||||
|
public TemplateResolver(TemplateConfiguration configuration)
|
||||||
|
{
|
||||||
|
_assignments = configuration.GetAssignments().ToList();
|
||||||
|
_fallbackTemplate = configuration.FallbackTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TemplateResolver(IEnumerable<TemplateAssignment> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Inspectron.Epson.Templates/Inspectron.Epson.Templates.csproj
Normal file
14
Inspectron.Epson.Templates/Inspectron.Epson.Templates.csproj
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>Inspectron.Epson.Templates</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Inspectron.Epson\Inspectron.Epson.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
213
Inspectron.Epson.Templates/Interpreter/DataContext.cs
Normal file
213
Inspectron.Epson.Templates/Interpreter/DataContext.cs
Normal file
@@ -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<string, JsonElement> _localVariables = new();
|
||||||
|
private readonly Dictionary<string, object> _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<JsonElement> 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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
176
Inspectron.Epson.Templates/Interpreter/ExpressionEvaluator.cs
Normal file
176
Inspectron.Epson.Templates/Interpreter/ExpressionEvaluator.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
129
Inspectron.Epson.Templates/Interpreter/FormatResolver.cs
Normal file
129
Inspectron.Epson.Templates/Interpreter/FormatResolver.cs
Normal file
@@ -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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
428
Inspectron.Epson.Templates/Interpreter/TemplateInterpreter.cs
Normal file
428
Inspectron.Epson.Templates/Interpreter/TemplateInterpreter.cs
Normal file
@@ -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<PrintCommand> Interpret(TemplateNode template, string jsonData)
|
||||||
|
{
|
||||||
|
var document = JsonDocument.Parse(jsonData);
|
||||||
|
var context = new DataContext(document.RootElement);
|
||||||
|
return Interpret(template, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PrintCommand> Interpret(TemplateNode template, DataContext context)
|
||||||
|
{
|
||||||
|
var commands = new List<PrintCommand>();
|
||||||
|
InterpretNodes(template.ChildNodes, context, commands, new StyleContext());
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InterpretNodes(
|
||||||
|
IReadOnlyList<ITemplateNode> nodes,
|
||||||
|
DataContext context,
|
||||||
|
List<PrintCommand> commands,
|
||||||
|
StyleContext style)
|
||||||
|
{
|
||||||
|
foreach (var node in nodes)
|
||||||
|
{
|
||||||
|
InterpretNode(node, context, commands, style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InterpretNode(
|
||||||
|
ITemplateNode node,
|
||||||
|
DataContext context,
|
||||||
|
List<PrintCommand> 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<PrintCommand> commands,
|
||||||
|
StyleContext style)
|
||||||
|
{
|
||||||
|
var text = ApplyAlignment(node.Text, style);
|
||||||
|
commands.Add(CreateCommand(text, style));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InterpretBinding(
|
||||||
|
BindingNode node,
|
||||||
|
DataContext context,
|
||||||
|
List<PrintCommand> 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<PrintCommand> 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<PrintCommand> 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<PrintCommand> 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<PrintCommand> 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<PrintCommand> 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<PrintCommand> 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<string> 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
331
Inspectron.Epson.Templates/Language/Lexer.cs
Normal file
331
Inspectron.Epson.Templates/Language/Lexer.cs
Normal file
@@ -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<string> _lines;
|
||||||
|
private int _lineIndex;
|
||||||
|
private int _columnIndex;
|
||||||
|
private readonly List<Token> _tokens = new();
|
||||||
|
private readonly List<LexerError> _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<Token> Tokens, List<LexerError> Errors)
|
||||||
|
{
|
||||||
|
public bool HasErrors => Errors.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record LexerError(string Message, SourcePosition Position);
|
||||||
14
Inspectron.Epson.Templates/Language/Nodes/BindingNode.cs
Normal file
14
Inspectron.Epson.Templates/Language/Nodes/BindingNode.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||||
|
|
||||||
|
public record BindingNode(string Path, string? Format, SourcePosition Position) : ITemplateNode
|
||||||
|
{
|
||||||
|
public IReadOnlyList<ITemplateNode> Children => Array.Empty<ITemplateNode>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
Inspectron.Epson.Templates/Language/Nodes/ColumnNode.cs
Normal file
27
Inspectron.Epson.Templates/Language/Nodes/ColumnNode.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||||
|
|
||||||
|
public enum ColumnAlignment
|
||||||
|
{
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Center
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ColumnNode(
|
||||||
|
int Width,
|
||||||
|
ColumnAlignment Alignment,
|
||||||
|
List<ITemplateNode> ContentNodes,
|
||||||
|
SourcePosition Position) : ITemplateNode
|
||||||
|
{
|
||||||
|
public IReadOnlyList<ITemplateNode> 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<ColumnAlignment>(parts[1], true, out var a) ? a : ColumnAlignment.Left
|
||||||
|
: ColumnAlignment.Left;
|
||||||
|
return (width, align);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||||
|
|
||||||
|
public record EmptyLineNode(SourcePosition Position) : ITemplateNode
|
||||||
|
{
|
||||||
|
public IReadOnlyList<ITemplateNode> Children => Array.Empty<ITemplateNode>();
|
||||||
|
}
|
||||||
21
Inspectron.Epson.Templates/Language/Nodes/ForeachNode.cs
Normal file
21
Inspectron.Epson.Templates/Language/Nodes/ForeachNode.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||||
|
|
||||||
|
public record ForeachNode(
|
||||||
|
string ItemVariable,
|
||||||
|
string CollectionPath,
|
||||||
|
List<ITemplateNode> Body,
|
||||||
|
SourcePosition Position) : ITemplateNode
|
||||||
|
{
|
||||||
|
public IReadOnlyList<ITemplateNode> 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||||
|
|
||||||
|
public interface ITemplateNode
|
||||||
|
{
|
||||||
|
SourcePosition Position { get; }
|
||||||
|
IReadOnlyList<ITemplateNode> Children { get; }
|
||||||
|
}
|
||||||
31
Inspectron.Epson.Templates/Language/Nodes/IfNode.cs
Normal file
31
Inspectron.Epson.Templates/Language/Nodes/IfNode.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||||
|
|
||||||
|
public record ConditionBranch(
|
||||||
|
string Condition,
|
||||||
|
List<ITemplateNode> Body,
|
||||||
|
SourcePosition Position);
|
||||||
|
|
||||||
|
public record IfNode(
|
||||||
|
ConditionBranch IfBranch,
|
||||||
|
IReadOnlyList<ConditionBranch> ElseIfBranches,
|
||||||
|
List<ITemplateNode>? ElseBranch,
|
||||||
|
SourcePosition Position) : ITemplateNode
|
||||||
|
{
|
||||||
|
public IReadOnlyList<ITemplateNode> Children
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var children = new List<ITemplateNode>();
|
||||||
|
children.AddRange(IfBranch.Body);
|
||||||
|
foreach (var branch in ElseIfBranches)
|
||||||
|
{
|
||||||
|
children.AddRange(branch.Body);
|
||||||
|
}
|
||||||
|
if (ElseBranch != null)
|
||||||
|
{
|
||||||
|
children.AddRange(ElseBranch);
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
Inspectron.Epson.Templates/Language/Nodes/RowNode.cs
Normal file
6
Inspectron.Epson.Templates/Language/Nodes/RowNode.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||||
|
|
||||||
|
public record RowNode(List<ColumnNode> Columns, SourcePosition Position) : ITemplateNode
|
||||||
|
{
|
||||||
|
public IReadOnlyList<ITemplateNode> Children => Columns;
|
||||||
|
}
|
||||||
14
Inspectron.Epson.Templates/Language/Nodes/SeparatorNode.cs
Normal file
14
Inspectron.Epson.Templates/Language/Nodes/SeparatorNode.cs
Normal file
@@ -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<ITemplateNode> Children => Array.Empty<ITemplateNode>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||||
|
|
||||||
|
public record StyledTextNode(
|
||||||
|
IReadOnlyList<string> Styles,
|
||||||
|
List<ITemplateNode> ContentNodes,
|
||||||
|
SourcePosition Position) : ITemplateNode
|
||||||
|
{
|
||||||
|
public IReadOnlyList<ITemplateNode> Children => ContentNodes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||||
|
|
||||||
|
public record TemplateNode(List<ITemplateNode> ChildNodes) : ITemplateNode
|
||||||
|
{
|
||||||
|
public SourcePosition Position => new(1, 1);
|
||||||
|
public IReadOnlyList<ITemplateNode> Children => ChildNodes;
|
||||||
|
}
|
||||||
6
Inspectron.Epson.Templates/Language/Nodes/TextNode.cs
Normal file
6
Inspectron.Epson.Templates/Language/Nodes/TextNode.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||||
|
|
||||||
|
public record TextNode(string Text, SourcePosition Position) : ITemplateNode
|
||||||
|
{
|
||||||
|
public IReadOnlyList<ITemplateNode> Children => Array.Empty<ITemplateNode>();
|
||||||
|
}
|
||||||
377
Inspectron.Epson.Templates/Language/Parser.cs
Normal file
377
Inspectron.Epson.Templates/Language/Parser.cs
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
using Inspectron.Epson.Templates.Language.Nodes;
|
||||||
|
|
||||||
|
namespace Inspectron.Epson.Templates.Language;
|
||||||
|
|
||||||
|
public class Parser
|
||||||
|
{
|
||||||
|
private readonly List<Token> _tokens;
|
||||||
|
private int _position;
|
||||||
|
private readonly List<ParseError> _errors = new();
|
||||||
|
|
||||||
|
public Parser(List<Token> 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<ITemplateNode> ParseNodeList(params TokenType[] terminators)
|
||||||
|
{
|
||||||
|
var nodes = new List<ITemplateNode>();
|
||||||
|
|
||||||
|
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<ITemplateNode>();
|
||||||
|
|
||||||
|
// 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<ConditionBranch>();
|
||||||
|
List<ITemplateNode>? 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<ColumnNode>();
|
||||||
|
|
||||||
|
// 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<ITemplateNode>();
|
||||||
|
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<ITemplateNode>();
|
||||||
|
|
||||||
|
// 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<ITemplateNode>();
|
||||||
|
|
||||||
|
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<ParseError> Errors)
|
||||||
|
{
|
||||||
|
public bool HasErrors => Errors.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ParseError(string Message, SourcePosition Position);
|
||||||
44
Inspectron.Epson.Templates/Language/Token.cs
Normal file
44
Inspectron.Epson.Templates/Language/Token.cs
Normal file
@@ -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));
|
||||||
|
}
|
||||||
91
Inspectron.Epson.Templates/Storage/FileTemplateStorage.cs
Normal file
91
Inspectron.Epson.Templates/Storage/FileTemplateStorage.cs
Normal file
@@ -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<string, string> _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<string> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Inspectron.Epson.Templates/Storage/ITemplateStorage.cs
Normal file
9
Inspectron.Epson.Templates/Storage/ITemplateStorage.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Inspectron.Epson.Templates.Storage;
|
||||||
|
|
||||||
|
public interface ITemplateStorage
|
||||||
|
{
|
||||||
|
string? Load(string templatePath);
|
||||||
|
bool Exists(string templatePath);
|
||||||
|
void Reload();
|
||||||
|
IEnumerable<string> ListTemplates();
|
||||||
|
}
|
||||||
115
Inspectron.Epson.Templates/TemplateEngine.cs
Normal file
115
Inspectron.Epson.Templates/TemplateEngine.cs
Normal file
@@ -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<PrintCommand> 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<PrintCommand> 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<PrintCommand> 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<PrintCommand> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
Inspectron.Epson.Templates/TemplateReceiptConverter.cs
Normal file
23
Inspectron.Epson.Templates/TemplateReceiptConverter.cs
Normal file
@@ -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<PrintCommand> Convert(string jsonContent)
|
||||||
|
{
|
||||||
|
return _engine.Render(_receiptType, _profile.Id, jsonContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Inspectron.Epson.Templates/Templates/assignments.json
Normal file
25
Inspectron.Epson.Templates/Templates/assignments.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
59
Inspectron.Epson.Templates/Templates/bar-default.template
Normal file
59
Inspectron.Epson.Templates/Templates/bar-default.template
Normal file
@@ -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
|
||||||
9
Inspectron.Epson.Templates/Templates/fallback.template
Normal file
9
Inspectron.Epson.Templates/Templates/fallback.template
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#center# Receipt #
|
||||||
|
---
|
||||||
|
@if Title
|
||||||
|
{Title}
|
||||||
|
@end
|
||||||
|
@if TransactionDateTime
|
||||||
|
{TransactionDateTime:dd-MMM-yy HH:mm}
|
||||||
|
@end
|
||||||
|
---
|
||||||
@@ -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
|
||||||
57
Inspectron.Epson.Templates/Templates/kitchen-u220.template
Normal file
57
Inspectron.Epson.Templates/Templates/kitchen-u220.template
Normal file
@@ -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
|
||||||
28
Inspectron.Epson.Templates/Validation/TemplateError.cs
Normal file
28
Inspectron.Epson.Templates/Validation/TemplateError.cs
Normal file
@@ -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}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace Inspectron.Epson.Templates.Validation;
|
||||||
|
|
||||||
|
public record TemplateValidationResult(
|
||||||
|
List<TemplateError> Errors,
|
||||||
|
List<TemplateWarning> Warnings)
|
||||||
|
{
|
||||||
|
public bool IsValid => Errors.Count == 0;
|
||||||
|
public bool HasWarnings => Warnings.Count > 0;
|
||||||
|
|
||||||
|
public static TemplateValidationResult Valid() => new(new List<TemplateError>(), new List<TemplateWarning>());
|
||||||
|
|
||||||
|
public static TemplateValidationResult WithErrors(params TemplateError[] errors) =>
|
||||||
|
new(errors.ToList(), new List<TemplateWarning>());
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
if (IsValid && !HasWarnings)
|
||||||
|
{
|
||||||
|
return "Template is valid.";
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = new List<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
228
Inspectron.Epson.Templates/Validation/TemplateValidator.cs
Normal file
228
Inspectron.Epson.Templates/Validation/TemplateValidator.cs
Normal file
@@ -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<TemplateError> _errors = new();
|
||||||
|
private readonly List<TemplateWarning> _warnings = new();
|
||||||
|
private readonly HashSet<string> _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<string> styles, SourcePosition position)
|
||||||
|
{
|
||||||
|
var validStyles = new HashSet<string>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
Inspectron.Epson.Templates/Validation/TemplateWarning.cs
Normal file
22
Inspectron.Epson.Templates/Validation/TemplateWarning.cs
Normal file
@@ -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}";
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Folder Name="/Utils/">
|
<Folder Name="/Utils/">
|
||||||
<Project Path="Discovery/Discovery.csproj" Id="240f5caa-0e72-474c-9759-d91f34294d34" />
|
<Project Path="EpsonTemplatesTest/EpsonTemplatesTest.csproj" Id="c191524c-43fe-4384-a4c8-78c041afad10" />
|
||||||
<Project Path="EpsonTest/EpsonTest.csproj" />
|
<Project Path="EpsonTest/EpsonTest.csproj" />
|
||||||
<Project Path="Parser/Parser.csproj" Id="2b56f4fa-1253-4206-9cc7-140cbe95432e" />
|
|
||||||
<Project Path="TestClient/TestClient.csproj" />
|
|
||||||
<Project Path="TriggerPrintReceipt/TriggerPrintReceipt.csproj" Id="0afc3093-4b6e-4456-889f-fd0860a566ed" />
|
|
||||||
</Folder>
|
</Folder>
|
||||||
<Project Path="ConfigurationPannel/ConfigurationPannel.csproj" Id="ef38809d-3184-4ff4-9bd5-e2268628fa7a" />
|
<Folder Name="/Tests/">
|
||||||
|
<Project Path="Inspectron.Epson.Templates.Tests/Inspectron.Epson.Templates.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
<Project Path="EpsonPrintService/EpsonPrintService.csproj" />
|
<Project Path="EpsonPrintService/EpsonPrintService.csproj" />
|
||||||
<Project Path="Inspectron.Epson/Inspectron.Epson.csproj" />
|
<Project Path="Inspectron.Epson/Inspectron.Epson.csproj" />
|
||||||
|
<Project Path="Inspectron.Epson.Templates/Inspectron.Epson.Templates.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -486,8 +486,8 @@ public class HtmlPrinter : IEpsonPrinter
|
|||||||
var (baseWidth, baseHeight) = FontSizes[_currentFont];
|
var (baseWidth, baseHeight) = FontSizes[_currentFont];
|
||||||
|
|
||||||
// Apply multipliers
|
// Apply multipliers
|
||||||
int effectiveWidthMultiplier = _fontWidthMultiplier;
|
float effectiveWidthMultiplier = _fontWidthMultiplier;
|
||||||
int effectiveHeightMultiplier = _fontHeightMultiplier;
|
float effectiveHeightMultiplier = _fontHeightMultiplier;
|
||||||
|
|
||||||
if (_isQuadrupleMode)
|
if (_isQuadrupleMode)
|
||||||
{
|
{
|
||||||
@@ -497,21 +497,21 @@ public class HtmlPrinter : IEpsonPrinter
|
|||||||
|
|
||||||
if (_isBiggerFontWidthTM220)
|
if (_isBiggerFontWidthTM220)
|
||||||
{
|
{
|
||||||
effectiveWidthMultiplier *= 2;
|
effectiveWidthMultiplier *= 2f;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_isBiggerFontHeightTM220)
|
if (_isBiggerFontHeightTM220)
|
||||||
{
|
{
|
||||||
effectiveHeightMultiplier *= 2;
|
effectiveHeightMultiplier *= 1.2f;
|
||||||
}
|
}
|
||||||
|
|
||||||
int fontSize = baseHeight * effectiveHeightMultiplier;
|
int fontSize = (int)(baseHeight * effectiveHeightMultiplier);
|
||||||
styles.Add($"font-size: {fontSize}px");
|
styles.Add($"font-size: {fontSize}px");
|
||||||
|
|
||||||
// Letter spacing for width scaling (approximate)
|
// Letter spacing for width scaling (approximate)
|
||||||
if (effectiveWidthMultiplier > 1)
|
if (effectiveWidthMultiplier > 1)
|
||||||
{
|
{
|
||||||
int letterSpacing = (effectiveWidthMultiplier) * baseWidth / 2;
|
int letterSpacing = (int)((effectiveWidthMultiplier) * baseWidth / 2);
|
||||||
styles.Add($"letter-spacing: {letterSpacing}px");
|
styles.Add($"letter-spacing: {letterSpacing}px");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
897
template_syntax.md
Normal file
897
template_syntax.md
Normal file
@@ -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 |
|
||||||
Reference in New Issue
Block a user