Implement stage 3 label preview rendering

This commit is contained in:
2026-05-07 14:32:10 +02:00
parent ad56962985
commit 812f13ebee
9 changed files with 442 additions and 10 deletions
+25 -1
View File
@@ -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
+48 -9
View File
@@ -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);
@@ -7,10 +7,13 @@
<UseWindowsForms>true</UseWindowsForms>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<ApplicationManifest>app.manifest</ApplicationManifest>
<NuGetAudit>false</NuGetAudit>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="System.Drawing.Common" Version="9.0.0" />
</ItemGroup>
</Project>
@@ -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,
@@ -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<string, object?> payload)
{
var warnings = new List<string>();
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<string, object?> payload, int dpi, List<string> 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<string> 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<string, object?> payload, int dpi, List<string> 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] + "...";
}
}
@@ -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);
}
}
@@ -0,0 +1,17 @@
namespace LabelPrintAgent.Rendering;
public static class PreviewDataProvider
{
public static Dictionary<string, object?> CreatePayload()
{
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["titel"] = "Beleg privat",
["beschreibung"] = "Dokument 2026-000123",
["nummer"] = "2026-000123",
["datum"] = new DateTime(2026, 5, 7),
["menge"] = 42.5m,
["qr"] = "bjoernprivat 0000123"
};
}
}
@@ -0,0 +1,24 @@
using System.Drawing;
namespace LabelPrintAgent.Rendering;
public sealed class RenderResult : IDisposable
{
public RenderResult(Bitmap image, List<string> 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<string> Warnings { get; }
public void Dispose()
{
Image.Dispose();
}
}
@@ -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<string, object?> payload)
{
return Format(template, payload, warnings: null);
}
public static string Format(string template, Dictionary<string, object?> payload, List<string>? 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<string, object?> FromJson(string payloadJson)
{
using var document = JsonDocument.Parse(payloadJson);
return FromJsonElement(document.RootElement);
}
private static Dictionary<string, object?> FromJsonElement(JsonElement element)
{
var payload = new Dictionary<string, object?>(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(@"\{(?<field>[A-Za-z0-9_]+)(:(?<format>[^}]+))?\}")]
private static partial Regex PlaceholderRegex();
}