diff --git a/README.md b/README.md index 721fb1c..1b607ed 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Windows-Tray-Anwendung für den späteren Etikettendruck aus JSON-Layouts. ## Etappe 1 -Dieser Stand ist ein lauffähiges Grundgerüst: +Die erste Etappe enthält das lauffähige Grundgerüst: - .NET-9-Windows-Forms-Projekt - Tray-Icon mit Kontextmenü @@ -21,7 +21,19 @@ Dieser Stand ist ein lauffähiges Grundgerüst: - verschlüsselte Passwortspeicherung per Windows DPAPI - Auflistung installierter Windows-Drucker - Beispiel-Layout `dymo_57x32_standard` -- Layout-JSON laden, validieren und speichern +- Layout-JSON laden und speichern + +## Etappe 2 + +Die zweite Etappe ergänzt das Layoutmodell und die JSON-Validierung: + +- typisierte Layoutklassen für Text, Linie, Rechteck und QR-Code +- JSON-Deserialisierung anhand der Element-Eigenschaft `type` +- Validierung des Layoutkopfs und aller Elemente +- Sammlung aller Validierungsfehler statt Abbruch beim ersten Fehler +- Anzeige der Validierungsfehler im Layout-Tab +- Speichern nur bei gültigem Layout +- formatierte Speicherung des Layout-JSON Noch nicht enthalten sind MySQL-Worker, echtes Drucken, QR-Code-Rendering und die vollständige Rendering-Engine. diff --git a/src/LabelPrintAgent/App/SettingsForm.cs b/src/LabelPrintAgent/App/SettingsForm.cs index ae8b323..c70c3e2 100644 --- a/src/LabelPrintAgent/App/SettingsForm.cs +++ b/src/LabelPrintAgent/App/SettingsForm.cs @@ -40,6 +40,14 @@ internal sealed class SettingsForm : Form Width = 900, Height = 460 }; + private readonly TextBox _layoutValidationErrors = new() + { + Multiline = true, + ReadOnly = true, + ScrollBars = ScrollBars.Vertical, + Width = 900, + Height = 72 + }; private readonly DataGridView _errorJobs = new() { @@ -122,9 +130,12 @@ internal sealed class SettingsForm : Form panel.Controls.Add(_layoutJson); _layoutJson.Left = 20; _layoutJson.Top = 60; - panel.Controls.Add(ButtonAt("Validieren", 20, 540, (_, _) => ValidateLayout())); - panel.Controls.Add(ButtonAt("Speichern", 130, 540, (_, _) => SaveLayout())); - panel.Controls.Add(LabelAt("Vorschau und Rendering folgen in Etappe 2.", 250, 546, 360)); + panel.Controls.Add(_layoutValidationErrors); + _layoutValidationErrors.Left = 20; + _layoutValidationErrors.Top = 530; + panel.Controls.Add(ButtonAt("Validieren", 20, 620, (_, _) => ValidateLayout(showSuccessMessage: true))); + panel.Controls.Add(ButtonAt("Speichern", 130, 620, (_, _) => SaveLayout())); + panel.Controls.Add(LabelAt("Vorschau und Rendering folgen in einer späteren Etappe.", 250, 626, 420)); return new TabPage("Layout") { Controls = { panel } }; } @@ -247,24 +258,51 @@ internal sealed class SettingsForm : Form MessageBox.Show("Druckereinstellungen gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); } - private void ValidateLayout() + private bool ValidateLayout(bool showSuccessMessage, out LabelLayout? layout) { + layout = null; try { - _layoutStore.ValidateJson(_layoutJson.Text); - MessageBox.Show("Layout ist gültig.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + var result = _layoutStore.ValidateJson(_layoutJson.Text, out layout); + if (result.IsValid) + { + _layoutValidationErrors.Text = string.Empty; + if (showSuccessMessage) + { + MessageBox.Show("Layout ist gültig.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + } + + return true; + } + + var message = string.Join(Environment.NewLine, result.Errors); + _layoutValidationErrors.Text = message; + MessageBox.Show(message, "Layout ist ungültig", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return false; } catch (Exception ex) { ShowError(ex); + return false; } } + private bool ValidateLayout(bool showSuccessMessage) + { + return ValidateLayout(showSuccessMessage, out _); + } + private void SaveLayout() { try { - _layoutStore.SaveJson(_layoutJson.Text); + if (!ValidateLayout(showSuccessMessage: false, out var layout) || layout is null) + { + return; + } + + _layoutStore.SaveLayout(layout); + _layoutJson.Text = LayoutJsonSerializer.Serialize(layout); LoadLayouts(); MessageBox.Show("Layout gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); } diff --git a/src/LabelPrintAgent/Layout/LabelLayout.cs b/src/LabelPrintAgent/Layout/LabelLayout.cs index 24a9781..7425093 100644 --- a/src/LabelPrintAgent/Layout/LabelLayout.cs +++ b/src/LabelPrintAgent/Layout/LabelLayout.cs @@ -1,25 +1,13 @@ -using System.Text.Json.Serialization; - namespace LabelPrintAgent.Layout; public sealed class LabelLayout { public string Name { get; set; } = string.Empty; public string Key { get; set; } = string.Empty; - public decimal WidthMm { get; set; } = 57; - public decimal HeightMm { get; set; } = 32; + public double WidthMm { get; set; } = 57; + public double HeightMm { get; set; } = 32; public int Dpi { get; set; } = 300; - public decimal MarginMm { get; set; } = 3; + public double MarginMm { get; set; } = 3; public string Orientation { get; set; } = "landscape"; public List Elements { get; set; } = []; } - -[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] -[JsonDerivedType(typeof(TextElement), "text")] -[JsonDerivedType(typeof(LineElement), "line")] -[JsonDerivedType(typeof(RectangleElement), "rectangle")] -[JsonDerivedType(typeof(QrCodeElement), "qr")] -public abstract class LayoutElement -{ - public string Type { get; init; } = string.Empty; -} diff --git a/src/LabelPrintAgent/Layout/LayoutElement.cs b/src/LabelPrintAgent/Layout/LayoutElement.cs index b1b816e..113810e 100644 --- a/src/LabelPrintAgent/Layout/LayoutElement.cs +++ b/src/LabelPrintAgent/Layout/LayoutElement.cs @@ -1,15 +1,12 @@ namespace LabelPrintAgent.Layout; -public enum HorizontalAlign +public abstract class LayoutElement { - Left, - Center, - Right + public string Type { get; set; } = string.Empty; + public double XMm { get; set; } + public double YMm { get; set; } } -public enum VerticalAlign +internal sealed class UnknownLayoutElement : LayoutElement { - Top, - Middle, - Bottom } diff --git a/src/LabelPrintAgent/Layout/LayoutElementJsonConverter.cs b/src/LabelPrintAgent/Layout/LayoutElementJsonConverter.cs index eccaafc..c559cad 100644 --- a/src/LabelPrintAgent/Layout/LayoutElementJsonConverter.cs +++ b/src/LabelPrintAgent/Layout/LayoutElementJsonConverter.cs @@ -10,17 +10,18 @@ public sealed class LayoutElementJsonConverter : JsonConverter using var document = JsonDocument.ParseValue(ref reader); if (!document.RootElement.TryGetProperty("type", out var typeProperty)) { - throw new JsonException("Layout element has no type."); + return new UnknownLayoutElement(); } var raw = document.RootElement.GetRawText(); - return typeProperty.GetString()?.ToLowerInvariant() switch + var type = typeProperty.GetString()?.ToLowerInvariant() ?? string.Empty; + return type switch { - "text" => JsonSerializer.Deserialize(raw, options) ?? throw new JsonException(), - "line" => JsonSerializer.Deserialize(raw, options) ?? throw new JsonException(), - "rectangle" => JsonSerializer.Deserialize(raw, options) ?? throw new JsonException(), - "qr" => JsonSerializer.Deserialize(raw, options) ?? throw new JsonException(), - _ => throw new JsonException($"Unknown layout element type '{typeProperty.GetString()}'.") + "text" => WithType(JsonSerializer.Deserialize(raw, options), type), + "line" => WithType(JsonSerializer.Deserialize(raw, options), type), + "rectangle" => WithType(JsonSerializer.Deserialize(raw, options), type), + "qr" => WithType(JsonSerializer.Deserialize(raw, options), type), + _ => new UnknownLayoutElement { Type = type } }; } @@ -28,4 +29,15 @@ public sealed class LayoutElementJsonConverter : JsonConverter { JsonSerializer.Serialize(writer, (object)value, value.GetType(), options); } + + private static T WithType(T? element, string type) where T : LayoutElement + { + if (element is null) + { + throw new JsonException(); + } + + element.Type = type; + return element; + } } diff --git a/src/LabelPrintAgent/Layout/LayoutJsonSerializer.cs b/src/LabelPrintAgent/Layout/LayoutJsonSerializer.cs new file mode 100644 index 0000000..fdbd863 --- /dev/null +++ b/src/LabelPrintAgent/Layout/LayoutJsonSerializer.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace LabelPrintAgent.Layout; + +public static class LayoutJsonSerializer +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new LayoutElementJsonConverter() } + }; + + public static LabelLayout? Deserialize(string json) + { + return JsonSerializer.Deserialize(json, JsonOptions); + } + + public static string Serialize(LabelLayout layout) + { + return JsonSerializer.Serialize(layout, JsonOptions); + } +} diff --git a/src/LabelPrintAgent/Layout/LayoutStore.cs b/src/LabelPrintAgent/Layout/LayoutStore.cs index b76101e..18baa89 100644 --- a/src/LabelPrintAgent/Layout/LayoutStore.cs +++ b/src/LabelPrintAgent/Layout/LayoutStore.cs @@ -1,17 +1,12 @@ using System.Text.Json; +using Serilog; namespace LabelPrintAgent.Layout; public sealed class LayoutStore : IDisposable { - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Converters = { new LayoutElementJsonConverter() } - }; - private readonly string _layoutFolder; + private readonly LayoutValidator _validator = new(); public LayoutStore(string layoutFolder) { @@ -22,7 +17,9 @@ public sealed class LayoutStore : IDisposable public IReadOnlyList GetLayoutKeys() { return Directory.GetFiles(_layoutFolder, "*.json") - .Select(path => Load(path).Key) + .Select(TryLoad) + .Where(layout => layout is not null) + .Select(layout => layout!.Key) .Where(key => !string.IsNullOrWhiteSpace(key)) .OrderBy(key => key) .ToList(); @@ -56,30 +53,79 @@ public sealed class LayoutStore : IDisposable throw new FileNotFoundException($"Layout '{key}' wurde nicht gefunden."); } - public LabelLayout ValidateJson(string json) + public LabelLayout DeserializeJson(string json) { - return JsonSerializer.Deserialize(json, JsonOptions) + return LayoutJsonSerializer.Deserialize(json) ?? throw new InvalidOperationException("Layout-JSON ist leer."); } + public LayoutValidationResult ValidateJson(string json, out LabelLayout? layout) + { + layout = null; + try + { + layout = DeserializeJson(json); + return _validator.Validate(layout); + } + catch (JsonException ex) + { + Log.Warning(ex, "Invalid layout JSON"); + var result = new LayoutValidationResult(); + result.AddError($"Layout-JSON ist ungültig: {ex.Message}"); + return result; + } + catch (Exception ex) + { + Log.Warning(ex, "Could not parse layout JSON"); + var result = new LayoutValidationResult(); + result.AddError($"Layout-JSON konnte nicht gelesen werden: {ex.Message}"); + return result; + } + } + + public void SaveLayout(LabelLayout layout) + { + var validation = _validator.Validate(layout); + if (!validation.IsValid) + { + throw new InvalidOperationException(string.Join(Environment.NewLine, validation.Errors)); + } + + var fileName = $"{layout.Key}.json"; + File.WriteAllText(Path.Combine(_layoutFolder, fileName), LayoutJsonSerializer.Serialize(layout)); + } + public void SaveJson(string json) { - var layout = ValidateJson(json); - if (string.IsNullOrWhiteSpace(layout.Key)) + var validation = ValidateJson(json, out var layout); + if (!validation.IsValid || layout is null) { - throw new InvalidOperationException("Layout-Key fehlt."); + throw new InvalidOperationException(string.Join(Environment.NewLine, validation.Errors)); } - File.WriteAllText(Path.Combine(_layoutFolder, $"{layout.Key}.json"), json); + SaveLayout(layout); } private static LabelLayout Load(string path) { var json = File.ReadAllText(path); - return JsonSerializer.Deserialize(json, JsonOptions) + return LayoutJsonSerializer.Deserialize(json) ?? throw new InvalidOperationException($"Layout '{path}' ist ungültig."); } + private static LabelLayout? TryLoad(string path) + { + try + { + return Load(path); + } + catch (Exception ex) + { + Log.Warning(ex, "Could not load layout file {LayoutPath}", path); + return null; + } + } + public static void EnsureExampleLayout(string layoutFolder) { Directory.CreateDirectory(layoutFolder); diff --git a/src/LabelPrintAgent/Layout/LayoutValidationResult.cs b/src/LabelPrintAgent/Layout/LayoutValidationResult.cs new file mode 100644 index 0000000..3581ec5 --- /dev/null +++ b/src/LabelPrintAgent/Layout/LayoutValidationResult.cs @@ -0,0 +1,13 @@ +namespace LabelPrintAgent.Layout; + +public sealed class LayoutValidationResult +{ + public List Errors { get; } = []; + + public bool IsValid => Errors.Count == 0; + + public void AddError(string error) + { + Errors.Add(error); + } +} diff --git a/src/LabelPrintAgent/Layout/LayoutValidator.cs b/src/LabelPrintAgent/Layout/LayoutValidator.cs new file mode 100644 index 0000000..497423a --- /dev/null +++ b/src/LabelPrintAgent/Layout/LayoutValidator.cs @@ -0,0 +1,231 @@ +namespace LabelPrintAgent.Layout; + +public sealed class LayoutValidator +{ + private static readonly HashSet Orientations = new(StringComparer.OrdinalIgnoreCase) + { + "landscape", + "portrait" + }; + + private static readonly HashSet HorizontalAlignments = new(StringComparer.OrdinalIgnoreCase) + { + "left", + "center", + "right" + }; + + private static readonly HashSet VerticalAlignments = new(StringComparer.OrdinalIgnoreCase) + { + "top", + "middle", + "bottom" + }; + + public LayoutValidationResult Validate(LabelLayout? layout) + { + var result = new LayoutValidationResult(); + if (layout is null) + { + result.AddError("Layout: JSON enthält kein Layout."); + return result; + } + + ValidateLayout(layout, result); + ValidateElements(layout, result); + return result; + } + + private static void ValidateLayout(LabelLayout layout, LayoutValidationResult result) + { + if (string.IsNullOrWhiteSpace(layout.Name)) + { + result.AddError("Layout: Name darf nicht leer sein."); + } + + if (string.IsNullOrWhiteSpace(layout.Key)) + { + result.AddError("Layout: Key darf nicht leer sein."); + } + + if (layout.WidthMm <= 0) + { + result.AddError("Layout: WidthMm muss größer als 0 sein."); + } + + if (layout.HeightMm <= 0) + { + result.AddError("Layout: HeightMm muss größer als 0 sein."); + } + + if (layout.Dpi <= 0) + { + result.AddError("Layout: Dpi muss größer als 0 sein."); + } + + if (layout.MarginMm < 0) + { + result.AddError("Layout: MarginMm darf nicht negativ sein."); + } + + if (!Orientations.Contains(layout.Orientation)) + { + result.AddError("Layout: Orientation muss 'landscape' oder 'portrait' sein."); + } + + if (layout.Elements is null) + { + result.AddError("Layout: Elements darf nicht null sein."); + } + } + + private static void ValidateElements(LabelLayout layout, LayoutValidationResult result) + { + if (layout.Elements is null) + { + return; + } + + for (var i = 0; i < layout.Elements.Count; i++) + { + var index = i + 1; + var element = layout.Elements[i]; + switch (element) + { + case null: + result.AddError($"Element {index}: Element darf nicht null sein."); + break; + case TextElement text: + ValidateTextElement(layout, text, index, result); + break; + case LineElement line: + ValidateLineElement(layout, line, index, result); + break; + case RectangleElement rectangle: + ValidateRectangleElement(layout, rectangle, index, result); + break; + case QrCodeElement qrCode: + ValidateQrCodeElement(layout, qrCode, index, result); + break; + default: + result.AddError($"Element {index}: Unbekannter Elementtyp '{element.Type}'."); + break; + } + } + } + + private static void ValidateTextElement(LabelLayout layout, TextElement element, int index, LayoutValidationResult result) + { + if (!IsBoxInside(layout, element.XMm, element.YMm, element.WidthMm, element.HeightMm)) + { + result.AddError($"Element {index}: Textfeld liegt außerhalb des Etiketts."); + } + + if (element.WidthMm <= 0) + { + result.AddError($"Element {index}: Textfeld hat ungültige Breite."); + } + + if (element.HeightMm <= 0) + { + result.AddError($"Element {index}: Textfeld hat ungültige Höhe."); + } + + if (element.FontSizePt <= 0) + { + result.AddError($"Element {index}: Textfeld hat ungültige Schriftgröße."); + } + + if (element.MinFontSizePt <= 0) + { + result.AddError($"Element {index}: Textfeld hat ungültige Mindestschriftgröße."); + } + + if (element.MinFontSizePt > element.FontSizePt) + { + result.AddError($"Element {index}: MinFontSizePt darf nicht größer als FontSizePt sein."); + } + + if (!HorizontalAlignments.Contains(element.HorizontalAlign)) + { + result.AddError($"Element {index}: HorizontalAlign muss left, center oder right sein."); + } + + if (!VerticalAlignments.Contains(element.VerticalAlign)) + { + result.AddError($"Element {index}: VerticalAlign muss top, middle oder bottom sein."); + } + + if (string.IsNullOrWhiteSpace(element.Value)) + { + result.AddError($"Element {index}: Textfeld-Value darf nicht leer sein."); + } + } + + private static void ValidateLineElement(LabelLayout layout, LineElement element, int index, LayoutValidationResult result) + { + if (!IsPointInside(layout, element.X1Mm, element.Y1Mm) || !IsPointInside(layout, element.X2Mm, element.Y2Mm)) + { + result.AddError($"Element {index}: Linie liegt außerhalb des Etiketts."); + } + + if (element.StrokeWidthMm <= 0) + { + result.AddError($"Element {index}: Linie hat ungültige Strichstärke."); + } + } + + private static void ValidateRectangleElement(LabelLayout layout, RectangleElement element, int index, LayoutValidationResult result) + { + if (!IsBoxInside(layout, element.XMm, element.YMm, element.WidthMm, element.HeightMm)) + { + result.AddError($"Element {index}: Rechteck liegt außerhalb des Etiketts."); + } + + if (element.WidthMm <= 0) + { + result.AddError($"Element {index}: Rechteck hat ungültige Breite."); + } + + if (element.HeightMm <= 0) + { + result.AddError($"Element {index}: Rechteck hat ungültige Höhe."); + } + + if (element.StrokeWidthMm <= 0) + { + result.AddError($"Element {index}: Rechteck hat ungültige Strichstärke."); + } + } + + private static void ValidateQrCodeElement(LabelLayout layout, QrCodeElement element, int index, LayoutValidationResult result) + { + if (!IsBoxInside(layout, element.XMm, element.YMm, element.SizeMm, element.SizeMm)) + { + result.AddError($"Element {index}: QR-Code liegt außerhalb des Etiketts."); + } + + if (element.SizeMm <= 0) + { + result.AddError($"Element {index}: QR-Code hat ungültige Größe."); + } + + if (string.IsNullOrWhiteSpace(element.Value)) + { + result.AddError($"Element {index}: QR-Code-Value darf nicht leer sein."); + } + } + + private static bool IsPointInside(LabelLayout layout, double xMm, double yMm) + { + return xMm >= 0 && yMm >= 0 && xMm <= layout.WidthMm && yMm <= layout.HeightMm; + } + + private static bool IsBoxInside(LabelLayout layout, double xMm, double yMm, double widthMm, double heightMm) + { + return xMm >= 0 + && yMm >= 0 + && xMm + Math.Max(0, widthMm) <= layout.WidthMm + && yMm + Math.Max(0, heightMm) <= layout.HeightMm; + } +} diff --git a/src/LabelPrintAgent/Layout/LineElement.cs b/src/LabelPrintAgent/Layout/LineElement.cs index 8e52513..4f6a063 100644 --- a/src/LabelPrintAgent/Layout/LineElement.cs +++ b/src/LabelPrintAgent/Layout/LineElement.cs @@ -2,9 +2,14 @@ namespace LabelPrintAgent.Layout; public sealed class LineElement : LayoutElement { - public decimal X1Mm { get; set; } - public decimal Y1Mm { get; set; } - public decimal X2Mm { get; set; } - public decimal Y2Mm { get; set; } - public decimal StrokeWidthMm { get; set; } = 0.3m; + public LineElement() + { + Type = "line"; + } + + public double X1Mm { get; set; } + public double Y1Mm { get; set; } + public double X2Mm { get; set; } + public double Y2Mm { get; set; } + public double StrokeWidthMm { get; set; } = 0.3; } diff --git a/src/LabelPrintAgent/Layout/QrCodeElement.cs b/src/LabelPrintAgent/Layout/QrCodeElement.cs index 0287896..8b59c48 100644 --- a/src/LabelPrintAgent/Layout/QrCodeElement.cs +++ b/src/LabelPrintAgent/Layout/QrCodeElement.cs @@ -2,8 +2,11 @@ namespace LabelPrintAgent.Layout; public sealed class QrCodeElement : LayoutElement { - public decimal XMm { get; set; } - public decimal YMm { get; set; } - public decimal SizeMm { get; set; } + public QrCodeElement() + { + Type = "qr"; + } + + public double SizeMm { get; set; } public string Value { get; set; } = string.Empty; } diff --git a/src/LabelPrintAgent/Layout/RectangleElement.cs b/src/LabelPrintAgent/Layout/RectangleElement.cs index 0a6ee63..b8f3274 100644 --- a/src/LabelPrintAgent/Layout/RectangleElement.cs +++ b/src/LabelPrintAgent/Layout/RectangleElement.cs @@ -2,10 +2,13 @@ namespace LabelPrintAgent.Layout; public sealed class RectangleElement : LayoutElement { - public decimal XMm { get; set; } - public decimal YMm { get; set; } - public decimal WidthMm { get; set; } - public decimal HeightMm { get; set; } - public decimal StrokeWidthMm { get; set; } = 0.3m; + public RectangleElement() + { + Type = "rectangle"; + } + + public double WidthMm { get; set; } + public double HeightMm { get; set; } + public double StrokeWidthMm { get; set; } = 0.3; public bool Filled { get; set; } } diff --git a/src/LabelPrintAgent/Layout/TextElement.cs b/src/LabelPrintAgent/Layout/TextElement.cs index 511e2ca..da01353 100644 --- a/src/LabelPrintAgent/Layout/TextElement.cs +++ b/src/LabelPrintAgent/Layout/TextElement.cs @@ -2,14 +2,17 @@ namespace LabelPrintAgent.Layout; public sealed class TextElement : LayoutElement { - public decimal XMm { get; set; } - public decimal YMm { get; set; } - public decimal WidthMm { get; set; } - public decimal HeightMm { get; set; } + public TextElement() + { + Type = "text"; + } + + public double WidthMm { get; set; } + public double HeightMm { get; set; } public string Value { get; set; } = string.Empty; public string FontFamily { get; set; } = "Arial"; - public float FontSizePt { get; set; } = 9; - public float MinFontSizePt { get; set; } = 6; + public double FontSizePt { get; set; } = 9; + public double MinFontSizePt { get; set; } = 6; public bool AutoShrink { get; set; } = true; public bool Bold { get; set; } public bool Italic { get; set; }