Implement stage 3 label preview rendering
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user