Implement stage 1 tray app scaffold

This commit is contained in:
2026-05-07 13:39:29 +02:00
parent 7f3592682c
commit 412afa3ad3
14 changed files with 77 additions and 846 deletions
+26 -34
View File
@@ -1,52 +1,44 @@
# LabelPrintAgent
Windows-Tray-Anwendung zum Rendern und Drucken von Etiketten aus JSON-Layouts.
Windows-Tray-Anwendung für den späteren Etikettendruck aus JSON-Layouts.
## Stand
## Etappe 1
Der erste lauffähige Schritt enthält:
Dieser Stand ist ein lauffähiges Grundgerüst:
- .NET-9-Windows-Forms-Projekt mit Tray-Icon
- Einstellungsdialog mit Tabs für Allgemein, Datenbank, Drucker, Layout und Fehlerjobs
- .NET-9-Windows-Forms-Projekt
- Tray-Icon mit Kontextmenü
- Einstellungsdialog mit Tabs:
- Allgemein
- Datenbank
- Drucker
- Layout
- Fehlerhafte Druckaufträge
- lokale Konfiguration unter `C:\ProgramData\LabelPrintAgent\settings.json`
- automatische Anlage von `C:\ProgramData\LabelPrintAgent`
- Layout-Ordner unter `C:\ProgramData\LabelPrintAgent\layouts`
- Log-Ordner unter `C:\ProgramData\LabelPrintAgent\logs`
- verschlüsselte Passwortspeicherung per Windows DPAPI
- Druckerauswahl über installierte Windows-Drucker
- Layout-JSON unter `C:\ProgramData\LabelPrintAgent\layouts`
- Auflistung installierter Windows-Drucker
- 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
- Layout-JSON laden, validieren und speichern
## Öffnen und Starten
Noch nicht enthalten sind MySQL-Worker, echtes Drucken, QR-Code-Rendering und die vollständige Rendering-Engine.
## Startanleitung
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`.
3. Das Tray-Symbol anklicken oder per Kontextmenü `Einstellungen` öffnen.
4. Im Tab `Drucker` einen installierten Windows-Drucker auswählen und speichern.
5. Im Tab `Layout` das Beispiel-Layout prüfen, bearbeiten und speichern.
## Datenbank
Beim ersten Start werden die Programmdatenordner und das Beispiel-Layout automatisch angelegt.
Die Tabelle kann mit folgendem Skript angelegt werden:
## Spätere Etappen
```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`:
Die SQL-Datei für die spätere Druckwarteschlange liegt bereits unter:
```text
{titel}
{nummer}
{datum:dd.MM.yyyy}
{menge:0.00}
sql/create_label_print_queue.sql
```
Formatangaben werden über C#-Formatstrings ausgewertet.
+2 -9
View File
@@ -1,10 +1,7 @@
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;
@@ -26,13 +23,9 @@ internal static class Program
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();
var printerService = new PrinterService();
Application.Run(new TrayApplicationContext(settingsStore, layoutStore, renderer, printerService, repository, worker));
Application.Run(new TrayApplicationContext(settingsStore, layoutStore, printerService));
}
catch (Exception ex)
{
+47 -182
View File
@@ -1,10 +1,7 @@
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;
@@ -17,10 +14,7 @@ internal sealed class SettingsForm : Form
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 };
@@ -37,8 +31,15 @@ internal sealed class SettingsForm : Form
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 TextBox _layoutJson = new()
{
Multiline = true,
ScrollBars = ScrollBars.Both,
WordWrap = false,
Font = new Font(FontFamily.GenericMonospace, 9),
Width = 900,
Height = 460
};
private readonly DataGridView _errorJobs = new()
{
@@ -47,28 +48,18 @@ internal sealed class SettingsForm : Form
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)
public SettingsForm(SettingsStore settingsStore, LayoutStore layoutStore, PrinterService printerService)
{
_settingsStore = settingsStore;
_layoutStore = layoutStore;
_renderer = renderer;
_printerService = printerService;
_repository = repository;
_worker = worker;
Text = "LabelPrintAgent Einstellungen";
Width = 1120;
Height = 760;
Width = 1040;
Height = 720;
StartPosition = FormStartPosition.CenterScreen;
var tabs = new TabControl { Dock = DockStyle.Fill };
@@ -79,12 +70,12 @@ internal sealed class SettingsForm : Form
tabs.TabPages.Add(CreateErrorJobsTab());
Controls.Add(tabs);
Load += async (_, _) =>
Load += (_, _) =>
{
LoadSettingsIntoUi();
LoadPrinters();
LoadLayouts();
await RefreshErrorJobsAsync();
LoadEmptyErrorJobsTable();
};
}
@@ -106,8 +97,8 @@ internal sealed class SettingsForm : Form
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()));
panel.Controls.Add(ButtonAt("Speichern", 140, 230, (_, _) => SaveDatabase()));
panel.Controls.Add(LabelAt("Verbindungstest folgt in Etappe 2.", 140, 276, 340));
return new TabPage("Datenbank") { Controls = { panel } };
}
@@ -118,10 +109,8 @@ internal sealed class SettingsForm : Form
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;
panel.Controls.Add(ButtonAt("Speichern", 300, 150, (_, _) => SavePrinter()));
panel.Controls.Add(LabelAt("Testdruck folgt in Etappe 2.", 140, 198, 340));
return new TabPage("Drucker") { Controls = { panel } };
}
@@ -133,26 +122,19 @@ internal sealed class SettingsForm : Form
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()));
panel.Controls.Add(ButtonAt("Validieren", 20, 540, (_, _) => ValidateLayout()));
panel.Controls.Add(ButtonAt("Speichern", 130, 540, (_, _) => SaveLayout()));
panel.Controls.Add(LabelAt("Vorschau und Rendering folgen in Etappe 2.", 250, 546, 360));
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()));
var footer = new Panel { Dock = DockStyle.Bottom, Height = 48, Padding = new Padding(12) };
footer.Controls.Add(LabelAt("Fehlerhafte Jobs werden angezeigt, sobald der MySQL-Worker umgesetzt ist.", 12, 14, 600));
page.Controls.Add(_errorJobs);
page.Controls.Add(panel);
page.Controls.Add(footer);
return page;
}
@@ -219,7 +201,6 @@ internal sealed class SettingsForm : Form
try
{
_layoutJson.Text = _layoutStore.LoadJsonByKey(key);
RenderPreview();
}
catch (Exception ex)
{
@@ -227,6 +208,14 @@ internal sealed class SettingsForm : Form
}
}
private void LoadEmptyErrorJobsTable()
{
_errorJobs.DataSource = new[]
{
new ErrorJobRow(0, string.Empty, string.Empty, 0)
};
}
private void SaveGeneral()
{
var settings = _settingsStore.Load();
@@ -248,20 +237,6 @@ internal sealed class SettingsForm : Form
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();
@@ -269,6 +244,7 @@ internal sealed class SettingsForm : Form
settings.Printer.LabelWidthMm = _labelWidth.Value;
settings.Printer.LabelHeightMm = _labelHeight.Value;
_settingsStore.Save(settings);
MessageBox.Show("Druckereinstellungen gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
}
private void ValidateLayout()
@@ -298,115 +274,6 @@ internal sealed class SettingsForm : Form
}
}
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);
@@ -430,6 +297,19 @@ internal sealed class SettingsForm : Form
private static Label Label(string text, int width) => new() { Text = text, Width = width, TextAlign = ContentAlignment.MiddleLeft };
private static Label LabelAt(string text, int left, int top, int width)
{
return new Label
{
Text = text,
Left = left,
Top = top,
Width = width,
Height = 24,
TextAlign = ContentAlignment.MiddleLeft
};
}
private static FlowLayoutPanel Row(int top, params Control[] controls)
{
var row = new FlowLayoutPanel
@@ -458,20 +338,5 @@ internal sealed class SettingsForm : Form
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;
}
private sealed record ErrorJobRow(long Id, string Layout, string Fehler, int Versuche);
}
@@ -1,9 +1,6 @@
using LabelPrintAgent.Configuration;
using LabelPrintAgent.Database;
using LabelPrintAgent.Layout;
using LabelPrintAgent.Printing;
using LabelPrintAgent.Rendering;
using LabelPrintAgent.Worker;
using Serilog;
namespace LabelPrintAgent.App;
@@ -13,26 +10,17 @@ 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)
PrinterService printerService)
{
_settingsStore = settingsStore;
_layoutStore = layoutStore;
_renderer = renderer;
_printerService = printerService;
_repository = repository;
_worker = worker;
var menu = new ContextMenuStrip();
menu.Items.Add("Einstellungen", null, (_, _) => ShowSettings());
@@ -60,7 +48,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
{
if (_settingsForm is null || _settingsForm.IsDisposed)
{
_settingsForm = new SettingsForm(_settingsStore, _layoutStore, _renderer, _printerService, _repository, _worker);
_settingsForm = new SettingsForm(_settingsStore, _layoutStore, _printerService);
}
_settingsForm.Show();
@@ -1,10 +0,0 @@
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;
}
@@ -1,162 +0,0 @@
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
@@ -1,15 +0,0 @@
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; }
}
@@ -10,11 +10,7 @@
</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>
@@ -1,29 +1,11 @@
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);
}
}
@@ -1,42 +0,0 @@
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);
}
}
@@ -1,165 +0,0 @@
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()
{
}
}
@@ -1,18 +0,0 @@
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();
}
}
@@ -1,47 +0,0 @@
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();
}
@@ -1,126 +0,0 @@
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();
}
}