Implement stage 2 layout validation

This commit is contained in:
2026-05-07 14:14:49 +02:00
parent 412afa3ad3
commit ad56962985
13 changed files with 449 additions and 73 deletions
+14 -2
View File
@@ -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.
+45 -7
View File
@@ -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);
}
+3 -15
View File
@@ -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<LayoutElement> 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;
}
+5 -8
View File
@@ -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
}
@@ -10,17 +10,18 @@ public sealed class LayoutElementJsonConverter : JsonConverter<LayoutElement>
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<TextElement>(raw, options) ?? throw new JsonException(),
"line" => JsonSerializer.Deserialize<LineElement>(raw, options) ?? throw new JsonException(),
"rectangle" => JsonSerializer.Deserialize<RectangleElement>(raw, options) ?? throw new JsonException(),
"qr" => JsonSerializer.Deserialize<QrCodeElement>(raw, options) ?? throw new JsonException(),
_ => throw new JsonException($"Unknown layout element type '{typeProperty.GetString()}'.")
"text" => WithType(JsonSerializer.Deserialize<TextElement>(raw, options), type),
"line" => WithType(JsonSerializer.Deserialize<LineElement>(raw, options), type),
"rectangle" => WithType(JsonSerializer.Deserialize<RectangleElement>(raw, options), type),
"qr" => WithType(JsonSerializer.Deserialize<QrCodeElement>(raw, options), type),
_ => new UnknownLayoutElement { Type = type }
};
}
@@ -28,4 +29,15 @@ public sealed class LayoutElementJsonConverter : JsonConverter<LayoutElement>
{
JsonSerializer.Serialize(writer, (object)value, value.GetType(), options);
}
private static T WithType<T>(T? element, string type) where T : LayoutElement
{
if (element is null)
{
throw new JsonException();
}
element.Type = type;
return element;
}
}
@@ -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<LabelLayout>(json, JsonOptions);
}
public static string Serialize(LabelLayout layout)
{
return JsonSerializer.Serialize(layout, JsonOptions);
}
}
+61 -15
View File
@@ -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<string> 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<LabelLayout>(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<LabelLayout>(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);
@@ -0,0 +1,13 @@
namespace LabelPrintAgent.Layout;
public sealed class LayoutValidationResult
{
public List<string> Errors { get; } = [];
public bool IsValid => Errors.Count == 0;
public void AddError(string error)
{
Errors.Add(error);
}
}
@@ -0,0 +1,231 @@
namespace LabelPrintAgent.Layout;
public sealed class LayoutValidator
{
private static readonly HashSet<string> Orientations = new(StringComparer.OrdinalIgnoreCase)
{
"landscape",
"portrait"
};
private static readonly HashSet<string> HorizontalAlignments = new(StringComparer.OrdinalIgnoreCase)
{
"left",
"center",
"right"
};
private static readonly HashSet<string> 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;
}
}
+10 -5
View File
@@ -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;
}
+6 -3
View File
@@ -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;
}
@@ -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; }
}
+9 -6
View File
@@ -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; }