Initial LabelPrintAgent scaffold

This commit is contained in:
2026-05-07 13:28:02 +02:00
commit 7f3592682c
30 changed files with 1854 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# Build output
bin/
obj/
# IDE/user files
.vs/
.vscode/
.idea/
*.user
*.suo
*.rsuser
# OS files
.DS_Store
Thumbs.db
# Runtime data
logs/
settings.json
layouts/
+21
View File
@@ -0,0 +1,21 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LabelPrintAgent", "src\LabelPrintAgent\LabelPrintAgent.csproj", "{7FEA11FE-3DB1-43CF-9A9F-4EDE96081623}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7FEA11FE-3DB1-43CF-9A9F-4EDE96081623}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7FEA11FE-3DB1-43CF-9A9F-4EDE96081623}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7FEA11FE-3DB1-43CF-9A9F-4EDE96081623}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7FEA11FE-3DB1-43CF-9A9F-4EDE96081623}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
+52
View File
@@ -0,0 +1,52 @@
# LabelPrintAgent
Windows-Tray-Anwendung zum Rendern und Drucken von Etiketten aus JSON-Layouts.
## Stand
Der erste lauffähige Schritt enthält:
- .NET-9-Windows-Forms-Projekt mit Tray-Icon
- Einstellungsdialog mit Tabs für Allgemein, Datenbank, Drucker, Layout und Fehlerjobs
- lokale Konfiguration unter `C:\ProgramData\LabelPrintAgent\settings.json`
- verschlüsselte Passwortspeicherung per Windows DPAPI
- Druckerauswahl über installierte Windows-Drucker
- Layout-JSON unter `C:\ProgramData\LabelPrintAgent\layouts`
- Beispiel-Layout `dymo_57x32_standard`
- Rendering mit 300 dpi als Bitmap
- Vorschau und Testdruck über `PrintDocument`
- Serilog-Dateilogs unter `C:\ProgramData\LabelPrintAgent\logs`
- vorbereiteter MySQL-Worker ohne automatischen Retry
## Öffnen und Starten
1. `LabelPrintAgent.sln` in Visual Studio oder Rider öffnen.
2. Auf einem Windows-Rechner bauen und starten.
3. Tray-Symbol anklicken, um die Einstellungen zu öffnen.
4. Im Tab `Drucker` den Dymo LabelWriter auswählen.
5. Im Tab `Layout` das Beispiel-Layout öffnen, `Vorschau` klicken und danach `Testdruck`.
## Datenbank
Die Tabelle kann mit folgendem Skript angelegt werden:
```sql
sql/create_label_print_queue.sql
```
Der Worker lädt nur Jobs mit `status = 'pending'`. Vor dem Druck wird der Job auf `printing` gesetzt. Bei Erfolg setzt er `printed`, `printed_at` und `printer_name`. Bei Fehlern setzt er `error`, speichert `error_message` und erhöht `attempts`.
Fehlerhafte Jobs werden nicht automatisch wiederholt. Im Tab `Fehlerhafte Druckaufträge` können sie manuell erneut auf `pending` gesetzt oder als `deleted` markiert werden.
## Layout-Platzhalter
Text- und QR-Werte unterstützen Platzhalter aus `payload_json`:
```text
{titel}
{nummer}
{datum:dd.MM.yyyy}
{menge:0.00}
```
Formatangaben werden über C#-Formatstrings ausgewertet.
+29
View File
@@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS label_print_queue (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
layout_key VARCHAR(100) NOT NULL,
payload_json JSON NOT NULL,
status ENUM('pending','printing','printed','error','deleted') NOT NULL DEFAULT 'pending',
printer_name VARCHAR(255) NULL,
attempts INT NOT NULL DEFAULT 0,
error_message TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
locked_at DATETIME NULL,
printed_at DATETIME NULL,
INDEX idx_label_print_queue_status_created (status, created_at),
INDEX idx_label_print_queue_layout_key (layout_key)
);
INSERT INTO label_print_queue
(layout_key, payload_json, status)
VALUES
(
'dymo_57x32_standard',
JSON_OBJECT(
'titel', 'Beleg privat',
'beschreibung', 'Dokument 2026-000123',
'nummer', '2026-000123',
'datum', '2026-05-07',
'qr', 'bjoernprivat 0000123'
),
'pending'
);
+47
View File
@@ -0,0 +1,47 @@
using LabelPrintAgent.Configuration;
using LabelPrintAgent.Layout;
using LabelPrintAgent.Logging;
using LabelPrintAgent.Rendering;
using LabelPrintAgent.Printing;
using LabelPrintAgent.Database;
using LabelPrintAgent.Worker;
using Serilog;
namespace LabelPrintAgent.App;
internal static class Program
{
[STAThread]
private static void Main()
{
ApplicationConfiguration.Initialize();
try
{
var protectedStrings = new ProtectedStringService();
var settingsStore = new SettingsStore(protectedStrings);
var settings = settingsStore.Load();
settings.EnsureDirectories();
LogSetup.Configure(settings.Paths.LogFolder);
LayoutStore.EnsureExampleLayout(settings.Paths.LayoutFolder);
using var layoutStore = new LayoutStore(settings.Paths.LayoutFolder);
using var renderer = new LabelRenderer();
var printerService = new PrinterService(new WindowsImagePrinter());
var repository = new MySqlLabelRepository(settingsStore, protectedStrings);
using var worker = new PrintJobWorker(settingsStore, layoutStore, renderer, printerService, repository);
worker.Start();
Application.Run(new TrayApplicationContext(settingsStore, layoutStore, renderer, printerService, repository, worker));
}
catch (Exception ex)
{
Log.Fatal(ex, "Fatal application startup error");
MessageBox.Show(ex.Message, "LabelPrintAgent", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
Log.CloseAndFlush();
}
}
}
+477
View File
@@ -0,0 +1,477 @@
using System.Drawing;
using LabelPrintAgent.Configuration;
using LabelPrintAgent.Database;
using LabelPrintAgent.Layout;
using LabelPrintAgent.Printing;
using LabelPrintAgent.Rendering;
using LabelPrintAgent.Worker;
using Microsoft.Win32;
using Serilog;
namespace LabelPrintAgent.App;
internal sealed class SettingsForm : Form
{
private const string AutoStartRunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string AutoStartValueName = "LabelPrintAgent";
private readonly SettingsStore _settingsStore;
private readonly LayoutStore _layoutStore;
private readonly LabelRenderer _renderer;
private readonly PrinterService _printerService;
private readonly MySqlLabelRepository _repository;
private readonly PrintJobWorker _worker;
private readonly CheckBox _autoStartCheckBox = new() { Text = "Autostart aktivieren", AutoSize = true };
private readonly NumericUpDown _pollInterval = new() { Minimum = 1, Maximum = 3600, Value = 5, Width = 100 };
private readonly TextBox _programDataFolder = new() { ReadOnly = true, Width = 540 };
private readonly TextBox _host = new() { Width = 260 };
private readonly NumericUpDown _port = new() { Minimum = 1, Maximum = 65535, Value = 3306, Width = 100 };
private readonly TextBox _database = new() { Width = 260 };
private readonly TextBox _username = new() { Width = 260 };
private readonly TextBox _password = new() { Width = 260, UseSystemPasswordChar = true };
private readonly ComboBox _printer = new() { Width = 360, DropDownStyle = ComboBoxStyle.DropDownList };
private readonly NumericUpDown _labelWidth = new() { Minimum = 1, Maximum = 500, DecimalPlaces = 1, Value = 57, Width = 100 };
private readonly NumericUpDown _labelHeight = new() { Minimum = 1, Maximum = 500, DecimalPlaces = 1, Value = 32, Width = 100 };
private readonly ComboBox _layoutKeys = new() { Width = 280, DropDownStyle = ComboBoxStyle.DropDownList };
private readonly TextBox _layoutJson = new() { Multiline = true, ScrollBars = ScrollBars.Both, WordWrap = false, Font = new Font(FontFamily.GenericMonospace, 9), Width = 620, Height = 360 };
private readonly PictureBox _preview = new() { BorderStyle = BorderStyle.FixedSingle, SizeMode = PictureBoxSizeMode.Zoom, Width = 420, Height = 240, BackColor = Color.White };
private readonly DataGridView _errorJobs = new()
{
ReadOnly = true,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
MultiSelect = false,
Dock = DockStyle.Fill
};
public SettingsForm(
SettingsStore settingsStore,
LayoutStore layoutStore,
LabelRenderer renderer,
PrinterService printerService,
MySqlLabelRepository repository,
PrintJobWorker worker)
{
_settingsStore = settingsStore;
_layoutStore = layoutStore;
_renderer = renderer;
_printerService = printerService;
_repository = repository;
_worker = worker;
Text = "LabelPrintAgent Einstellungen";
Width = 1120;
Height = 760;
StartPosition = FormStartPosition.CenterScreen;
var tabs = new TabControl { Dock = DockStyle.Fill };
tabs.TabPages.Add(CreateGeneralTab());
tabs.TabPages.Add(CreateDatabaseTab());
tabs.TabPages.Add(CreatePrinterTab());
tabs.TabPages.Add(CreateLayoutTab());
tabs.TabPages.Add(CreateErrorJobsTab());
Controls.Add(tabs);
Load += async (_, _) =>
{
LoadSettingsIntoUi();
LoadPrinters();
LoadLayouts();
await RefreshErrorJobsAsync();
};
}
private TabPage CreateGeneralTab()
{
var panel = CreatePaddedPanel();
panel.Controls.Add(Row(20, Label("Autostart", 140), _autoStartCheckBox));
panel.Controls.Add(Row(60, Label("Prüfintervall", 140), _pollInterval, Label("Sekunden", 80)));
panel.Controls.Add(Row(100, Label("Programmdaten", 140), _programDataFolder));
panel.Controls.Add(ButtonAt("Speichern", 160, 150, (_, _) => SaveGeneral()));
return new TabPage("Allgemein") { Controls = { panel } };
}
private TabPage CreateDatabaseTab()
{
var panel = CreatePaddedPanel();
panel.Controls.Add(Row(20, Label("Host", 120), _host));
panel.Controls.Add(Row(60, Label("Port", 120), _port));
panel.Controls.Add(Row(100, Label("Datenbank", 120), _database));
panel.Controls.Add(Row(140, Label("Benutzer", 120), _username));
panel.Controls.Add(Row(180, Label("Passwort", 120), _password));
panel.Controls.Add(ButtonAt("Verbindung testen", 140, 230, async (_, _) => await TestConnectionAsync()));
panel.Controls.Add(ButtonAt("Speichern", 310, 230, (_, _) => SaveDatabase()));
return new TabPage("Datenbank") { Controls = { panel } };
}
private TabPage CreatePrinterTab()
{
var panel = CreatePaddedPanel();
panel.Controls.Add(Row(20, Label("Drucker", 120), _printer));
panel.Controls.Add(Row(60, Label("Breite", 120), _labelWidth, Label("mm", 40)));
panel.Controls.Add(Row(100, Label("Höhe", 120), _labelHeight, Label("mm", 40)));
panel.Controls.Add(ButtonAt("Drucker neu laden", 140, 150, (_, _) => LoadPrinters()));
panel.Controls.Add(ButtonAt("Testdruck", 300, 150, (_, _) => PrintCurrentLayout()));
panel.Controls.Add(_preview);
_preview.Left = 520;
_preview.Top = 20;
return new TabPage("Drucker") { Controls = { panel } };
}
private TabPage CreateLayoutTab()
{
var panel = CreatePaddedPanel();
panel.Controls.Add(Row(20, Label("Layout", 80), _layoutKeys));
_layoutKeys.SelectedIndexChanged += (_, _) => LoadSelectedLayoutJson();
panel.Controls.Add(_layoutJson);
_layoutJson.Left = 20;
_layoutJson.Top = 60;
panel.Controls.Add(_preview);
_preview.Left = 680;
_preview.Top = 60;
panel.Controls.Add(ButtonAt("Validieren", 20, 440, (_, _) => ValidateLayout()));
panel.Controls.Add(ButtonAt("Speichern", 130, 440, (_, _) => SaveLayout()));
panel.Controls.Add(ButtonAt("Vorschau", 240, 440, (_, _) => RenderPreview()));
panel.Controls.Add(ButtonAt("Testdruck", 350, 440, (_, _) => PrintCurrentLayout()));
return new TabPage("Layout") { Controls = { panel } };
}
private TabPage CreateErrorJobsTab()
{
var page = new TabPage("Fehlerhafte Druckaufträge");
var panel = new Panel { Dock = DockStyle.Bottom, Height = 56, Padding = new Padding(12) };
panel.Controls.Add(ButtonAt("Aktualisieren", 12, 12, async (_, _) => await RefreshErrorJobsAsync()));
panel.Controls.Add(ButtonAt("Vorschau", 140, 12, (_, _) => PreviewSelectedErrorJob()));
panel.Controls.Add(ButtonAt("Nochmal drucken", 240, 12, async (_, _) => await RequeueSelectedErrorJobAsync()));
panel.Controls.Add(ButtonAt("Löschen/verwerfen", 390, 12, async (_, _) => await DeleteSelectedErrorJobAsync()));
page.Controls.Add(_errorJobs);
page.Controls.Add(panel);
return page;
}
private void LoadSettingsIntoUi()
{
var settings = _settingsStore.Load();
_autoStartCheckBox.Checked = IsAutostartEnabled();
_pollInterval.Value = Math.Clamp(settings.Worker.PollIntervalSeconds, 1, 3600);
_programDataFolder.Text = settings.Paths.BaseFolder;
_host.Text = settings.Database.Host;
_port.Value = settings.Database.Port;
_database.Text = settings.Database.Database;
_username.Text = settings.Database.Username;
_password.Text = _settingsStore.DecryptPassword(settings);
_labelWidth.Value = settings.Printer.LabelWidthMm;
_labelHeight.Value = settings.Printer.LabelHeightMm;
}
private void LoadPrinters()
{
var selected = _settingsStore.Load().Printer.PrinterName;
_printer.Items.Clear();
foreach (var printer in _printerService.GetInstalledPrinters())
{
_printer.Items.Add(printer);
}
if (!string.IsNullOrWhiteSpace(selected) && _printer.Items.Contains(selected))
{
_printer.SelectedItem = selected;
}
else if (_printer.Items.Count > 0)
{
_printer.SelectedIndex = 0;
}
}
private void LoadLayouts()
{
var selected = _layoutKeys.SelectedItem?.ToString();
_layoutKeys.Items.Clear();
foreach (var key in _layoutStore.GetLayoutKeys())
{
_layoutKeys.Items.Add(key);
}
if (selected is not null && _layoutKeys.Items.Contains(selected))
{
_layoutKeys.SelectedItem = selected;
}
else if (_layoutKeys.Items.Count > 0)
{
_layoutKeys.SelectedIndex = 0;
}
}
private void LoadSelectedLayoutJson()
{
if (_layoutKeys.SelectedItem is not string key)
{
return;
}
try
{
_layoutJson.Text = _layoutStore.LoadJsonByKey(key);
RenderPreview();
}
catch (Exception ex)
{
ShowError(ex);
}
}
private void SaveGeneral()
{
var settings = _settingsStore.Load();
settings.Worker.PollIntervalSeconds = (int)_pollInterval.Value;
_settingsStore.Save(settings);
SetAutostart(_autoStartCheckBox.Checked);
MessageBox.Show("Allgemeine Einstellungen gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
}
private void SaveDatabase()
{
var settings = _settingsStore.Load();
settings.Database.Host = _host.Text.Trim();
settings.Database.Port = (uint)_port.Value;
settings.Database.Database = _database.Text.Trim();
settings.Database.Username = _username.Text.Trim();
settings.Database.EncryptedPassword = _settingsStore.EncryptPassword(_password.Text);
_settingsStore.Save(settings);
MessageBox.Show("Datenbankeinstellungen gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
}
private async Task TestConnectionAsync()
{
try
{
SaveDatabase();
await _repository.TestConnectionAsync();
MessageBox.Show("Verbindung erfolgreich.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
ShowError(ex);
}
}
private void SavePrinter()
{
var settings = _settingsStore.Load();
settings.Printer.PrinterName = _printer.SelectedItem?.ToString() ?? string.Empty;
settings.Printer.LabelWidthMm = _labelWidth.Value;
settings.Printer.LabelHeightMm = _labelHeight.Value;
_settingsStore.Save(settings);
}
private void ValidateLayout()
{
try
{
_layoutStore.ValidateJson(_layoutJson.Text);
MessageBox.Show("Layout ist gültig.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
ShowError(ex);
}
}
private void SaveLayout()
{
try
{
_layoutStore.SaveJson(_layoutJson.Text);
LoadLayouts();
MessageBox.Show("Layout gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
ShowError(ex);
}
}
private void RenderPreview()
{
try
{
var layout = _layoutStore.ValidateJson(_layoutJson.Text);
using var result = _renderer.Render(layout, LayoutStore.ExamplePayloadJson);
var old = _preview.Image;
_preview.Image = (Bitmap)result.Bitmap.Clone();
old?.Dispose();
}
catch (Exception ex)
{
ShowError(ex);
}
}
private void PrintCurrentLayout()
{
try
{
SavePrinter();
var layout = _layoutStore.ValidateJson(_layoutJson.Text);
var settings = _settingsStore.Load();
using var result = _renderer.Render(layout, LayoutStore.ExamplePayloadJson);
_printerService.Print(result.Bitmap, settings.Printer.PrinterName, layout.WidthMm, layout.HeightMm);
MessageBox.Show("Testdruck wurde gesendet.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
ShowError(ex);
}
}
private async Task RefreshErrorJobsAsync()
{
try
{
var jobs = await _repository.LoadErrorJobsAsync();
_errorJobs.DataSource = jobs.Select(job => new ErrorJobRow(job)).ToList();
}
catch (Exception ex)
{
Log.Warning(ex, "Could not load error jobs");
_errorJobs.DataSource = Array.Empty<ErrorJobRow>();
}
}
private void PreviewSelectedErrorJob()
{
try
{
var job = GetSelectedErrorJob();
var layout = _layoutStore.LoadByKey(job.Layout);
using var result = _renderer.Render(layout, job.PayloadJson);
var previewForm = new Form { Text = $"Vorschau Job {job.Id}", Width = 640, Height = 420, StartPosition = FormStartPosition.CenterParent };
previewForm.Controls.Add(new PictureBox
{
Image = (Bitmap)result.Bitmap.Clone(),
Dock = DockStyle.Fill,
SizeMode = PictureBoxSizeMode.Zoom,
BackColor = Color.White
});
previewForm.Show(this);
}
catch (Exception ex)
{
ShowError(ex);
}
}
private async Task RequeueSelectedErrorJobAsync()
{
try
{
var job = GetSelectedErrorJob();
await _repository.RequeueAsync(job.Id);
await _worker.PollOnceAsync();
await RefreshErrorJobsAsync();
}
catch (Exception ex)
{
ShowError(ex);
}
}
private async Task DeleteSelectedErrorJobAsync()
{
try
{
var job = GetSelectedErrorJob();
await _repository.MarkDeletedAsync(job.Id);
await RefreshErrorJobsAsync();
}
catch (Exception ex)
{
ShowError(ex);
}
}
private ErrorJobRow GetSelectedErrorJob()
{
if (_errorJobs.CurrentRow?.DataBoundItem is ErrorJobRow row)
{
return row;
}
throw new InvalidOperationException("Bitte zuerst einen fehlerhaften Druckauftrag auswählen.");
}
private bool IsAutostartEnabled()
{
using var key = Registry.CurrentUser.OpenSubKey(AutoStartRunKey, writable: false);
return key?.GetValue(AutoStartValueName) is string value && value.Length > 0;
}
private static void SetAutostart(bool enabled)
{
using var key = Registry.CurrentUser.OpenSubKey(AutoStartRunKey, writable: true)
?? Registry.CurrentUser.CreateSubKey(AutoStartRunKey);
if (!enabled)
{
key.DeleteValue(AutoStartValueName, throwOnMissingValue: false);
return;
}
key.SetValue(AutoStartValueName, $"\"{Application.ExecutablePath}\"");
}
private static Panel CreatePaddedPanel() => new() { Dock = DockStyle.Fill, Padding = new Padding(20), AutoScroll = true };
private static Label Label(string text, int width) => new() { Text = text, Width = width, TextAlign = ContentAlignment.MiddleLeft };
private static FlowLayoutPanel Row(int top, params Control[] controls)
{
var row = new FlowLayoutPanel
{
Left = 20,
Top = top,
Width = 760,
Height = 32,
FlowDirection = FlowDirection.LeftToRight,
WrapContents = false
};
row.Controls.AddRange(controls);
return row;
}
private static Button ButtonAt(string text, int left, int top, EventHandler click)
{
var button = new Button { Text = text, Left = left, Top = top, Width = Math.Max(100, text.Length * 8 + 24), Height = 32 };
button.Click += click;
return button;
}
private void ShowError(Exception ex)
{
Log.Error(ex, "Settings form action failed");
MessageBox.Show(ex.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
private sealed class ErrorJobRow
{
private readonly PrintJob _job;
public ErrorJobRow(PrintJob job)
{
_job = job;
}
public long Id => _job.Id;
public string Layout => _job.LayoutKey;
public DateTime ErstelltAm => _job.CreatedAt;
public string Fehler => _job.ErrorMessage ?? string.Empty;
public int Versuche => _job.Attempts;
public string PayloadJson => _job.PayloadJson;
}
}
@@ -0,0 +1,84 @@
using LabelPrintAgent.Configuration;
using LabelPrintAgent.Database;
using LabelPrintAgent.Layout;
using LabelPrintAgent.Printing;
using LabelPrintAgent.Rendering;
using LabelPrintAgent.Worker;
using Serilog;
namespace LabelPrintAgent.App;
internal sealed class TrayApplicationContext : ApplicationContext
{
private readonly NotifyIcon _notifyIcon;
private readonly SettingsStore _settingsStore;
private readonly LayoutStore _layoutStore;
private readonly LabelRenderer _renderer;
private readonly PrinterService _printerService;
private readonly MySqlLabelRepository _repository;
private readonly PrintJobWorker _worker;
private SettingsForm? _settingsForm;
public TrayApplicationContext(
SettingsStore settingsStore,
LayoutStore layoutStore,
LabelRenderer renderer,
PrinterService printerService,
MySqlLabelRepository repository,
PrintJobWorker worker)
{
_settingsStore = settingsStore;
_layoutStore = layoutStore;
_renderer = renderer;
_printerService = printerService;
_repository = repository;
_worker = worker;
var menu = new ContextMenuStrip();
menu.Items.Add("Einstellungen", null, (_, _) => ShowSettings());
menu.Items.Add("Beenden", null, (_, _) => ExitThread());
_notifyIcon = new NotifyIcon
{
Text = "LabelPrintAgent",
Icon = SystemIcons.Application,
ContextMenuStrip = menu,
Visible = true
};
_notifyIcon.MouseClick += (_, args) =>
{
if (args.Button == MouseButtons.Left)
{
ShowSettings();
}
};
}
private void ShowSettings()
{
try
{
if (_settingsForm is null || _settingsForm.IsDisposed)
{
_settingsForm = new SettingsForm(_settingsStore, _layoutStore, _renderer, _printerService, _repository, _worker);
}
_settingsForm.Show();
_settingsForm.WindowState = FormWindowState.Normal;
_settingsForm.Activate();
}
catch (Exception ex)
{
Log.Error(ex, "Could not open settings form");
MessageBox.Show(ex.Message, "LabelPrintAgent", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
protected override void ExitThreadCore()
{
_notifyIcon.Visible = false;
_notifyIcon.Dispose();
_settingsForm?.Dispose();
base.ExitThreadCore();
}
}
@@ -0,0 +1,52 @@
namespace LabelPrintAgent.Configuration;
public sealed class AppSettings
{
public DatabaseSettings Database { get; set; } = new();
public PrinterSettings Printer { get; set; } = new();
public WorkerSettings Worker { get; set; } = new();
public PathSettings Paths { get; set; } = PathSettings.CreateDefault();
public void EnsureDirectories()
{
Directory.CreateDirectory(Paths.BaseFolder);
Directory.CreateDirectory(Paths.LayoutFolder);
Directory.CreateDirectory(Paths.LogFolder);
}
}
public sealed class DatabaseSettings
{
public string Host { get; set; } = "10.1.10.xxx";
public uint Port { get; set; } = 3306;
public string Database { get; set; } = "labeldb";
public string Username { get; set; } = "label_user";
public string EncryptedPassword { get; set; } = string.Empty;
}
public sealed class PrinterSettings
{
public string PrinterName { get; set; } = string.Empty;
public decimal LabelWidthMm { get; set; } = 57;
public decimal LabelHeightMm { get; set; } = 32;
public int Dpi { get; set; } = 300;
}
public sealed class WorkerSettings
{
public int PollIntervalSeconds { get; set; } = 5;
}
public sealed class PathSettings
{
public string BaseFolder { get; set; } = DefaultBaseFolder;
public string LayoutFolder { get; set; } = Path.Combine(DefaultBaseFolder, "layouts");
public string LogFolder { get; set; } = Path.Combine(DefaultBaseFolder, "logs");
public static string DefaultBaseFolder =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "LabelPrintAgent");
public static string SettingsFilePath => Path.Combine(DefaultBaseFolder, "settings.json");
public static PathSettings CreateDefault() => new();
}
@@ -0,0 +1,31 @@
using System.Security.Cryptography;
using System.Text;
namespace LabelPrintAgent.Configuration;
public sealed class ProtectedStringService
{
public string Protect(string plainText)
{
if (string.IsNullOrEmpty(plainText))
{
return string.Empty;
}
var data = Encoding.UTF8.GetBytes(plainText);
var encrypted = ProtectedData.Protect(data, optionalEntropy: null, DataProtectionScope.CurrentUser);
return Convert.ToBase64String(encrypted);
}
public string Unprotect(string encryptedText)
{
if (string.IsNullOrWhiteSpace(encryptedText))
{
return string.Empty;
}
var data = Convert.FromBase64String(encryptedText);
var decrypted = ProtectedData.Unprotect(data, optionalEntropy: null, DataProtectionScope.CurrentUser);
return Encoding.UTF8.GetString(decrypted);
}
}
@@ -0,0 +1,60 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Serilog;
namespace LabelPrintAgent.Configuration;
public sealed class SettingsStore
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly ProtectedStringService _protectedStringService;
public SettingsStore(ProtectedStringService protectedStringService)
{
_protectedStringService = protectedStringService;
}
public string SettingsFilePath => PathSettings.SettingsFilePath;
public AppSettings Load()
{
Directory.CreateDirectory(PathSettings.DefaultBaseFolder);
if (!File.Exists(SettingsFilePath))
{
var defaults = new AppSettings();
Save(defaults);
return defaults;
}
try
{
var json = File.ReadAllText(SettingsFilePath);
var settings = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions) ?? new AppSettings();
settings.Paths ??= PathSettings.CreateDefault();
return settings;
}
catch (Exception ex)
{
Log.Error(ex, "Could not load settings, using defaults");
return new AppSettings();
}
}
public void Save(AppSettings settings)
{
settings.EnsureDirectories();
var json = JsonSerializer.Serialize(settings, JsonOptions);
File.WriteAllText(SettingsFilePath, json);
}
public string EncryptPassword(string password) => _protectedStringService.Protect(password);
public string DecryptPassword(AppSettings settings) => _protectedStringService.Unprotect(settings.Database.EncryptedPassword);
}
@@ -0,0 +1,10 @@
namespace LabelPrintAgent.Database;
public sealed class DbConnectionSettings
{
public string Host { get; set; } = string.Empty;
public uint Port { get; set; } = 3306;
public string Database { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
@@ -0,0 +1,162 @@
using LabelPrintAgent.Configuration;
using MySqlConnector;
namespace LabelPrintAgent.Database;
public sealed class MySqlLabelRepository
{
private readonly SettingsStore _settingsStore;
private readonly ProtectedStringService _protectedStringService;
public MySqlLabelRepository(SettingsStore settingsStore, ProtectedStringService protectedStringService)
{
_settingsStore = settingsStore;
_protectedStringService = protectedStringService;
}
public async Task TestConnectionAsync(CancellationToken cancellationToken = default)
{
await using var connection = new MySqlConnection(BuildConnectionString());
await connection.OpenAsync(cancellationToken);
}
public async Task<IReadOnlyList<PrintJob>> LoadErrorJobsAsync(CancellationToken cancellationToken = default)
{
await using var connection = new MySqlConnection(BuildConnectionString());
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT id, layout_key, payload_json, status, printer_name, attempts, error_message, created_at, locked_at, printed_at
FROM label_print_queue
WHERE status = 'error'
ORDER BY created_at DESC
LIMIT 100;
""";
var jobs = new List<PrintJob>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
jobs.Add(ReadJob(reader));
}
return jobs;
}
public async Task<IReadOnlyList<PrintJob>> LoadPendingJobsAsync(int limit, CancellationToken cancellationToken = default)
{
await using var connection = new MySqlConnection(BuildConnectionString());
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT id, layout_key, payload_json, status, printer_name, attempts, error_message, created_at, locked_at, printed_at
FROM label_print_queue
WHERE status = 'pending'
ORDER BY created_at ASC
LIMIT @limit;
""";
command.Parameters.AddWithValue("@limit", limit);
var jobs = new List<PrintJob>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
jobs.Add(ReadJob(reader));
}
return jobs;
}
public async Task<bool> MarkPrintingAsync(long id, CancellationToken cancellationToken = default)
{
var affectedRows = await ExecuteAsync("""
UPDATE label_print_queue
SET status = 'printing', locked_at = CURRENT_TIMESTAMP
WHERE id = @id AND status = 'pending';
""", id, cancellationToken);
return affectedRows == 1;
}
public async Task MarkPrintedAsync(long id, string printerName, CancellationToken cancellationToken = default)
{
await ExecuteAsync("""
UPDATE label_print_queue
SET status = 'printed', printer_name = @printerName, printed_at = CURRENT_TIMESTAMP, error_message = NULL
WHERE id = @id;
""", id, cancellationToken, ("@printerName", printerName));
}
public async Task MarkErrorAsync(long id, string errorMessage, CancellationToken cancellationToken = default)
{
await ExecuteAsync("""
UPDATE label_print_queue
SET status = 'error', error_message = @errorMessage, attempts = attempts + 1
WHERE id = @id;
""", id, cancellationToken, ("@errorMessage", errorMessage));
}
public async Task RequeueAsync(long id, CancellationToken cancellationToken = default)
{
await ExecuteAsync("""
UPDATE label_print_queue
SET status = 'pending', error_message = NULL, locked_at = NULL
WHERE id = @id AND status = 'error';
""", id, cancellationToken);
}
public async Task MarkDeletedAsync(long id, CancellationToken cancellationToken = default)
{
await ExecuteAsync("""
UPDATE label_print_queue
SET status = 'deleted'
WHERE id = @id AND status = 'error';
""", id, cancellationToken);
}
private async Task<int> ExecuteAsync(string sql, long id, CancellationToken cancellationToken, params (string Name, object Value)[] parameters)
{
await using var connection = new MySqlConnection(BuildConnectionString());
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = sql;
command.Parameters.AddWithValue("@id", id);
foreach (var parameter in parameters)
{
command.Parameters.AddWithValue(parameter.Name, parameter.Value);
}
return await command.ExecuteNonQueryAsync(cancellationToken);
}
private string BuildConnectionString()
{
var settings = _settingsStore.Load();
var builder = new MySqlConnectionStringBuilder
{
Server = settings.Database.Host,
Port = settings.Database.Port,
Database = settings.Database.Database,
UserID = settings.Database.Username,
Password = _protectedStringService.Unprotect(settings.Database.EncryptedPassword),
SslMode = MySqlSslMode.Preferred
};
return builder.ConnectionString;
}
private static PrintJob ReadJob(MySqlDataReader reader)
{
return new PrintJob
{
Id = reader.GetInt64("id"),
LayoutKey = reader.GetString("layout_key"),
PayloadJson = reader.GetString("payload_json"),
Status = reader.GetString("status"),
PrinterName = reader.IsDBNull(reader.GetOrdinal("printer_name")) ? null : reader.GetString("printer_name"),
Attempts = reader.GetInt32("attempts"),
ErrorMessage = reader.IsDBNull(reader.GetOrdinal("error_message")) ? null : reader.GetString("error_message"),
CreatedAt = reader.GetDateTime("created_at"),
LockedAt = reader.IsDBNull(reader.GetOrdinal("locked_at")) ? null : reader.GetDateTime("locked_at"),
PrintedAt = reader.IsDBNull(reader.GetOrdinal("printed_at")) ? null : reader.GetDateTime("printed_at")
};
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace LabelPrintAgent.Database;
public sealed class PrintJob
{
public long Id { get; set; }
public string LayoutKey { get; set; } = string.Empty;
public string PayloadJson { get; set; } = "{}";
public string Status { get; set; } = "pending";
public string? PrinterName { get; set; }
public int Attempts { get; set; }
public string? ErrorMessage { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? LockedAt { get; set; }
public DateTime? PrintedAt { get; set; }
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWindowsForms>true</UseWindowsForms>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="MySqlConnector" Version="2.4.0" />
<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>
+25
View File
@@ -0,0 +1,25 @@
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 int Dpi { get; set; } = 300;
public decimal 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;
}
@@ -0,0 +1,15 @@
namespace LabelPrintAgent.Layout;
public enum HorizontalAlign
{
Left,
Center,
Right
}
public enum VerticalAlign
{
Top,
Middle,
Bottom
}
@@ -0,0 +1,31 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LabelPrintAgent.Layout;
public sealed class LayoutElementJsonConverter : JsonConverter<LayoutElement>
{
public override LayoutElement Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var document = JsonDocument.ParseValue(ref reader);
if (!document.RootElement.TryGetProperty("type", out var typeProperty))
{
throw new JsonException("Layout element has no type.");
}
var raw = document.RootElement.GetRawText();
return typeProperty.GetString()?.ToLowerInvariant() 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()}'.")
};
}
public override void Write(Utf8JsonWriter writer, LayoutElement value, JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, (object)value, value.GetType(), options);
}
}
+191
View File
@@ -0,0 +1,191 @@
using System.Text.Json;
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;
public LayoutStore(string layoutFolder)
{
_layoutFolder = layoutFolder;
Directory.CreateDirectory(_layoutFolder);
}
public IReadOnlyList<string> GetLayoutKeys()
{
return Directory.GetFiles(_layoutFolder, "*.json")
.Select(path => Load(path).Key)
.Where(key => !string.IsNullOrWhiteSpace(key))
.OrderBy(key => key)
.ToList();
}
public LabelLayout LoadByKey(string key)
{
foreach (var path in Directory.GetFiles(_layoutFolder, "*.json"))
{
var layout = Load(path);
if (string.Equals(layout.Key, key, StringComparison.OrdinalIgnoreCase))
{
return layout;
}
}
throw new FileNotFoundException($"Layout '{key}' wurde nicht gefunden.");
}
public string LoadJsonByKey(string key)
{
foreach (var path in Directory.GetFiles(_layoutFolder, "*.json"))
{
var layout = Load(path);
if (string.Equals(layout.Key, key, StringComparison.OrdinalIgnoreCase))
{
return File.ReadAllText(path);
}
}
throw new FileNotFoundException($"Layout '{key}' wurde nicht gefunden.");
}
public LabelLayout ValidateJson(string json)
{
return JsonSerializer.Deserialize<LabelLayout>(json, JsonOptions)
?? throw new InvalidOperationException("Layout-JSON ist leer.");
}
public void SaveJson(string json)
{
var layout = ValidateJson(json);
if (string.IsNullOrWhiteSpace(layout.Key))
{
throw new InvalidOperationException("Layout-Key fehlt.");
}
File.WriteAllText(Path.Combine(_layoutFolder, $"{layout.Key}.json"), json);
}
private static LabelLayout Load(string path)
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<LabelLayout>(json, JsonOptions)
?? throw new InvalidOperationException($"Layout '{path}' ist ungültig.");
}
public static void EnsureExampleLayout(string layoutFolder)
{
Directory.CreateDirectory(layoutFolder);
var path = Path.Combine(layoutFolder, "dymo_57x32_standard.json");
if (File.Exists(path))
{
return;
}
File.WriteAllText(path, ExampleLayoutJson);
}
public void Dispose()
{
}
public const string ExamplePayloadJson = """
{
"titel": "Beleg privat",
"beschreibung": "Dokument 2026-000123",
"nummer": "2026-000123",
"datum": "2026-05-07",
"qr": "bjoernprivat 0000123"
}
""";
public const string ExampleLayoutJson = """
{
"name": "Dymo_57x32_Standard",
"key": "dymo_57x32_standard",
"widthMm": 57,
"heightMm": 32,
"dpi": 300,
"marginMm": 3,
"orientation": "landscape",
"elements": [
{
"type": "text",
"xMm": 3,
"yMm": 3,
"widthMm": 36,
"heightMm": 8,
"value": "{titel}",
"fontFamily": "Arial",
"fontSizePt": 10,
"minFontSizePt": 6,
"autoShrink": true,
"bold": true,
"italic": false,
"underline": false,
"horizontalAlign": "left",
"verticalAlign": "top",
"multiline": true
},
{
"type": "text",
"xMm": 3,
"yMm": 12,
"widthMm": 36,
"heightMm": 8,
"value": "{beschreibung}",
"fontFamily": "Arial",
"fontSizePt": 8,
"minFontSizePt": 6,
"autoShrink": true,
"bold": false,
"italic": false,
"underline": false,
"horizontalAlign": "left",
"verticalAlign": "top",
"multiline": true
},
{
"type": "qr",
"xMm": 41,
"yMm": 4,
"sizeMm": 16,
"value": "{qr}"
},
{
"type": "line",
"x1Mm": 3,
"y1Mm": 23,
"x2Mm": 54,
"y2Mm": 23,
"strokeWidthMm": 0.3
},
{
"type": "text",
"xMm": 3,
"yMm": 24,
"widthMm": 51,
"heightMm": 5,
"value": "Nr: {nummer} | {datum:dd.MM.yyyy}",
"fontFamily": "Arial",
"fontSizePt": 7,
"minFontSizePt": 5,
"autoShrink": true,
"bold": false,
"italic": false,
"underline": false,
"horizontalAlign": "center",
"verticalAlign": "middle",
"multiline": false
}
]
}
""";
}
+10
View File
@@ -0,0 +1,10 @@
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;
}
@@ -0,0 +1,9 @@
namespace LabelPrintAgent.Layout;
public sealed class QrCodeElement : LayoutElement
{
public decimal XMm { get; set; }
public decimal YMm { get; set; }
public decimal SizeMm { get; set; }
public string Value { get; set; } = string.Empty;
}
@@ -0,0 +1,11 @@
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 bool Filled { get; set; }
}
+20
View File
@@ -0,0 +1,20 @@
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 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 bool AutoShrink { get; set; } = true;
public bool Bold { get; set; }
public bool Italic { get; set; }
public bool Underline { get; set; }
public string HorizontalAlign { get; set; } = "left";
public string VerticalAlign { get; set; } = "top";
public bool Multiline { get; set; } = true;
}
+19
View File
@@ -0,0 +1,19 @@
using Serilog;
namespace LabelPrintAgent.Logging;
public static class LogSetup
{
public static void Configure(string logFolder)
{
Directory.CreateDirectory(logFolder);
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.File(
Path.Combine(logFolder, "label-print-agent-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
shared: true)
.CreateLogger();
}
}
@@ -0,0 +1,29 @@
using System.Drawing;
using System.Drawing.Printing;
namespace LabelPrintAgent.Printing;
public sealed class PrinterService
{
private readonly WindowsImagePrinter _printer;
public PrinterService(WindowsImagePrinter printer)
{
_printer = printer;
}
public IReadOnlyList<string> GetInstalledPrinters()
{
return PrinterSettings.InstalledPrinters.Cast<string>().OrderBy(name => name).ToList();
}
public void Print(Bitmap bitmap, string printerName, decimal widthMm, decimal heightMm)
{
if (string.IsNullOrWhiteSpace(printerName))
{
throw new InvalidOperationException("Bitte zuerst einen Drucker auswählen.");
}
_printer.Print(bitmap, printerName, widthMm, heightMm);
}
}
@@ -0,0 +1,42 @@
using System.Drawing;
using System.Drawing.Printing;
namespace LabelPrintAgent.Printing;
public sealed class WindowsImagePrinter
{
public void Print(Bitmap bitmap, string printerName, decimal widthMm, decimal heightMm)
{
using var document = new PrintDocument();
document.PrinterSettings.PrinterName = printerName;
if (!document.PrinterSettings.IsValid)
{
throw new InvalidOperationException($"Drucker '{printerName}' ist nicht verfügbar.");
}
document.DocumentName = "LabelPrintAgent Testdruck";
document.DefaultPageSettings.PaperSize = new PaperSize(
"Label 57x32mm",
MmToHundredthsOfInch(widthMm),
MmToHundredthsOfInch(heightMm));
document.DefaultPageSettings.Landscape = widthMm > heightMm;
document.DefaultPageSettings.Margins = new Margins(0, 0, 0, 0);
document.PrintPage += (_, args) =>
{
if (args.Graphics is null)
{
throw new InvalidOperationException("Druckgrafik konnte nicht initialisiert werden.");
}
args.Graphics.DrawImage(bitmap, args.PageBounds);
args.HasMorePages = false;
};
document.Print();
}
private static int MmToHundredthsOfInch(decimal mm)
{
return (int)Math.Round((double)mm / 25.4d * 100d);
}
}
@@ -0,0 +1,165 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Drawing.Text;
using System.Text.Json;
using LabelPrintAgent.Layout;
using QRCoder;
namespace LabelPrintAgent.Rendering;
public sealed class LabelRenderer : IDisposable
{
public RenderResult Render(LabelLayout layout, string payloadJson)
{
using var payload = JsonDocument.Parse(payloadJson);
var width = MmToPixels(layout.WidthMm, layout.Dpi);
var height = MmToPixels(layout.HeightMm, layout.Dpi);
var bitmap = new Bitmap(width, height, 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.RootElement, layout.Dpi);
break;
case LineElement line:
DrawLine(graphics, line, layout.Dpi);
break;
case RectangleElement rectangle:
DrawRectangle(graphics, rectangle, layout.Dpi);
break;
case QrCodeElement qr:
DrawQr(graphics, qr, payload.RootElement, layout.Dpi);
break;
}
}
return new RenderResult(bitmap);
}
private static void DrawText(Graphics graphics, TextElement element, JsonElement payload, int dpi)
{
var text = TemplateFormatter.Format(element.Value, payload);
var bounds = new RectangleF(
MmToPixelsF(element.XMm, dpi),
MmToPixelsF(element.YMm, dpi),
MmToPixelsF(element.WidthMm, dpi),
MmToPixelsF(element.HeightMm, dpi));
var style = FontStyle.Regular;
if (element.Bold) style |= FontStyle.Bold;
if (element.Italic) style |= FontStyle.Italic;
if (element.Underline) style |= FontStyle.Underline;
using var format = BuildStringFormat(element);
var fontSize = element.FontSizePt;
Font? font = null;
try
{
while (true)
{
font?.Dispose();
font = new Font(element.FontFamily, fontSize, style, GraphicsUnit.Point);
var measured = graphics.MeasureString(text, font, bounds.Size, format);
if (!element.AutoShrink || fontSize <= element.MinFontSizePt || (measured.Width <= bounds.Width && measured.Height <= bounds.Height))
{
break;
}
fontSize -= 0.5f;
}
using var brush = new SolidBrush(Color.Black);
graphics.DrawString(text, font, brush, bounds, format);
}
finally
{
font?.Dispose();
}
}
private static StringFormat BuildStringFormat(TextElement element)
{
var format = new StringFormat
{
Trimming = StringTrimming.EllipsisCharacter,
FormatFlags = element.Multiline ? 0 : StringFormatFlags.NoWrap
};
format.Alignment = element.HorizontalAlign.ToLowerInvariant() switch
{
"center" => StringAlignment.Center,
"right" => StringAlignment.Far,
_ => StringAlignment.Near
};
format.LineAlignment = element.VerticalAlign.ToLowerInvariant() switch
{
"middle" => StringAlignment.Center,
"bottom" => StringAlignment.Far,
_ => StringAlignment.Near
};
return format;
}
private static void DrawLine(Graphics graphics, LineElement element, int dpi)
{
using var pen = new Pen(Color.Black, Math.Max(1, MmToPixelsF(element.StrokeWidthMm, dpi)));
graphics.DrawLine(
pen,
MmToPixelsF(element.X1Mm, dpi),
MmToPixelsF(element.Y1Mm, dpi),
MmToPixelsF(element.X2Mm, dpi),
MmToPixelsF(element.Y2Mm, dpi));
}
private static void DrawRectangle(Graphics graphics, RectangleElement element, int dpi)
{
var rect = new RectangleF(
MmToPixelsF(element.XMm, dpi),
MmToPixelsF(element.YMm, dpi),
MmToPixelsF(element.WidthMm, dpi),
MmToPixelsF(element.HeightMm, dpi));
if (element.Filled)
{
using var brush = new SolidBrush(Color.Black);
graphics.FillRectangle(brush, rect);
return;
}
using var pen = new Pen(Color.Black, Math.Max(1, MmToPixelsF(element.StrokeWidthMm, dpi)));
graphics.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height);
}
private static void DrawQr(Graphics graphics, QrCodeElement element, JsonElement payload, int dpi)
{
var text = TemplateFormatter.Format(element.Value, payload);
using var generator = new QRCodeGenerator();
using var qrData = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.Q);
using var qrCode = new QRCode(qrData);
using var qrBitmap = qrCode.GetGraphic(12, Color.Black, Color.White, drawQuietZones: true);
graphics.DrawImage(
qrBitmap,
MmToPixelsF(element.XMm, dpi),
MmToPixelsF(element.YMm, dpi),
MmToPixelsF(element.SizeMm, dpi),
MmToPixelsF(element.SizeMm, dpi));
}
private static int MmToPixels(decimal mm, int dpi) => (int)Math.Round((double)mm / 25.4d * dpi);
private static float MmToPixelsF(decimal mm, int dpi) => (float)((double)mm / 25.4d * dpi);
public void Dispose()
{
}
}
@@ -0,0 +1,18 @@
using System.Drawing;
namespace LabelPrintAgent.Rendering;
public sealed class RenderResult : IDisposable
{
public RenderResult(Bitmap bitmap)
{
Bitmap = bitmap;
}
public Bitmap Bitmap { get; }
public void Dispose()
{
Bitmap.Dispose();
}
}
@@ -0,0 +1,47 @@
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, JsonElement payload)
{
return PlaceholderRegex().Replace(template, match =>
{
var field = match.Groups["field"].Value;
var format = match.Groups["format"].Success ? match.Groups["format"].Value : null;
if (!payload.TryGetProperty(field, out var value))
{
return string.Empty;
}
var typedValue = ConvertJsonValue(value);
if (format is null)
{
return Convert.ToString(typedValue, CultureInfo.CurrentCulture) ?? string.Empty;
}
return string.Format(CultureInfo.CurrentCulture, "{0:" + format + "}", typedValue);
});
}
private static object? ConvertJsonValue(JsonElement value)
{
return value.ValueKind switch
{
JsonValueKind.String when DateTime.TryParse(value.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var date) => date,
JsonValueKind.String => 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,
_ => value.ToString()
};
}
[GeneratedRegex(@"\{(?<field>[A-Za-z0-9_]+)(:(?<format>[^}]+))?\}")]
private static partial Regex PlaceholderRegex();
}
@@ -0,0 +1,126 @@
using LabelPrintAgent.Configuration;
using LabelPrintAgent.Database;
using LabelPrintAgent.Layout;
using LabelPrintAgent.Printing;
using LabelPrintAgent.Rendering;
using Serilog;
namespace LabelPrintAgent.Worker;
public sealed class PrintJobWorker : IDisposable
{
private readonly SettingsStore _settingsStore;
private readonly LayoutStore _layoutStore;
private readonly LabelRenderer _renderer;
private readonly PrinterService _printerService;
private readonly MySqlLabelRepository _repository;
private readonly SemaphoreSlim _semaphore = new(1, 1);
private CancellationTokenSource? _cancellationTokenSource;
private Task? _task;
public PrintJobWorker(
SettingsStore settingsStore,
LayoutStore layoutStore,
LabelRenderer renderer,
PrinterService printerService,
MySqlLabelRepository repository)
{
_settingsStore = settingsStore;
_layoutStore = layoutStore;
_renderer = renderer;
_printerService = printerService;
_repository = repository;
}
public void Start()
{
if (_task is not null)
{
return;
}
_cancellationTokenSource = new CancellationTokenSource();
_task = Task.Run(() => RunAsync(_cancellationTokenSource.Token));
}
private async Task RunAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
await PollOnceAsync(cancellationToken);
}
catch (Exception ex)
{
Log.Error(ex, "Print worker poll failed");
}
var settings = _settingsStore.Load();
await Task.Delay(TimeSpan.FromSeconds(Math.Max(1, settings.Worker.PollIntervalSeconds)), cancellationToken);
}
}
public async Task PollOnceAsync(CancellationToken cancellationToken = default)
{
if (!await _semaphore.WaitAsync(0, cancellationToken))
{
return;
}
try
{
var settings = _settingsStore.Load();
if (string.IsNullOrWhiteSpace(settings.Database.Host) || string.IsNullOrWhiteSpace(settings.Printer.PrinterName))
{
return;
}
var jobs = await _repository.LoadPendingJobsAsync(5, cancellationToken);
foreach (var job in jobs)
{
await ProcessJobAsync(job, settings, cancellationToken);
}
}
finally
{
_semaphore.Release();
}
}
private async Task ProcessJobAsync(PrintJob job, AppSettings settings, CancellationToken cancellationToken)
{
try
{
if (!await _repository.MarkPrintingAsync(job.Id, cancellationToken))
{
return;
}
var layout = _layoutStore.LoadByKey(job.LayoutKey);
using var result = _renderer.Render(layout, job.PayloadJson);
_printerService.Print(result.Bitmap, settings.Printer.PrinterName, layout.WidthMm, layout.HeightMm);
await _repository.MarkPrintedAsync(job.Id, settings.Printer.PrinterName, cancellationToken);
}
catch (Exception ex)
{
Log.Error(ex, "Could not print job {JobId}", job.Id);
await _repository.MarkErrorAsync(job.Id, ex.Message, cancellationToken);
}
}
public void Dispose()
{
_cancellationTokenSource?.Cancel();
try
{
_task?.Wait(TimeSpan.FromSeconds(2));
}
catch
{
}
_cancellationTokenSource?.Dispose();
_semaphore.Dispose();
}
}
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="LabelPrintAgent.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>