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();
+}