From 812f13ebeefabbefebfe97cc1a559aacfe0b46f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Thu, 7 May 2026 14:32:10 +0200 Subject: [PATCH] Implement stage 3 label preview rendering --- README.md | 26 ++- src/LabelPrintAgent/App/SettingsForm.cs | 57 +++++- src/LabelPrintAgent/LabelPrintAgent.csproj | 3 + src/LabelPrintAgent/Layout/LayoutStore.cs | 9 + .../Rendering/LabelRenderer.cs | 189 ++++++++++++++++++ .../Rendering/MmToPixelConverter.cs | 14 ++ .../Rendering/PreviewDataProvider.cs | 17 ++ src/LabelPrintAgent/Rendering/RenderResult.cs | 24 +++ .../Rendering/TemplateFormatter.cs | 113 +++++++++++ 9 files changed, 442 insertions(+), 10 deletions(-) create mode 100644 src/LabelPrintAgent/Rendering/LabelRenderer.cs create mode 100644 src/LabelPrintAgent/Rendering/MmToPixelConverter.cs create mode 100644 src/LabelPrintAgent/Rendering/PreviewDataProvider.cs create mode 100644 src/LabelPrintAgent/Rendering/RenderResult.cs create mode 100644 src/LabelPrintAgent/Rendering/TemplateFormatter.cs diff --git a/README.md b/README.md index 1b607ed..8666745 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,31 @@ Die zweite Etappe ergänzt das Layoutmodell und die JSON-Validierung: - 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. +## Etappe 3 + +Die dritte Etappe ergänzt die Rendering-Engine und die Vorschau im Layout-Tab: + +- Bitmap-Rendering mit 300 dpi +- Text, Linien, Rechtecke und QR-Codes +- Platzhalterersetzung wie `{titel}`, `{datum:dd.MM.yyyy}` und `{menge:0.00}` +- automatische Beispiel-Daten für Vorschauen +- AutoShrink für Textfelder +- Warnungen, wenn Platzhalter fehlen oder Text nicht vollständig passt + +Eine Vorschau erzeugst du im Tab `Layout` mit dem Button `Vorschau`. Zuerst wird das JSON validiert, dann wird das Etikett mit folgenden Beispiel-Daten gerendert: + +```json +{ + "titel": "Beleg privat", + "beschreibung": "Dokument 2026-000123", + "nummer": "2026-000123", + "datum": "2026-05-07", + "menge": 42.5, + "qr": "bjoernprivat 0000123" +} +``` + +Noch nicht enthalten sind MySQL-Worker und echtes Drucken. ## Startanleitung diff --git a/src/LabelPrintAgent/App/SettingsForm.cs b/src/LabelPrintAgent/App/SettingsForm.cs index c70c3e2..b9a190a 100644 --- a/src/LabelPrintAgent/App/SettingsForm.cs +++ b/src/LabelPrintAgent/App/SettingsForm.cs @@ -2,6 +2,7 @@ using System.Drawing; using LabelPrintAgent.Configuration; using LabelPrintAgent.Layout; using LabelPrintAgent.Printing; +using LabelPrintAgent.Rendering; using Microsoft.Win32; using Serilog; @@ -15,6 +16,7 @@ internal sealed class SettingsForm : Form private readonly SettingsStore _settingsStore; private readonly LayoutStore _layoutStore; private readonly PrinterService _printerService; + private readonly LabelRenderer _labelRenderer = new(); private readonly CheckBox _autoStartCheckBox = new() { Text = "Autostart aktivieren", AutoSize = true }; private readonly NumericUpDown _pollInterval = new() { Minimum = 1, Maximum = 3600, Value = 5, Width = 100 }; @@ -37,17 +39,25 @@ internal sealed class SettingsForm : Form ScrollBars = ScrollBars.Both, WordWrap = false, Font = new Font(FontFamily.GenericMonospace, 9), - Width = 900, - Height = 460 + Width = 650, + Height = 420 }; private readonly TextBox _layoutValidationErrors = new() { Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Vertical, - Width = 900, + Width = 1050, Height = 72 }; + private readonly PictureBox _layoutPreview = new() + { + BorderStyle = BorderStyle.FixedSingle, + SizeMode = PictureBoxSizeMode.Zoom, + BackColor = Color.White, + Width = 360, + Height = 220 + }; private readonly DataGridView _errorJobs = new() { @@ -66,8 +76,8 @@ internal sealed class SettingsForm : Form _printerService = printerService; Text = "LabelPrintAgent Einstellungen"; - Width = 1040; - Height = 720; + Width = 1160; + Height = 760; StartPosition = FormStartPosition.CenterScreen; var tabs = new TabControl { Dock = DockStyle.Fill }; @@ -130,12 +140,15 @@ internal sealed class SettingsForm : Form panel.Controls.Add(_layoutJson); _layoutJson.Left = 20; _layoutJson.Top = 60; + panel.Controls.Add(_layoutPreview); + _layoutPreview.Left = 700; + _layoutPreview.Top = 60; 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)); + _layoutValidationErrors.Top = 500; + panel.Controls.Add(ButtonAt("Validieren", 20, 595, (_, _) => ValidateLayout(showSuccessMessage: true))); + panel.Controls.Add(ButtonAt("Speichern", 130, 595, (_, _) => SaveLayout())); + panel.Controls.Add(ButtonAt("Vorschau", 240, 595, (_, _) => RenderLayoutPreview())); return new TabPage("Layout") { Controls = { panel } }; } @@ -312,6 +325,32 @@ internal sealed class SettingsForm : Form } } + private void RenderLayoutPreview() + { + try + { + if (!ValidateLayout(showSuccessMessage: false, out var layout) || layout is null) + { + return; + } + + using var result = _labelRenderer.Render(layout, PreviewDataProvider.CreatePayload()); + var oldImage = _layoutPreview.Image; + _layoutPreview.Image = (Bitmap)result.Image.Clone(); + oldImage?.Dispose(); + + _layoutValidationErrors.Text = result.Warnings.Count == 0 + ? $"Vorschau erzeugt: {result.WidthPx} x {result.HeightPx} px" + : $"Vorschau erzeugt: {result.WidthPx} x {result.HeightPx} px{Environment.NewLine}" + + string.Join(Environment.NewLine, result.Warnings); + } + catch (Exception ex) + { + Log.Error(ex, "Could not render layout preview"); + MessageBox.Show($"Vorschau konnte nicht erzeugt werden: {ex.Message}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + private bool IsAutostartEnabled() { using var key = Registry.CurrentUser.OpenSubKey(AutoStartRunKey, writable: false); diff --git a/src/LabelPrintAgent/LabelPrintAgent.csproj b/src/LabelPrintAgent/LabelPrintAgent.csproj index 73cf90e..e765ac4 100644 --- a/src/LabelPrintAgent/LabelPrintAgent.csproj +++ b/src/LabelPrintAgent/LabelPrintAgent.csproj @@ -7,10 +7,13 @@ true true app.manifest + false + + diff --git a/src/LabelPrintAgent/Layout/LayoutStore.cs b/src/LabelPrintAgent/Layout/LayoutStore.cs index 18baa89..2b61c3f 100644 --- a/src/LabelPrintAgent/Layout/LayoutStore.cs +++ b/src/LabelPrintAgent/Layout/LayoutStore.cs @@ -162,6 +162,15 @@ public sealed class LayoutStore : IDisposable "marginMm": 3, "orientation": "landscape", "elements": [ + { + "type": "rectangle", + "xMm": 1.5, + "yMm": 1.5, + "widthMm": 54, + "heightMm": 29, + "strokeWidthMm": 0.25, + "filled": false + }, { "type": "text", "xMm": 3, diff --git a/src/LabelPrintAgent/Rendering/LabelRenderer.cs b/src/LabelPrintAgent/Rendering/LabelRenderer.cs new file mode 100644 index 0000000..c572037 --- /dev/null +++ b/src/LabelPrintAgent/Rendering/LabelRenderer.cs @@ -0,0 +1,189 @@ +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.Drawing.Text; +using LabelPrintAgent.Layout; +using QRCoder; + +namespace LabelPrintAgent.Rendering; + +public sealed class LabelRenderer +{ + public RenderResult Render(LabelLayout layout, Dictionary payload) + { + var warnings = new List(); + var widthPx = MmToPixelConverter.MmToPx(layout.WidthMm, layout.Dpi); + var heightPx = MmToPixelConverter.MmToPx(layout.HeightMm, layout.Dpi); + var bitmap = new Bitmap(widthPx, heightPx, PixelFormat.Format32bppArgb); + bitmap.SetResolution(layout.Dpi, layout.Dpi); + + using var graphics = Graphics.FromImage(bitmap); + graphics.Clear(Color.White); + graphics.SmoothingMode = SmoothingMode.AntiAlias; + graphics.TextRenderingHint = TextRenderingHint.ClearTypeGridFit; + graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; + graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; + + foreach (var element in layout.Elements) + { + switch (element) + { + case TextElement text: + DrawText(graphics, text, payload, layout.Dpi, warnings); + break; + case LineElement line: + DrawLine(graphics, line, layout.Dpi); + break; + case RectangleElement rectangle: + DrawRectangle(graphics, rectangle, layout.Dpi); + break; + case QrCodeElement qrCode: + DrawQrCode(graphics, qrCode, payload, layout.Dpi, warnings); + break; + } + } + + return new RenderResult(bitmap, warnings); + } + + private static void DrawText(Graphics graphics, TextElement element, Dictionary payload, int dpi, List warnings) + { + var text = TemplateFormatter.Format(element.Value, payload, warnings).Replace("\\n", Environment.NewLine); + var bounds = new RectangleF( + MmToPixelConverter.MmToPxFloat(element.XMm, dpi), + MmToPixelConverter.MmToPxFloat(element.YMm, dpi), + MmToPixelConverter.MmToPxFloat(element.WidthMm, dpi), + MmToPixelConverter.MmToPxFloat(element.HeightMm, dpi)); + + using var format = CreateStringFormat(element); + var style = CreateFontStyle(element); + using var clipRegion = graphics.Clip.Clone(); + graphics.SetClip(bounds); + + Font? font = null; + try + { + font = CreateFittingFont(graphics, element, text, bounds, format, style, warnings); + using var brush = new SolidBrush(Color.Black); + graphics.DrawString(text, font, brush, bounds, format); + } + finally + { + graphics.Clip = clipRegion; + font?.Dispose(); + } + } + + private static Font CreateFittingFont(Graphics graphics, TextElement element, string text, RectangleF bounds, StringFormat format, FontStyle style, List warnings) + { + var fontSize = element.FontSizePt; + Font? currentFont = null; + + while (true) + { + currentFont?.Dispose(); + currentFont = new Font(element.FontFamily, (float)fontSize, style, GraphicsUnit.Point); + var measured = graphics.MeasureString(text, currentFont, bounds.Size, format); + var fits = measured.Width <= bounds.Width + 1 && measured.Height <= bounds.Height + 1; + if (fits) + { + return currentFont; + } + + if (!element.AutoShrink || fontSize <= element.MinFontSizePt) + { + warnings.Add($"Text '{Shorten(text)}' passt nicht vollständig in den Elementbereich."); + return currentFont; + } + + fontSize = Math.Max(element.MinFontSizePt, fontSize - 0.5d); + } + } + + private static StringFormat CreateStringFormat(TextElement element) + { + return new StringFormat + { + Alignment = element.HorizontalAlign.ToLowerInvariant() switch + { + "center" => StringAlignment.Center, + "right" => StringAlignment.Far, + _ => StringAlignment.Near + }, + LineAlignment = element.VerticalAlign.ToLowerInvariant() switch + { + "middle" => StringAlignment.Center, + "bottom" => StringAlignment.Far, + _ => StringAlignment.Near + }, + FormatFlags = element.Multiline ? 0 : StringFormatFlags.NoWrap, + Trimming = StringTrimming.EllipsisCharacter + }; + } + + private static FontStyle CreateFontStyle(TextElement element) + { + var style = FontStyle.Regular; + if (element.Bold) style |= FontStyle.Bold; + if (element.Italic) style |= FontStyle.Italic; + if (element.Underline) style |= FontStyle.Underline; + return style; + } + + private static void DrawLine(Graphics graphics, LineElement element, int dpi) + { + using var pen = new Pen(Color.Black, Math.Max(1f, MmToPixelConverter.MmToPxFloat(element.StrokeWidthMm, dpi))); + graphics.DrawLine( + pen, + MmToPixelConverter.MmToPxFloat(element.X1Mm, dpi), + MmToPixelConverter.MmToPxFloat(element.Y1Mm, dpi), + MmToPixelConverter.MmToPxFloat(element.X2Mm, dpi), + MmToPixelConverter.MmToPxFloat(element.Y2Mm, dpi)); + } + + private static void DrawRectangle(Graphics graphics, RectangleElement element, int dpi) + { + var rectangle = new RectangleF( + MmToPixelConverter.MmToPxFloat(element.XMm, dpi), + MmToPixelConverter.MmToPxFloat(element.YMm, dpi), + MmToPixelConverter.MmToPxFloat(element.WidthMm, dpi), + MmToPixelConverter.MmToPxFloat(element.HeightMm, dpi)); + + if (element.Filled) + { + using var brush = new SolidBrush(Color.Black); + graphics.FillRectangle(brush, rectangle); + return; + } + + using var pen = new Pen(Color.Black, Math.Max(1f, MmToPixelConverter.MmToPxFloat(element.StrokeWidthMm, dpi))); + graphics.DrawRectangle(pen, rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height); + } + + private static void DrawQrCode(Graphics graphics, QrCodeElement element, Dictionary payload, int dpi, List warnings) + { + var value = TemplateFormatter.Format(element.Value, payload, warnings); + if (string.IsNullOrWhiteSpace(value)) + { + warnings.Add("QR-Code wurde nicht gerendert, weil der Wert leer ist."); + return; + } + + using var generator = new QRCodeGenerator(); + using var qrData = generator.CreateQrCode(value, QRCodeGenerator.ECCLevel.Q); + using var qrCode = new QRCode(qrData); + using var qrBitmap = qrCode.GetGraphic(12, Color.Black, Color.White, drawQuietZones: true); + + graphics.DrawImage( + qrBitmap, + MmToPixelConverter.MmToPxFloat(element.XMm, dpi), + MmToPixelConverter.MmToPxFloat(element.YMm, dpi), + MmToPixelConverter.MmToPxFloat(element.SizeMm, dpi), + MmToPixelConverter.MmToPxFloat(element.SizeMm, dpi)); + } + + private static string Shorten(string value) + { + return value.Length <= 40 ? value : value[..37] + "..."; + } +} diff --git a/src/LabelPrintAgent/Rendering/MmToPixelConverter.cs b/src/LabelPrintAgent/Rendering/MmToPixelConverter.cs new file mode 100644 index 0000000..ff3a1b7 --- /dev/null +++ b/src/LabelPrintAgent/Rendering/MmToPixelConverter.cs @@ -0,0 +1,14 @@ +namespace LabelPrintAgent.Rendering; + +public static class MmToPixelConverter +{ + public static int MmToPx(double mm, int dpi) + { + return (int)Math.Round(mm / 25.4d * dpi); + } + + public static float MmToPxFloat(double mm, int dpi) + { + return (float)(mm / 25.4d * dpi); + } +} diff --git a/src/LabelPrintAgent/Rendering/PreviewDataProvider.cs b/src/LabelPrintAgent/Rendering/PreviewDataProvider.cs new file mode 100644 index 0000000..8a8fda7 --- /dev/null +++ b/src/LabelPrintAgent/Rendering/PreviewDataProvider.cs @@ -0,0 +1,17 @@ +namespace LabelPrintAgent.Rendering; + +public static class PreviewDataProvider +{ + public static Dictionary CreatePayload() + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["titel"] = "Beleg privat", + ["beschreibung"] = "Dokument 2026-000123", + ["nummer"] = "2026-000123", + ["datum"] = new DateTime(2026, 5, 7), + ["menge"] = 42.5m, + ["qr"] = "bjoernprivat 0000123" + }; + } +} diff --git a/src/LabelPrintAgent/Rendering/RenderResult.cs b/src/LabelPrintAgent/Rendering/RenderResult.cs new file mode 100644 index 0000000..e52d6d0 --- /dev/null +++ b/src/LabelPrintAgent/Rendering/RenderResult.cs @@ -0,0 +1,24 @@ +using System.Drawing; + +namespace LabelPrintAgent.Rendering; + +public sealed class RenderResult : IDisposable +{ + public RenderResult(Bitmap image, List warnings) + { + Image = image; + WidthPx = image.Width; + HeightPx = image.Height; + Warnings = warnings; + } + + public Bitmap Image { get; } + public int WidthPx { get; } + public int HeightPx { get; } + public List Warnings { get; } + + public void Dispose() + { + Image.Dispose(); + } +} diff --git a/src/LabelPrintAgent/Rendering/TemplateFormatter.cs b/src/LabelPrintAgent/Rendering/TemplateFormatter.cs new file mode 100644 index 0000000..7a66e50 --- /dev/null +++ b/src/LabelPrintAgent/Rendering/TemplateFormatter.cs @@ -0,0 +1,113 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace LabelPrintAgent.Rendering; + +public static partial class TemplateFormatter +{ + public static string Format(string template, Dictionary payload) + { + return Format(template, payload, warnings: null); + } + + public static string Format(string template, Dictionary payload, List? warnings) + { + return PlaceholderRegex().Replace(template, match => + { + var field = match.Groups["field"].Value; + var format = match.Groups["format"].Success ? match.Groups["format"].Value : null; + + if (!payload.TryGetValue(field, out var rawValue) || rawValue is null) + { + warnings?.Add($"Platzhalter '{field}' wurde nicht in den Vorschau-Daten gefunden."); + return match.Value; + } + + var value = NormalizeValue(rawValue); + try + { + if (string.IsNullOrWhiteSpace(format)) + { + return Convert.ToString(value, CultureInfo.CurrentCulture) ?? string.Empty; + } + + return string.Format(CultureInfo.CurrentCulture, "{0:" + format + "}", value); + } + catch (FormatException) + { + warnings?.Add($"Platzhalter '{field}' hat ein ungültiges Format '{format}'."); + return Convert.ToString(value, CultureInfo.CurrentCulture) ?? string.Empty; + } + }); + } + + public static Dictionary FromJson(string payloadJson) + { + using var document = JsonDocument.Parse(payloadJson); + return FromJsonElement(document.RootElement); + } + + private static Dictionary FromJsonElement(JsonElement element) + { + var payload = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (element.ValueKind != JsonValueKind.Object) + { + return payload; + } + + foreach (var property in element.EnumerateObject()) + { + payload[property.Name] = ConvertJsonValue(property.Value); + } + + return payload; + } + + private static object? ConvertJsonValue(JsonElement value) + { + return value.ValueKind switch + { + JsonValueKind.String => ParseStringValue(value.GetString()), + JsonValueKind.Number when value.TryGetInt64(out var longValue) => longValue, + JsonValueKind.Number when value.TryGetDecimal(out var decimalValue) => decimalValue, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => value.ToString() + }; + } + + private static object? ParseStringValue(string? value) + { + if (value is null) + { + return null; + } + + if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dateTime)) + { + return dateTime; + } + + if (decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var decimalValue)) + { + return decimalValue; + } + + return value; + } + + private static object NormalizeValue(object value) + { + return value switch + { + JsonElement jsonElement => ConvertJsonValue(jsonElement) ?? string.Empty, + string stringValue => ParseStringValue(stringValue) ?? string.Empty, + _ => value + }; + } + + [GeneratedRegex(@"\{(?[A-Za-z0-9_]+)(:(?[^}]+))?\}")] + private static partial Regex PlaceholderRegex(); +}