diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 88fe641..876b1aa 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,14 @@ "Bash(tree:*)", "Bash(wc:*)", "Bash(dotnet build:*)", - "Bash(dotnet test:*)" + "Bash(dotnet test:*)", + "Bash(dotnet restore:*)", + "Bash(dotnet workload:*)", + "mcp__context7__resolve-library-id", + "WebFetch(domain:raw.githubusercontent.com)", + "WebSearch", + "WebFetch(domain:github.com)", + "Bash(curl:*)" ] } } diff --git a/EpsonTest.Templates/Templates/FinalReceipt.json b/EpsonTest.Templates/Templates/FinalReceipt.json new file mode 100644 index 0000000..5f82650 --- /dev/null +++ b/EpsonTest.Templates/Templates/FinalReceipt.json @@ -0,0 +1,166 @@ +{ + "CompanyName": "Klingler Gastro AG", + "Address1": "M\u00FCnzplatz 3", + "Address2": "CH-8001 Z\u00FCrich", + "Phone": "043 321 22 22", + "ReceiptNumber": null, + "DateTime": "2025-12-16T14:58:00", + "Guests": 2, + "Items": [ + { + "Quantity": 1, + "Description": "San Pellegrino 50cl", + "UnitPrice": 6.50, + "TotalPrice": 6.50, + "TaxCategory": "A", + "SubItems": null, + "PriceDisplay": "6.50 6.50 A" + }, + { + "Quantity": 1, + "Description": "Panna 50cl", + "UnitPrice": 6.50, + "TotalPrice": 6.50, + "TaxCategory": "A", + "SubItems": null, + "PriceDisplay": "6.50 6.50 A" + }, + { + "Quantity": 1, + "Description": "An item with really long description that should wrap", + "UnitPrice": 5.50, + "TotalPrice": 5.50, + "TaxCategory": "A", + "SubItems": null, + "PriceDisplay": "5.50 5.50 A" + }, + { + "Quantity": 2, + "Description": "Business Lunch Menu Seco", + "UnitPrice": 44.00, + "TotalPrice": 88.00, + "TaxCategory": "A", + "SubItems": [ + "Tomato Soup", + "Grilled Salmon", + "Tiramisu" + ], + "PriceDisplay": "44.00 88.00 A" + }, + { + "Quantity": 2, + "Description": "Brunello di Montalcino 1", + "UnitPrice": 16.00, + "TotalPrice": 32.00, + "TaxCategory": "A", + "SubItems": null, + "PriceDisplay": "16.00 32.00 A" + }, + { + "Quantity": 2, + "Description": "Espresso", + "UnitPrice": 5.50, + "TotalPrice": 11.00, + "TaxCategory": "A", + "SubItems": null, + "PriceDisplay": "5.50 11.00 A" + }, + { + "Quantity": 1, + "Description": "Tip", + "UnitPrice": 10.50, + "TotalPrice": 10.50, + "TaxCategory": "B", + "SubItems": null, + "PriceDisplay": "10.50 10.50 B" + } + ], + "Total": 160.00, + "Currency": "CHF", + "TotalInAlternateCurrency": 172.80, + "AlternateCurrency": "EUR", + "SplitPayments": [ + { + "PaymentMethod": "Cash", + "Amount": 50.00, + "Currency": "CHF" + }, + { + "PaymentMethod": "Card", + "Amount": 110.00, + "Currency": "CHF" + } + ], + "PaymentMethod": "MASTER", + "PaymentAmount": 160.00, + "TaxBreakdown": [ + { + "Category": "A", + "Rate": 8.1, + "Gross": 149.50, + "Net": 138.30, + "TaxAmount": 11.20, + "Currency": "CHF" + }, + { + "Category": "B", + "Rate": 0, + "Gross": 10.50, + "Net": 10.50, + "TaxAmount": 0.00, + "Currency": "CHF" + } + ], + "IsDebtor": true, + "WaiterName": "Yves", + "Terminal": "Hauptkasse ZH", + "TableNumber": "12", + "VatNumber": "CHE-449.635.880 MWST", + "ThankYouMessage": "Das Team bedankt sich herzlich f\u00FCr Ihren", + "GoodbyeMessageLine1": "Besuch.", + "GoodbyeMessageLine2": "Auf Wiedersehen.", + "TerminalReceipts": [ + { + "ReceiptType": "*** Kundenbeleg ***", + "BookingType": "Buchung", + "PaymentSystem": "TWINT", + "TransactionNumber": "XXXXXXXXXXXXXXX1494", + "TransactionDateTime": "2025-11-11T12:34:49", + "TerminalId": "31108834", + "AID": "A0000015749E", + "TransactionSeqCount": "6127", + "TransactionRefNo": "99036644599", + "AuthCode": "0d3396", + "AcquirerId": "2", + "EftAmount": 67.00, + "TipAmount": 6.70, + "TotalEftAmount": 73.70, + "Currency": "CHF" + }, + { + "ReceiptType": "*** Kundenbeleg ***", + "BookingType": "Buchung", + "PaymentSystem": "MASTER", + "TransactionNumber": "XXXXXXXXXXXXXXX2587", + "TransactionDateTime": "2025-11-11T12:36:12", + "TerminalId": "31108834", + "AID": "A0000000041010", + "TransactionSeqCount": "6128", + "TransactionRefNo": "99036644600", + "AuthCode": "1a4b2c", + "AcquirerId": "1", + "EftAmount": 93.00, + "TipAmount": 0.00, + "TotalEftAmount": 93.00, + "Currency": "CHF" + } + ], + "DiscountInfo": { + "Description": "Fine Dine", + "Amount": -50.00, + "Currency": "CHF" + }, + "ReceiptLabel": "", + "HasSplitPayments": true, + "HasTaxableCategories": true +} diff --git a/Inspectron.Epson.slnx b/Inspectron.Epson.slnx index d44e2eb..6cb719b 100644 --- a/Inspectron.Epson.slnx +++ b/Inspectron.Epson.slnx @@ -1,10 +1,11 @@ - + + - - + + diff --git a/Inspectron.Epson/HtmlPrintBuilder.cs b/Inspectron.Epson/HtmlPrintBuilder.cs new file mode 100644 index 0000000..55bffb4 --- /dev/null +++ b/Inspectron.Epson/HtmlPrintBuilder.cs @@ -0,0 +1,652 @@ +using System.Text; +using Microsoft.Extensions.Logging; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; + +namespace Inspectron.Epson; + +/// +/// HTML-based printer implementation that builds HTML in memory, +/// simulating Epson thermal printer output for testing and preview purposes. +/// No file I/O is performed; call to retrieve the result. +/// +public class HtmlPrintBuilder : IEpsonPrinter +{ + private readonly ILogger? _logger; + private readonly byte _id; + private readonly int _paperWidth; + + private bool _isConnected; + private readonly StringBuilder _content = new(); + + // Current styling state + private EpsonCommands.PrinterFont _currentFont = EpsonCommands.PrinterFont.A; + private int _fontWidthMultiplier = 1; + private int _fontHeightMultiplier = 1; + private bool _isEmphasized; + private bool _isRedColor; + private bool _isQuadrupleMode; + private bool _isBiggerFontWidthTM220; + private bool _isBiggerFontHeightTM220; + private int _lineSpacing = 14; + private int _absolutePosition; + + // Loaded image buffer + private Image? _loadedImage; + private int _loadedImageMaxWidth; + + private static readonly Dictionary FontSizes = new() + { + { EpsonCommands.PrinterFont.A, (6, 12) }, + { EpsonCommands.PrinterFont.B, (5, 9) }, + { EpsonCommands.PrinterFont.C, (5, 12) }, + { EpsonCommands.PrinterFont.D, (5, 12) }, + { EpsonCommands.PrinterFont.E, (6, 12) } + }; + + /// + public bool IsConnected => _isConnected; + + /// + /// Initialize a new instance of HtmlPrintBuilder + /// + /// Paper width in pixels (default 384 for 80mm thermal printer) + /// Optional logger for diagnostic output + /// Printer ID byte + public HtmlPrintBuilder(int paperWidth = 384, ILogger? logger = null, byte id = 0x01) + { + _paperWidth = paperWidth; + _logger = logger; + _id = id; + } + + /// + /// Returns the generated HTML string. + /// + public string GetHtml() + { + return GenerateHtml(); + } + + /// + public Task ConnectAsync(string ip, int port = 9100, int timeoutSeconds = 10) + { + _logger?.LogInformation("HtmlPrintBuilder: Connecting (paper width: {Width}px)", _paperWidth); + + _content.Clear(); + ResetStylingState(); + _isConnected = true; + + _logger?.LogInformation("HtmlPrintBuilder: Connected successfully"); + return Task.CompletedTask; + } + + /// + public void Disconnect() + { + _logger?.LogDebug("HtmlPrintBuilder: Disconnecting"); + + _loadedImage?.Dispose(); + _loadedImage = null; + _isConnected = false; + } + + /// + public Task GetPrinterStatusAsync() + { + _logger?.LogDebug("HtmlPrintBuilder: Returning mock printer status (always ready)"); + return Task.FromResult(new PrinterStatus + { + RawByte = 0x12, + DrawerOpen = false, + IsOnline = true, + IsCoverClosed = true, + PaperFeedButtonPressed = false + }); + } + + /// + public Task GetOfflineStatusAsync() + { + _logger?.LogDebug("HtmlPrintBuilder: Returning mock offline status (no issues)"); + return Task.FromResult(new OfflineStatus + { + RawByte = 0x12, + CoverOpen = false, + PaperFeedButton = false, + PaperEnd = false, + ErrorOccurred = false + }); + } + + /// + public Task GetErrorStatusAsync() + { + _logger?.LogDebug("HtmlPrintBuilder: Returning mock error status (no errors)"); + return Task.FromResult(new ErrorStatus + { + RawByte = 0x12, + RecoverableError = false, + AutoCutterError = false, + UnrecoverableError = false, + AutoRecoveryError = false + }); + } + + /// + public Task GetPaperSensorStatusAsync() + { + _logger?.LogDebug("HtmlPrintBuilder: Returning mock paper sensor status (paper present)"); + return Task.FromResult(new PaperSensorStatus + { + RawByte = 0x12, + PaperNearEnd = false, + PaperPresent = true + }); + } + + /// + public Task GetTM220StatusAsync() + { + _logger?.LogDebug("HtmlPrintBuilder: Returning mock TM220 status"); + return Task.FromResult((byte)0x12); + } + + /// + public Task GetPrinterIdAsync() + { + return Task.FromResult(_id); + } + + /// + public Task GetOverallStatusAsync() + { + _logger?.LogInformation("HtmlPrintBuilder: Returning mock overall status (ready)"); + return Task.FromResult(new OverallStatus + { + PrinterModel = "HtmlPrintBuilder", + PrinterStatus = new PrinterStatus { RawByte = 0x12, IsOnline = true, IsCoverClosed = true }, + OfflineStatus = new OfflineStatus { RawByte = 0x12 }, + ErrorStatus = new ErrorStatus { RawByte = 0x12 }, + PaperStatus = new PaperSensorStatus { RawByte = 0x12, PaperPresent = true }, + StatusText = "READY - HTML Print Builder", + StatusIcon = "🟢", + IsReady = true, + Recommendations = new List() + }); + } + + /// + public Task InitAsync() + { + _logger?.LogInformation("HtmlPrintBuilder: Initializing (resetting styling state)"); + ResetStylingState(); + return Task.CompletedTask; + } + + /// + public Task SetQuadrupleMode(bool enable) + { + _logger?.LogInformation("HtmlPrintBuilder: {Action} quadruple mode", enable ? "Enabling" : "Disabling"); + _isQuadrupleMode = enable; + return Task.CompletedTask; + } + + /// + public Task SetRedColor(bool enable) + { + _logger?.LogInformation("HtmlPrintBuilder: {Action} red color mode", enable ? "Enabling" : "Disabling"); + _isRedColor = enable; + return Task.CompletedTask; + } + + /// + public Task SetEmphasized(bool enable) + { + _logger?.LogInformation("HtmlPrintBuilder: {Action} emphasized mode", enable ? "Enabling" : "Disabling"); + _isEmphasized = enable; + return Task.CompletedTask; + } + + /// + public Task SelectFont(EpsonCommands.PrinterFont font) + { + _logger?.LogInformation("HtmlPrintBuilder: Selecting font {Font}", font); + _currentFont = font; + return Task.CompletedTask; + } + + /// + public Task SetBiggerFontTM220(bool biggerWidth, bool biggerHeight = false, bool secondaryFont = true) + { + _logger?.LogInformation("HtmlPrintBuilder: Setting bigger font mode: Width={BiggerWidth}, Height={BiggerHeight}, SecondaryFont={SecondaryFont}", + biggerWidth, biggerHeight, secondaryFont); + _isBiggerFontWidthTM220 = biggerWidth; + _isBiggerFontHeightTM220 = biggerHeight; + return Task.CompletedTask; + } + + /// + public Task PrintTextAsync(string text) + { + EnsureConnected(); + _logger?.LogInformation("HtmlPrintBuilder: Printing text ({Length} characters)", text.Length); + + var style = BuildCurrentStyle(); + var escapedText = EscapeHtml(text); + + escapedText = escapedText.TrimEnd('\n').Replace("\n", "
"); + + var positionStyle = _absolutePosition > 0 ? $"margin-left: {_absolutePosition}px;" : ""; + + _content.AppendLine($"{escapedText}"); + + _absolutePosition = 0; + + return Task.CompletedTask; + } + + /// + public Task SetFontSizeAsync(int width, int height) + { + if (width < 1 || width > 8 || height < 1 || height > 8) + throw new ArgumentException("Size must be between 1 and 8"); + + _logger?.LogInformation("HtmlPrintBuilder: Setting font size to {Width}x{Height}", width, height); + _fontWidthMultiplier = width; + _fontHeightMultiplier = height; + return Task.CompletedTask; + } + + /// + public async Task PrintTextAndCutAsync(string text, int feedLines = 5) + { + _logger?.LogInformation("HtmlPrintBuilder: Printing text with cut ({Length} characters, {FeedLines} feed lines)", + text.Length, feedLines); + + await InitAsync(); + await PrintTextAsync(text); + await PrintTextAsync("\n"); + await FeedLinesAsync(feedLines); + await CutAsync(); + } + + /// + public Task PrintBuffer() + { + _logger?.LogDebug("HtmlPrintBuilder: PrintBuffer (no-op)"); + return Task.CompletedTask; + } + + /// + public Task FeedLinesAsync(int lines) + { + EnsureConnected(); + _logger?.LogInformation("HtmlPrintBuilder: Feeding {Lines} lines", lines); + + for (int i = 0; i < lines; i++) + { + _content.AppendLine($"
"); + } + + return Task.CompletedTask; + } + + /// + public Task CutAsync(bool fullCut = true) + { + EnsureConnected(); + _logger?.LogInformation("HtmlPrintBuilder: Cutting paper"); + _content.AppendLine("
✂️
"); + return Task.CompletedTask; + } + + /// + public async Task LoadImageAsync(string imagePath, int maxWidth = 384) + { + _logger?.LogInformation("HtmlPrintBuilder: Loading image from file: {Path}", imagePath); + + if (!File.Exists(imagePath)) + { + throw new FileNotFoundException($"Image file not found: {imagePath}", imagePath); + } + + _loadedImage?.Dispose(); + _loadedImage = await Image.LoadAsync(imagePath); + _loadedImageMaxWidth = maxWidth; + } + + /// + public async Task LoadImageAsync(Stream imageStream, int maxWidth = 384) + { + _logger?.LogInformation("HtmlPrintBuilder: Loading image from stream"); + + _loadedImage?.Dispose(); + _loadedImage = await Image.LoadAsync(imageStream); + _loadedImageMaxWidth = maxWidth; + } + + /// + public Task LoadImageAsync(Image image, int maxWidth = 384) + { + _logger?.LogInformation("HtmlPrintBuilder: Loading image from ImageSharp object"); + + _loadedImage?.Dispose(); + _loadedImage = image.Clone(); + _loadedImageMaxWidth = maxWidth; + return Task.CompletedTask; + } + + /// + public async Task PrintLoadedImage() + { + EnsureConnected(); + + if (_loadedImage == null) + { + _logger?.LogWarning("HtmlPrintBuilder: No image loaded to print"); + return; + } + + _logger?.LogInformation("HtmlPrintBuilder: Printing loaded image"); + await RenderImageToHtml(_loadedImage, _loadedImageMaxWidth); + + _loadedImage.Dispose(); + _loadedImage = null; + } + + /// + public async Task PrintImageBitModeAsync(string imagePath, EpsonCommands.BitImageMode mode = EpsonCommands.BitImageMode.SingleDensity8Dot, int maxWidth = 10) + { + _logger?.LogInformation("HtmlPrintBuilder: Printing image (bit mode) from file: {Path}", imagePath); + + if (!File.Exists(imagePath)) + { + throw new FileNotFoundException($"Image file not found: {imagePath}", imagePath); + } + + using var image = await Image.LoadAsync(imagePath); + await PrintImageBitModeAsync(image, mode, maxWidth); + } + + /// + public async Task PrintImageBitModeAsync(Stream imageStream, EpsonCommands.BitImageMode mode = EpsonCommands.BitImageMode.DoubleDensity24Dot, int maxWidth = 384) + { + _logger?.LogInformation("HtmlPrintBuilder: Printing image (bit mode) from stream"); + + using var image = await Image.LoadAsync(imageStream); + await PrintImageBitModeAsync(image, mode, maxWidth); + } + + /// + public Task PrintImageBitModeAsync(Image image, EpsonCommands.BitImageMode mode, int maxWidth) + { + EnsureConnected(); + _logger?.LogInformation("HtmlPrintBuilder: Printing image (bit mode) {Width}x{Height}", image.Width, image.Height); + + return RenderImageToHtml(image, maxWidth); + } + + /// + public Task SendRawCommandAsync(byte[] command) + { + _logger?.LogDebug("HtmlPrintBuilder: SendRawCommandAsync ignored ({Length} bytes)", command.Length); + return Task.CompletedTask; + } + + /// + public Task SetAbsolutePrintPosition(int x) + { + if (x < 0 || x > 65535) + throw new ArgumentOutOfRangeException(nameof(x), "Position must be between 0 and 65535"); + + _logger?.LogInformation("HtmlPrintBuilder: Setting absolute print position to {X}", x); + _absolutePosition = x; + return Task.CompletedTask; + } + + /// + public Task SetDefaultLineSpacing() + { + _logger?.LogInformation("HtmlPrintBuilder: Setting default line spacing"); + _lineSpacing = 14; + return Task.CompletedTask; + } + + /// + public Task SetCustomLineSpacing(int spacing = 24) + { + if (spacing < 0 || spacing > 255) + throw new ArgumentOutOfRangeException(nameof(spacing), "Spacing must be between 0 and 255"); + + _logger?.LogInformation("HtmlPrintBuilder: Setting custom line spacing to {Spacing}", spacing); + _lineSpacing = (int)(spacing * 0.6); + return Task.CompletedTask; + } + + /// + public ValueTask DisposeAsync() + { + Disconnect(); + return ValueTask.CompletedTask; + } + + #region Private Methods + + private void ResetStylingState() + { + _currentFont = EpsonCommands.PrinterFont.A; + _fontWidthMultiplier = 1; + _fontHeightMultiplier = 1; + _isEmphasized = false; + _isRedColor = false; + _isQuadrupleMode = false; + _isBiggerFontWidthTM220 = false; + _isBiggerFontHeightTM220 = false; + _lineSpacing = 14; + _absolutePosition = 0; + } + + private string BuildCurrentStyle() + { + var styles = new List(); + + var (baseWidth, baseHeight) = FontSizes[_currentFont]; + + float effectiveWidthMultiplier = _fontWidthMultiplier; + float effectiveHeightMultiplier = _fontHeightMultiplier; + + if (_isQuadrupleMode) + { + effectiveWidthMultiplier *= 2; + effectiveHeightMultiplier *= 2; + } + + if (_isBiggerFontWidthTM220) + { + effectiveWidthMultiplier *= 2f; + } + + if (_isBiggerFontHeightTM220) + { + effectiveHeightMultiplier *= 1.2f; + } + + int fontSize = (int)(baseHeight * effectiveHeightMultiplier); + styles.Add($"font-size: {fontSize}px"); + + if (effectiveWidthMultiplier > 1) + { + int letterSpacing = (int)((effectiveWidthMultiplier) * baseWidth / 2); + styles.Add($"letter-spacing: {letterSpacing}px"); + } + + if (_isEmphasized) + { + styles.Add("font-weight: bold"); + } + + if (_isRedColor) + { + styles.Add("color: #cc0000"); + } + + styles.Add($"line-height: {_lineSpacing}px"); + + return string.Join("; ", styles) + ";"; + } + + private async Task RenderImageToHtml(Image sourceImage, int maxWidth) + { + using var processedImage = sourceImage.Clone(); + + if (processedImage.Width > maxWidth) + { + EpsonImageConverter.ResizeImage(processedImage, maxWidth); + } + + using var grayscaleImage = EpsonImageConverter.ConvertToGrayscale(processedImage); + + EpsonImageConverter.ApplyFloydSteinbergDithering(grayscaleImage); + + using var ditheredImage = new Image(grayscaleImage.Width, grayscaleImage.Height); + grayscaleImage.ProcessPixelRows(ditheredImage, (sourceAccessor, targetAccessor) => + { + for (int y = 0; y < sourceAccessor.Height; y++) + { + var sourceRow = sourceAccessor.GetRowSpan(y); + var targetRow = targetAccessor.GetRowSpan(y); + + for (int x = 0; x < sourceAccessor.Width; x++) + { + byte value = sourceRow[x].PackedValue; + targetRow[x] = new Rgba32(value, value, value, 255); + } + } + }); + + // Embed image as base64 data URI instead of saving to file + using var ms = new MemoryStream(); + await ditheredImage.SaveAsync(ms, new PngEncoder()); + var base64 = Convert.ToBase64String(ms.ToArray()); + var dataUri = $"data:image/png;base64,{base64}"; + + var positionStyle = _absolutePosition > 0 ? $"margin-left: {_absolutePosition}px;" : ""; + _content.AppendLine($"
\"Printed
"); + + _absolutePosition = 0; + + _logger?.LogInformation("HtmlPrintBuilder: Image embedded as base64 ({Bytes} bytes)", ms.Length); + } + + private string GenerateHtml() + { + return $@" + + + + + Epson Printer Output + + + +
+{_content} +
+ +"; + } + + private static string EscapeHtml(string text) + { + return text + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """) + .Replace("'", "'"); + } + + private void EnsureConnected() + { + if (!_isConnected) + { + throw new EpsonConnectionException("Not connected. Call ConnectAsync first."); + } + } + + #endregion +} diff --git a/Inspectron.Epson/PrintServer/PrintServices/IPrinter.cs b/Inspectron.Epson/PrintServer/PrintServices/IPrinter.cs index 296e07a..e86da58 100644 --- a/Inspectron.Epson/PrintServer/PrintServices/IPrinter.cs +++ b/Inspectron.Epson/PrintServer/PrintServices/IPrinter.cs @@ -6,7 +6,6 @@ public interface IPrinter { public Task InitAsync(); public Task PrintImageAsync(string path); - public Task SetFontSizeAsync(int fontSize); public Task PrintAsync(List commands); public Task Cut(); } \ No newline at end of file diff --git a/Inspectron.PrintServer.TemplateEditor/App.xaml b/Inspectron.PrintServer.TemplateEditor/App.xaml new file mode 100644 index 0000000..57fd44d --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/App.xaml @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/Inspectron.PrintServer.TemplateEditor/App.xaml.cs b/Inspectron.PrintServer.TemplateEditor/App.xaml.cs new file mode 100644 index 0000000..2d62d24 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/App.xaml.cs @@ -0,0 +1,15 @@ +namespace Inspectron.PrintServer.TemplateEditor +{ + public partial class App : Application + { + public App() + { + InitializeComponent(); + } + + protected override Window CreateWindow(IActivationState? activationState) + { + return new Window(new MainPage()) { Title = "Inspectron.PrintServer.TemplateEditor" }; + } + } +} diff --git a/Inspectron.PrintServer.TemplateEditor/Components/Layout/MainLayout.razor b/Inspectron.PrintServer.TemplateEditor/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..e1a9a75 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/Components/Layout/MainLayout.razor @@ -0,0 +1,3 @@ +@inherits LayoutComponentBase + +@Body diff --git a/Inspectron.PrintServer.TemplateEditor/Components/Layout/MainLayout.razor.css b/Inspectron.PrintServer.TemplateEditor/Components/Layout/MainLayout.razor.css new file mode 100644 index 0000000..e69de29 diff --git a/Inspectron.PrintServer.TemplateEditor/Components/Pages/Home.razor b/Inspectron.PrintServer.TemplateEditor/Components/Pages/Home.razor new file mode 100644 index 0000000..baf2fe4 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/Components/Pages/Home.razor @@ -0,0 +1,113 @@ +@page "/" +@using BlazorMonaco +@using BlazorMonaco.Editor +@using BlazorMonaco.Languages +@using Inspectron.PrintServer.TemplateEditor.Services +@inject IJSRuntime JSRuntime + +
+
+ + + +
+ + @if (!string.IsNullOrEmpty(_loadedJsonFile)) + { + @_loadedJsonFile + } +
+ +
+
+
Editor
+ +
+ +
+
Preview
+
+
+
+
+
+ +@code { + private StandaloneCodeEditor _editor = null!; + private string _editorContent = ""; + private string? _loadedJsonFile; + private readonly TemplateCompletionProvider _completionProvider = new(); + + private StandaloneEditorConstructionOptions EditorConstructionOptions(StandaloneCodeEditor editor) + { + return new StandaloneEditorConstructionOptions + { + AutomaticLayout = true, + Language = "receipt-xml", + Theme = "receipt-dark" + }; + } + + private async Task OnEditorInitAsync() + { + // Register custom language and theme + await JSRuntime.InvokeVoidAsync("receiptLanguage.register"); + await JSRuntime.InvokeVoidAsync("receiptLanguage.defineTheme"); + + // Apply the theme now that it's defined + await BlazorMonaco.Editor.Global.SetTheme(JSRuntime, "receipt-dark"); + + // Wire up content accessor for the completion provider + _completionProvider.SetEditorContentAccessor(() => _editorContent); + + // Register completion provider + await BlazorMonaco.Languages.Global.RegisterCompletionItemProvider( + JSRuntime, + "receipt-xml", + _completionProvider.ProvideCompletionItems, + _completionProvider.ResolveCompletionItem); + } + + private async Task OnContentChanged(ModelContentChangedEvent e) + { + _editorContent = await _editor.GetValue(); + } + + private async Task LoadJsonAsync() + { + try + { + var result = await FilePicker.Default.PickAsync(new PickOptions + { + PickerTitle = "Select a JSON data file", + FileTypes = new FilePickerFileType(new Dictionary> + { + { DevicePlatform.WinUI, new[] { ".json" } }, + { DevicePlatform.macOS, new[] { "json" } }, + { DevicePlatform.iOS, new[] { "public.json" } }, + { DevicePlatform.Android, new[] { "application/json" } }, + }) + }); + + if (result is null) + return; + + using var stream = await result.OpenReadAsync(); + using var reader = new StreamReader(stream); + var json = await reader.ReadToEndAsync(); + + var paths = JsonPathExtractor.Extract(json); + _completionProvider.SetJsonPaths(paths); + _loadedJsonFile = result.FileName; + + StateHasChanged(); + } + catch (Exception) + { + // User cancelled or file read failed + } + } +} diff --git a/Inspectron.PrintServer.TemplateEditor/Components/Pages/NotFound.razor b/Inspectron.PrintServer.TemplateEditor/Components/Pages/NotFound.razor new file mode 100644 index 0000000..091b6b8 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/Components/Pages/NotFound.razor @@ -0,0 +1,5 @@ +@page "/not-found" +@layout MainLayout + +

Not Found

+

Sorry, the content you are looking for does not exist.

diff --git a/Inspectron.PrintServer.TemplateEditor/Components/Routes.razor b/Inspectron.PrintServer.TemplateEditor/Components/Routes.razor new file mode 100644 index 0000000..9059591 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/Inspectron.PrintServer.TemplateEditor/Components/_Imports.razor b/Inspectron.PrintServer.TemplateEditor/Components/_Imports.razor new file mode 100644 index 0000000..b6090a5 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/Components/_Imports.razor @@ -0,0 +1,10 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Inspectron.PrintServer.TemplateEditor +@using Inspectron.PrintServer.TemplateEditor.Components +@using Inspectron.PrintServer.TemplateEditor.Components.Layout diff --git a/Inspectron.PrintServer.TemplateEditor/Inspectron.PrintServer.TemplateEditor.csproj b/Inspectron.PrintServer.TemplateEditor/Inspectron.PrintServer.TemplateEditor.csproj new file mode 100644 index 0000000..56a8610 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/Inspectron.PrintServer.TemplateEditor.csproj @@ -0,0 +1,63 @@ + + + + $(TargetFrameworks);net10.0-windows10.0.19041.0 + + + + + Exe + Inspectron.PrintServer.TemplateEditor + true + true + enable + false + enable + + + Inspectron.PrintServer.TemplateEditor + + + com.companyname.inspectron.printserver.templateeditor + + + 1.0 + 1 + + + None + + 10.0.17763.0 + 10.0.17763.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Inspectron.PrintServer.TemplateEditor/MainPage.xaml b/Inspectron.PrintServer.TemplateEditor/MainPage.xaml new file mode 100644 index 0000000..db10a6d --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/MainPage.xaml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/Inspectron.PrintServer.TemplateEditor/MainPage.xaml.cs b/Inspectron.PrintServer.TemplateEditor/MainPage.xaml.cs new file mode 100644 index 0000000..56279c6 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/MainPage.xaml.cs @@ -0,0 +1,10 @@ +namespace Inspectron.PrintServer.TemplateEditor +{ + public partial class MainPage : ContentPage + { + public MainPage() + { + InitializeComponent(); + } + } +} diff --git a/Inspectron.PrintServer.TemplateEditor/MauiProgram.cs b/Inspectron.PrintServer.TemplateEditor/MauiProgram.cs new file mode 100644 index 0000000..37eb180 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/MauiProgram.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Logging; + +namespace Inspectron.PrintServer.TemplateEditor +{ + public static class MauiProgram + { + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder + .UseMauiApp() + .ConfigureFonts(fonts => + { + fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); + }); + + builder.Services.AddMauiBlazorWebView(); + +#if DEBUG + builder.Services.AddBlazorWebViewDeveloperTools(); + builder.Logging.AddDebug(); +#endif + + return builder.Build(); + } + } +} diff --git a/Inspectron.PrintServer.TemplateEditor/Platforms/Windows/App.xaml b/Inspectron.PrintServer.TemplateEditor/Platforms/Windows/App.xaml new file mode 100644 index 0000000..377d498 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/Platforms/Windows/App.xaml @@ -0,0 +1,8 @@ + + + diff --git a/Inspectron.PrintServer.TemplateEditor/Platforms/Windows/App.xaml.cs b/Inspectron.PrintServer.TemplateEditor/Platforms/Windows/App.xaml.cs new file mode 100644 index 0000000..c09da3f --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/Platforms/Windows/App.xaml.cs @@ -0,0 +1,25 @@ +using Microsoft.UI.Xaml; + +// To learn more about WinUI, the WinUI project structure, +// and more about our project templates, see: http://aka.ms/winui-project-info. + +namespace Inspectron.PrintServer.TemplateEditor.WinUI +{ + /// + /// Provides application-specific behavior to supplement the default Application class. + /// + public partial class App : MauiWinUIApplication + { + /// + /// Initializes the singleton application object. This is the first line of authored code + /// executed, and as such is the logical equivalent of main() or WinMain(). + /// + public App() + { + this.InitializeComponent(); + } + + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); + } + +} diff --git a/Inspectron.PrintServer.TemplateEditor/Platforms/Windows/Package.appxmanifest b/Inspectron.PrintServer.TemplateEditor/Platforms/Windows/Package.appxmanifest new file mode 100644 index 0000000..44f254a --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/Platforms/Windows/Package.appxmanifest @@ -0,0 +1,46 @@ + + + + + + + + + $placeholder$ + User Name + $placeholder$.png + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Inspectron.PrintServer.TemplateEditor/Platforms/Windows/app.manifest b/Inspectron.PrintServer.TemplateEditor/Platforms/Windows/app.manifest new file mode 100644 index 0000000..39cb4f4 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/Platforms/Windows/app.manifest @@ -0,0 +1,17 @@ + + + + + + + + true/PM + PerMonitorV2, PerMonitor + + true + + + diff --git a/Inspectron.PrintServer.TemplateEditor/Properties/launchSettings.json b/Inspectron.PrintServer.TemplateEditor/Properties/launchSettings.json new file mode 100644 index 0000000..4f85793 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Windows Machine": { + "commandName": "Project", + "nativeDebugging": false + } + } +} \ No newline at end of file diff --git a/Inspectron.PrintServer.TemplateEditor/Resources/AppIcon/appicon.svg b/Inspectron.PrintServer.TemplateEditor/Resources/AppIcon/appicon.svg new file mode 100644 index 0000000..9d63b65 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/Resources/AppIcon/appicon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Inspectron.PrintServer.TemplateEditor/Resources/AppIcon/appiconfg.svg b/Inspectron.PrintServer.TemplateEditor/Resources/AppIcon/appiconfg.svg new file mode 100644 index 0000000..21dfb25 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/Resources/AppIcon/appiconfg.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/Inspectron.PrintServer.TemplateEditor/Resources/Fonts/OpenSans-Regular.ttf b/Inspectron.PrintServer.TemplateEditor/Resources/Fonts/OpenSans-Regular.ttf new file mode 100644 index 0000000..fe25ce0 Binary files /dev/null and b/Inspectron.PrintServer.TemplateEditor/Resources/Fonts/OpenSans-Regular.ttf differ diff --git a/Inspectron.PrintServer.TemplateEditor/Resources/Images/dotnet_bot.svg b/Inspectron.PrintServer.TemplateEditor/Resources/Images/dotnet_bot.svg new file mode 100644 index 0000000..abfaff2 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/Resources/Images/dotnet_bot.svg @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Inspectron.PrintServer.TemplateEditor/Resources/Raw/AboutAssets.txt b/Inspectron.PrintServer.TemplateEditor/Resources/Raw/AboutAssets.txt new file mode 100644 index 0000000..6de1c15 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/Resources/Raw/AboutAssets.txt @@ -0,0 +1,15 @@ +Any raw assets you want to be deployed with your application can be placed in +this directory (and child directories). Deployment of the asset to your application +is automatically handled by the following `MauiAsset` Build Action within your `.csproj`. + + + +These files will be deployed with your package and will be accessible using Essentials: + + async Task LoadMauiAsset() + { + using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt"); + using var reader = new StreamReader(stream); + + var contents = reader.ReadToEnd(); + } diff --git a/Inspectron.PrintServer.TemplateEditor/Resources/Splash/splash.svg b/Inspectron.PrintServer.TemplateEditor/Resources/Splash/splash.svg new file mode 100644 index 0000000..21dfb25 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/Resources/Splash/splash.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/Inspectron.PrintServer.TemplateEditor/Services/JsonPathExtractor.cs b/Inspectron.PrintServer.TemplateEditor/Services/JsonPathExtractor.cs new file mode 100644 index 0000000..54f7cfc --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/Services/JsonPathExtractor.cs @@ -0,0 +1,94 @@ +using System.Text.Json; + +namespace Inspectron.PrintServer.TemplateEditor.Services; + +public record JsonPropertyInfo(string Path, string Type, bool IsArray); + +public static class JsonPathExtractor +{ + public static List Extract(string json) + { + var results = new List(); + + try + { + using var doc = JsonDocument.Parse(json); + WalkElement(doc.RootElement, "", results); + } + catch (JsonException) + { + // Invalid JSON - return empty list + } + + return results; + } + + private static void WalkElement(JsonElement element, string prefix, List results) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var prop in element.EnumerateObject()) + { + var path = string.IsNullOrEmpty(prefix) ? prop.Name : $"{prefix}.{prop.Name}"; + + var type = GetTypeName(prop.Value.ValueKind); + var isArray = prop.Value.ValueKind == JsonValueKind.Array; + + results.Add(new JsonPropertyInfo(path, type, isArray)); + + if (prop.Value.ValueKind == JsonValueKind.Object) + { + WalkElement(prop.Value, path, results); + } + else if (prop.Value.ValueKind == JsonValueKind.Array) + { + WalkArrayElement(prop.Value, path, results); + } + } + break; + } + } + + private static void WalkArrayElement(JsonElement array, string arrayPath, List results) + { + // Inspect first element to discover item property paths + foreach (var item in array.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.Object) + { + // Use "[item]" prefix to indicate these are array item properties + foreach (var prop in item.EnumerateObject()) + { + var itemPath = $"{arrayPath}[].{prop.Name}"; + var type = GetTypeName(prop.Value.ValueKind); + var isArray = prop.Value.ValueKind == JsonValueKind.Array; + + results.Add(new JsonPropertyInfo(itemPath, type, isArray)); + + if (prop.Value.ValueKind == JsonValueKind.Object) + { + WalkElement(prop.Value, itemPath, results); + } + else if (prop.Value.ValueKind == JsonValueKind.Array) + { + WalkArrayElement(prop.Value, itemPath, results); + } + } + } + + break; // Only inspect the first element + } + } + + private static string GetTypeName(JsonValueKind kind) => kind switch + { + JsonValueKind.String => "string", + JsonValueKind.Number => "number", + JsonValueKind.True or JsonValueKind.False => "boolean", + JsonValueKind.Array => "array", + JsonValueKind.Object => "object", + JsonValueKind.Null => "null", + _ => "unknown" + }; +} diff --git a/Inspectron.PrintServer.TemplateEditor/Services/TemplateCompletionProvider.cs b/Inspectron.PrintServer.TemplateEditor/Services/TemplateCompletionProvider.cs new file mode 100644 index 0000000..6660b7a --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/Services/TemplateCompletionProvider.cs @@ -0,0 +1,512 @@ +using System.Text.Json; +using BlazorMonaco; +using BlazorMonaco.Languages; + +namespace Inspectron.PrintServer.TemplateEditor.Services; + +public class TemplateCompletionProvider +{ + private List _jsonPaths = []; + + public void SetJsonPaths(List paths) => _jsonPaths = paths; + + public Task ProvideCompletionItems(string modelUri, Position position, CompletionContext context) + { + var items = new List(); + var editorContent = _editorContentAccessor?.Invoke(); + + if (string.IsNullOrEmpty(editorContent)) + return Task.FromResult(new CompletionList { Suggestions = items }); + + var lines = editorContent.Split('\n'); + if (position.LineNumber < 1 || position.LineNumber > lines.Length) + return Task.FromResult(new CompletionList { Suggestions = items }); + + var line = lines[position.LineNumber - 1]; + var col = Math.Min(position.Column - 1, line.Length); + var textBefore = line[..col]; + + var completionContext = DetectContext(textBefore, lines, position.LineNumber); + + switch (completionContext.Kind) + { + case ContextKind.ElementName: + items = GetElementCompletions(completionContext.Prefix); + break; + case ContextKind.AttributeName: + items = GetAttributeCompletions(completionContext.ElementName, completionContext.Prefix); + break; + case ContextKind.AttributeValue: + items = GetAttributeValueCompletions(completionContext.ElementName, completionContext.AttributeName); + break; + case ContextKind.Expression: + items = GetExpressionCompletions(completionContext.Prefix, lines, position.LineNumber); + break; + } + + return Task.FromResult(new CompletionList { Suggestions = items }); + } + + public Task ResolveCompletionItem(CompletionItem item) => Task.FromResult(item); + + private Func? _editorContentAccessor; + + public void SetEditorContentAccessor(Func accessor) => _editorContentAccessor = accessor; + + #region Context Detection + + private enum ContextKind { None, ElementName, AttributeName, AttributeValue, Expression } + + private record CompletionContextInfo(ContextKind Kind, string Prefix = "", string ElementName = "", string AttributeName = ""); + + private static CompletionContextInfo DetectContext(string textBefore, string[] lines, int lineNumber) + { + // Inside {{ }} expression? + var lastOpen = textBefore.LastIndexOf("{{", StringComparison.Ordinal); + var lastClose = textBefore.LastIndexOf("}}", StringComparison.Ordinal); + if (lastOpen >= 0 && lastOpen > lastClose) + { + var exprText = textBefore[(lastOpen + 2)..]; + return new CompletionContextInfo(ContextKind.Expression, exprText); + } + + // Inside attribute value (="...")? + var inAttrValue = IsInsideAttributeValue(textBefore); + if (inAttrValue.Inside) + return new CompletionContextInfo(ContextKind.AttributeValue, + ElementName: inAttrValue.ElementName, AttributeName: inAttrValue.AttributeName); + + // After < for element name? + var lastLt = textBefore.LastIndexOf('<'); + var lastGt = textBefore.LastIndexOf('>'); + if (lastLt >= 0 && lastLt > lastGt) + { + var afterLt = textBefore[(lastLt + 1)..]; + + // Closing tag = 0 ? attrText[(lastSpace + 1)..] : attrText; + // Filter out if we're right after = or inside a value + if (!attrPrefix.Contains('=')) + return new CompletionContextInfo(ContextKind.AttributeName, attrPrefix, elemName); + } + + return new CompletionContextInfo(ContextKind.None); + } + + private static (bool Inside, string ElementName, string AttributeName) IsInsideAttributeValue(string text) + { + // Find the last opening tag context + var lastLt = text.LastIndexOf('<'); + if (lastLt < 0) return (false, "", ""); + + var tagContent = text[(lastLt + 1)..]; + var lastGt = tagContent.LastIndexOf('>'); + if (lastGt >= 0) return (false, "", ""); + + // Extract element name + var spaceIdx = tagContent.IndexOf(' '); + if (spaceIdx < 0) return (false, "", ""); + var elemName = tagContent[..spaceIdx]; + if (elemName.StartsWith("/")) return (false, "", ""); + + // Check if cursor is inside a quoted attribute value + var attrPart = tagContent[spaceIdx..]; + var inDoubleQuote = false; + var inSingleQuote = false; + var lastAttrName = ""; + var i = 0; + + while (i < attrPart.Length) + { + if (attrPart[i] == '"' && !inSingleQuote) + { + inDoubleQuote = !inDoubleQuote; + } + else if (attrPart[i] == '\'' && !inDoubleQuote) + { + inSingleQuote = !inSingleQuote; + } + else if (!inDoubleQuote && !inSingleQuote && char.IsLetter(attrPart[i])) + { + // Capture attribute name + var start = i; + while (i < attrPart.Length && (char.IsLetterOrDigit(attrPart[i]) || attrPart[i] == '-' || attrPart[i] == '_')) + i++; + lastAttrName = attrPart[start..i]; + continue; + } + + i++; + } + + if (inDoubleQuote || inSingleQuote) + return (true, elemName, lastAttrName); + + return (false, "", ""); + } + + #endregion + + #region Element Schema + + private static readonly Dictionary Schema = new(StringComparer.OrdinalIgnoreCase) + { + ["receipt"] = new("Required root element", []), + ["line"] = new("Single line of text", new() + { + ["align"] = new(["left", "center", "right"], "Text alignment"), + ["bold"] = BoolAttr("Bold text"), + ["big"] = BoolAttr("Double-width font"), + ["tall"] = BoolAttr("Double-height font"), + ["red"] = BoolAttr("Red text (supported printers)"), + ["lineSpacing"] = new([], "Line spacing in dots"), + ["wrap"] = BoolAttr("Word-wrap text"), + ["wrapIndent"] = new([], "Indent for wrapped lines"), + }), + ["columns"] = new("Two-column layout", new() + { + ["left"] = new([], "Left column content"), + ["right"] = new([], "Right column content"), + ["bold"] = BoolAttr("Bold text"), + ["big"] = BoolAttr("Double-width font"), + ["tall"] = BoolAttr("Double-height font"), + ["red"] = BoolAttr("Red text"), + ["lineSpacing"] = new([], "Line spacing in dots"), + ["wrap"] = BoolAttr("Wrap left column"), + ["wrapIndent"] = new([], "Indent for wrapped lines"), + }), + ["row"] = new("Multi-column layout with children", new() + { + ["bold"] = BoolAttr("Bold text"), + ["big"] = BoolAttr("Double-width font"), + ["tall"] = BoolAttr("Double-height font"), + ["red"] = BoolAttr("Red text"), + ["lineSpacing"] = new([], "Line spacing in dots"), + }), + ["col"] = new("Column inside or ", new() + { + ["width"] = new([], "Column width (char count or percentage)"), + ["align"] = new(["left", "center", "right"], "Cell alignment"), + ["wrap"] = BoolAttr("Word-wrap cell text"), + }), + ["separator"] = new("Repeated character line", new() + { + ["char"] = new([], "Character to repeat (default: -)"), + }), + ["cut"] = new("Paper cut command", []), + ["feed"] = new("Empty lines", new() + { + ["lines"] = new([], "Number of empty lines"), + }), + ["table"] = new("ASCII box table", new() + { + ["items"] = new([], "JSON array path for data rows"), + ["var"] = new([], "Loop variable name"), + ["headerItems"] = new([], "JSON array path for header rows"), + }), + ["foreach"] = new("Loop over array", new() + { + ["items"] = new([], "Path to JSON array (required)"), + ["var"] = new([], "Loop variable name (required)"), + }), + ["if"] = new("Conditional block", new() + { + ["test"] = new([], "Condition expression (required)"), + }), + ["else"] = new("Else branch (must follow )", []), + }; + + private static AttributeSchema BoolAttr(string desc) => new(["true", "false"], desc); + + private record ElementSchema(string Description, Dictionary Attributes); + private record AttributeSchema(List Values, string Description); + + #endregion + + #region Completion Generators + + private static List GetElementCompletions(string prefix) + { + var items = new List(); + var sortIndex = 0; + + foreach (var (name, schema) in Schema) + { + items.Add(new CompletionItem + { + LabelAsString = name, + Kind = CompletionItemKind.Keyword, + Detail = schema.Description, + InsertText = GetElementSnippet(name, schema), + InsertTextRules = CompletionItemInsertTextRule.InsertAsSnippet, + SortText = sortIndex++.ToString("D3"), + }); + } + + return items; + } + + private static string GetElementSnippet(string name, ElementSchema schema) + { + return name switch + { + "line" => "line${1: align=\"${2:left}\"}>${3:$0}", + "columns" => "columns left=\"${1}\" right=\"${2}\" />", + "row" => "row>\n\t${2}\n\t${4}\n", + "col" => "col${1: align=\"${2:left}\"}>${3:$0}", + "separator" => "separator />", + "cut" => "cut />", + "feed" => "feed${1: lines=\"${2:1}\"} />", + "foreach" => "foreach items=\"${1}\" var=\"${2:item}\">\n\t$0\n", + "if" => "if test=\"${1}\">\n\t$0\n", + "else" => "else>\n\t$0\n", + "table" => "table items=\"${1}\" var=\"${2:item}\">\n\t${5:Header}\n
", + "receipt" => "receipt>\n\t$0\n", + _ => $"{name}>$0" + }; + } + + private static List GetAttributeCompletions(string elementName, string prefix) + { + var items = new List(); + + if (!Schema.TryGetValue(elementName, out var schema)) + return items; + + var sortIndex = 0; + foreach (var (attrName, attrSchema) in schema.Attributes) + { + var insertText = attrSchema.Values.Count > 0 + ? $"{attrName}=\"${{1|{string.Join(",", attrSchema.Values)}|}}\"" + : $"{attrName}=\"$1\""; + + items.Add(new CompletionItem + { + LabelAsString = attrName, + Kind = CompletionItemKind.Property, + Detail = attrSchema.Description, + InsertText = insertText, + InsertTextRules = CompletionItemInsertTextRule.InsertAsSnippet, + SortText = sortIndex++.ToString("D3"), + }); + } + + return items; + } + + private static List GetAttributeValueCompletions(string elementName, string attributeName) + { + var items = new List(); + + if (!Schema.TryGetValue(elementName, out var schema)) + return items; + + if (!schema.Attributes.TryGetValue(attributeName, out var attrSchema)) + return items; + + foreach (var value in attrSchema.Values) + { + items.Add(new CompletionItem + { + LabelAsString = value, + Kind = CompletionItemKind.Value, + InsertText = value, + }); + } + + return items; + } + + private List GetExpressionCompletions(string prefix, string[] lines, int lineNumber) + { + var items = new List(); + var sortIndex = 0; + + // Loop variables + var loopVars = FindLoopVariables(lines, lineNumber); + + // Special loop variables + foreach (var special in new[] { "$index", "$first", "$last" }) + { + if (loopVars.Count > 0) // Only suggest if inside a foreach + { + items.Add(new CompletionItem + { + LabelAsString = special, + Kind = CompletionItemKind.Variable, + Detail = special switch + { + "$index" => "Zero-based loop index", + "$first" => "True on first iteration", + "$last" => "True on last iteration", + _ => "" + }, + InsertText = special, + SortText = sortIndex++.ToString("D3"), + }); + } + } + + // Loop variable property paths from JSON + foreach (var loopVar in loopVars) + { + // Find array in JSON paths and suggest its item properties + var arrayPaths = FindMatchingArrayPaths(loopVar.ItemsPath); + + foreach (var prop in arrayPaths) + { + // prop.Path is like "Items[].Description" - we want "item.Description" + var suffix = prop.Path; + var bracketIdx = suffix.IndexOf("[].", StringComparison.Ordinal); + if (bracketIdx >= 0) + suffix = suffix[(bracketIdx + 3)..]; + else + continue; + + var label = $"{loopVar.VarName}.{suffix}"; + + items.Add(new CompletionItem + { + LabelAsString = label, + Kind = CompletionItemKind.Field, + Detail = $"{prop.Type} (from {loopVar.ItemsPath})", + InsertText = label, + SortText = sortIndex++.ToString("D3"), + }); + } + } + + // Top-level JSON paths + foreach (var prop in _jsonPaths.Where(p => !p.Path.Contains("[]."))) + { + var kind = prop.IsArray ? CompletionItemKind.Enum : CompletionItemKind.Field; + items.Add(new CompletionItem + { + LabelAsString = prop.Path, + Kind = kind, + Detail = prop.Type, + InsertText = prop.Path, + SortText = sortIndex++.ToString("D3"), + }); + } + + // Format specifiers + if (prefix.Contains(':')) + { + items.Clear(); + foreach (var (fmt, desc) in FormatSpecifiers) + { + items.Add(new CompletionItem + { + LabelAsString = fmt, + Kind = CompletionItemKind.Unit, + Detail = desc, + InsertText = fmt, + }); + } + } + + return items; + } + + private static readonly (string Format, string Description)[] FormatSpecifiers = + [ + ("F2", "Fixed-point 2 decimals (9.50)"), + ("F0", "Fixed-point no decimals (10)"), + ("N0", "Number with separators (1,234)"), + ("N2", "Number with separators + decimals (1,234.56)"), + ("dd.MM.yyyy", "Date (15.03.2024)"), + ("HH:mm", "Time (14:30)"), + ("HH:mm:ss", "Time with seconds (14:30:00)"), + ("dd-MMM-yy HH:mm", "Date + time (15-Mar-24 14:30)"), + ("dd.MM.yyyy HH:mm", "Date + time (15.03.2024 14:30)"), + ]; + + #endregion + + #region Loop Variable Detection + + private record LoopVarInfo(string VarName, string ItemsPath); + + private static List FindLoopVariables(string[] lines, int lineNumber) + { + var vars = new List(); + + // Walk backwards from current line to find enclosing foreach elements + var depth = 0; + for (var i = lineNumber - 1; i >= 0; i--) + { + var line = lines[i].Trim(); + + // Count closing foreach tags (increase depth needed) + if (line.Contains("")) + depth++; + + // Check for opening foreach tags + var foreachMatch = System.Text.RegularExpressions.Regex.Match(line, + @" 0) + { + depth--; + continue; + } + + string itemsPath, varName; + if (foreachMatch.Groups.Count >= 3) + { + // Determine which capture is items vs var based on pattern + if (line.IndexOf("items=", StringComparison.Ordinal) < line.IndexOf("var=", StringComparison.Ordinal)) + { + itemsPath = foreachMatch.Groups[1].Value; + varName = foreachMatch.Groups[2].Value; + } + else + { + varName = foreachMatch.Groups[1].Value; + itemsPath = foreachMatch.Groups[2].Value; + } + + vars.Add(new LoopVarInfo(varName, itemsPath)); + } + } + } + + return vars; + } + + private List FindMatchingArrayPaths(string itemsPath) + { + // itemsPath could be "Items" or "gang.Dishes" etc. + // We need to find JSON paths that start with the matching array path + "[]." + return _jsonPaths + .Where(p => p.Path.StartsWith(itemsPath + "[].", StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + #endregion +} diff --git a/Inspectron.PrintServer.TemplateEditor/wwwroot/app.css b/Inspectron.PrintServer.TemplateEditor/wwwroot/app.css new file mode 100644 index 0000000..c0732f8 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/wwwroot/app.css @@ -0,0 +1,180 @@ +h1:focus { + outline: none; +} + +html, body { + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; +} + +.editor-container { + display: flex; + flex-direction: column; + height: 100vh; +} + +.toolbar { + display: flex; + gap: 4px; + padding: 6px 8px; + background: #2d2d2d; + border-bottom: 1px solid #404040; + flex-shrink: 0; +} + +.toolbar-btn { + padding: 4px 14px; + background: #3c3c3c; + color: #cccccc; + border: 1px solid #555; + border-radius: 3px; + cursor: pointer; + font-size: 13px; +} + +.toolbar-btn:hover { + background: #505050; +} + +.toolbar-separator { + width: 1px; + background: #555; + margin: 2px 6px; +} + +.json-indicator { + display: inline-flex; + align-items: center; + padding: 2px 10px; + background: #2b4a3e; + color: #4EC9B0; + border: 1px solid #3a6b57; + border-radius: 3px; + font-size: 12px; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.split-pane { + display: flex; + flex: 1; + min-height: 0; +} + +.pane { + display: flex; + flex-direction: column; + min-width: 0; +} + +.pane-editor { + flex: 1; + border-right: 1px solid #404040; +} + +.pane-preview { + flex: 1; + background: #1e1e1e; +} + +.pane-header { + padding: 4px 10px; + background: #252526; + color: #888; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid #404040; + flex-shrink: 0; +} + +#my-editor-id { + flex: 1; + min-height: 0; +} + +.preview-content { + flex: 1; + padding: 16px; + overflow: auto; + color: #cccccc; +} +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} + +.status-bar-safe-area { + display: none; +} + +@supports (-webkit-touch-callout: none) { + .status-bar-safe-area { + display: flex; + position: sticky; + top: 0; + height: env(safe-area-inset-top); + background-color: #f7f7f7; + width: 100%; + z-index: 1; + } + + .flex-column, .navbar-brand { + padding-left: env(safe-area-inset-left); + } +} diff --git a/Inspectron.PrintServer.TemplateEditor/wwwroot/index.html b/Inspectron.PrintServer.TemplateEditor/wwwroot/index.html new file mode 100644 index 0000000..d96a143 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/wwwroot/index.html @@ -0,0 +1,33 @@ + + + + + + Inspectron.PrintServer.TemplateEditor + + + + + + + + + + + + +
+ +
Loading...
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + + + \ No newline at end of file diff --git a/Inspectron.PrintServer.TemplateEditor/wwwroot/receiptLanguage.js b/Inspectron.PrintServer.TemplateEditor/wwwroot/receiptLanguage.js new file mode 100644 index 0000000..61baf44 --- /dev/null +++ b/Inspectron.PrintServer.TemplateEditor/wwwroot/receiptLanguage.js @@ -0,0 +1,158 @@ +window.receiptLanguage = { + _registered: false, + + register: function () { + if (this._registered) return; + this._registered = true; + + monaco.languages.register({ id: 'receipt-xml' }); + + monaco.languages.setLanguageConfiguration('receipt-xml', { + comments: { + blockComment: [''] + }, + brackets: [ + ['<', '>'], + ['{{', '}}'] + ], + autoClosingPairs: [ + { open: '<', close: '>' }, + { open: '{{', close: '}}' }, + { open: '"', close: '"' }, + { open: "'", close: "'" } + ], + surroundingPairs: [ + { open: '<', close: '>' }, + { open: '"', close: '"' }, + { open: "'", close: "'" } + ] + }); + + var knownElements = [ + 'receipt', 'line', 'columns', 'row', 'col', 'separator', + 'cut', 'feed', 'table', 'foreach', 'if', 'else' + ]; + + monaco.languages.setMonarchTokensProvider('receipt-xml', { + knownElements: knownElements, + + tokenizer: { + root: [ + // Expression open + [/\{\{/, { token: 'delimiter.expression', next: '@expression' }], + + // XML comment + [//, { token: 'comment', next: '@pop' }], + [/./, 'comment'] + ] + } + }); + }, + + defineTheme: function () { + monaco.editor.defineTheme('receipt-dark', { + base: 'vs-dark', + inherit: true, + rules: [ + { token: 'tag.element', foreground: '569CD6', fontStyle: 'bold' }, + { token: 'tag.unknown', foreground: 'F44747' }, + { token: 'attribute.name', foreground: '9CDCFE' }, + { token: 'attribute.value', foreground: 'CE9178' }, + { token: 'attribute.value.delimiter', foreground: 'CE9178' }, + { token: 'delimiter.tag', foreground: '808080' }, + { token: 'delimiter', foreground: '808080' }, + { token: 'delimiter.expression', foreground: 'DCDCAA', fontStyle: 'bold' }, + { token: 'variable.property', foreground: '4EC9B0' }, + { token: 'variable.special', foreground: 'C586C0', fontStyle: 'italic' }, + { token: 'string.format', foreground: 'D7BA7D' }, + { token: 'comment', foreground: '6A9955', fontStyle: 'italic' }, + { token: 'text', foreground: 'D4D4D4' } + ], + colors: {} + }); + }, + + getEditorContent: function (editorId) { + var editor = blazorMonaco.editor.getEditorById(editorId); + if (editor) { + return editor.getValue(); + } + return ''; + } +};