QuestPDF-Vorlagensystem und Designer hinzufügen
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-08-30 15:37:22 +02:00
parent a088823b30
commit 2f2761caf8
40 changed files with 2177 additions and 738 deletions
@@ -0,0 +1,321 @@
using System.Collections.ObjectModel;
using System.Globalization;
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Templating;
namespace LehrerApp.TemplateDesigner;
public partial class DesignerViewModel : ObservableObject
{
[ObservableProperty] private string _templateId = "elternbrief-standard";
[ObservableProperty] private string _templateName = "Elternbrief Standard";
[ObservableProperty] private string _description = "Briefvorlage mit Schul-Briefkopf";
[ObservableProperty] private decimal _pageWidth = 210;
[ObservableProperty] private decimal _pageHeight = 297;
[ObservableProperty] private string _unit = "mm";
[ObservableProperty] private string _layoutSource = DefaultLayout;
[ObservableProperty] private string _newElementType = "TEXT";
[ObservableProperty] private string _newX = "20";
[ObservableProperty] private string _newY = "50";
[ObservableProperty] private string _newWidth = "170";
[ObservableProperty] private string _newHeight = "30";
[ObservableProperty] private string _newImageScale = "100";
[ObservableProperty] private string _newContent = "$Brieftext";
[ObservableProperty] private string _newAttributes = "size=11";
[ObservableProperty] private Bitmap? _previewImage;
[ObservableProperty] private string _status = "Bereit.";
[ObservableProperty] private string _statusColor = "#475569";
[ObservableProperty] private bool _canExport;
[ObservableProperty] private DesignerPlaceholder? _selectedPlaceholder;
[ObservableProperty] private DesignerAsset? _selectedAsset;
[ObservableProperty] private StarterTemplateItem? _selectedStarterTemplate;
public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"];
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
public IReadOnlyList<string> ElementTypes { get; } = ["TEXT", "TEXTBOX", "IMG", "TABLE", "CHART"];
public ObservableCollection<DesignerPlaceholder> Placeholders { get; } =
[
new("Datum", PlaceholderType.Date, true, DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
new("Empfaenger", PlaceholderType.Text, true, "Familie Beispiel"),
new("Anrede", PlaceholderType.Text, true, "Frau Beispiel"),
new("Brieftext", PlaceholderType.Multiline, true, "hiermit informieren wir Sie über einen wichtigen Termin.\n\nMit freundlichen Grüßen"),
new("LehrerName", PlaceholderType.Text, true, "M. Mustermann"),
];
public Dictionary<string, byte[]> Assets { get; } = new(StringComparer.OrdinalIgnoreCase);
public ObservableCollection<DesignerAsset> AssetItems { get; } = [];
public ObservableCollection<StarterTemplateItem> StarterTemplates { get; } = [];
public void Reset()
{
TemplateId = "neue-vorlage"; TemplateName = "Neue Vorlage"; Description = "";
PageWidth = 210; PageHeight = 297; Unit = "mm"; LayoutSource = "PAGE 210 297 mm\n";
Placeholders.Clear(); Assets.Clear(); AssetItems.Clear(); SelectedAsset = null;
PreviewImage = null; CanExport = false;
SetStatus("Neues Projekt angelegt.", false);
}
public TemplateManifest BuildManifest() => new()
{
SchemaVersion = TemplateLoader.CurrentSchemaVersion,
Id = TemplateId.Trim(), Name = TemplateName.Trim(), Description = Description.Trim(),
PageSize = new((float)PageWidth, (float)PageHeight, Unit), LayoutFile = "layout.tpl",
Placeholders = Placeholders.Select(x => new PlaceholderDefinition(x.Name.Trim(), x.Type, x.Required)).ToList(),
};
public LoadedTemplate BuildLoaded()
{
var manifest = BuildManifest();
if (string.IsNullOrWhiteSpace(manifest.Id) || manifest.Id.Any(c => !(char.IsAsciiLetterOrDigit(c) || c == '-')))
throw new InvalidDataException("Die ID darf nur ASCII-Buchstaben, Ziffern und Bindestriche enthalten.");
if (string.IsNullOrWhiteSpace(manifest.Name)) throw new InvalidDataException("Der Vorlagenname fehlt.");
var layout = new LayoutParser().Parse(LayoutSource);
var issues = new List<ValidationIssue>();
var declared = manifest.Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal);
foreach (var used in TemplateLoader.UsedPlaceholders(layout).Where(x => !declared.Contains(x)))
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{used}“ ist nicht deklariert."));
foreach (var path in layout.Elements.Select(x => x switch { BackgroundElement b => b.Path, ImageElement i => i.Path, _ => null }).Where(x => x is not null))
if (!Assets.ContainsKey(path!)) issues.Add(new(ValidationSeverity.Error, $"Asset „{path}“ fehlt."));
if (issues.Count > 0) throw new TemplateValidationException(new(issues));
return new(manifest, layout, new Dictionary<string, byte[]>(Assets));
}
public ITemplateDataProvider BuildDataProvider() => new DesignerDataProvider(Placeholders.ToDictionary(
x => x.Name.Trim(), x => x.ToValue(), StringComparer.Ordinal));
public void Load(LoadedTemplate template, string layoutSource)
{
TemplateId = template.Manifest.Id; TemplateName = template.Manifest.Name; Description = template.Manifest.Description;
PageWidth = (decimal)template.Manifest.PageSize.Width; PageHeight = (decimal)template.Manifest.PageSize.Height;
Unit = template.Manifest.PageSize.Unit; LayoutSource = layoutSource;
Placeholders.Clear();
foreach (var placeholder in template.Manifest.Placeholders)
Placeholders.Add(new(placeholder.Name, placeholder.Type, placeholder.Required, DesignerPlaceholder.SampleFor(placeholder.Type)));
Assets.Clear(); AssetItems.Clear();
foreach (var asset in template.Assets) AddOrReplaceAsset(asset.Key, asset.Value, keepName: true);
RefreshAssetUsage();
CanExport = true; SetStatus($"„{template.Manifest.Name}“ geladen.", false);
}
public void LoadAsNewProject(LoadedTemplate template, string layoutSource, string? newId = null)
{
Load(template, layoutSource);
TemplateId = newId ?? template.Manifest.Id + "-neu";
TemplateName = template.Manifest.Name + " - Neu";
CanExport = false;
SetStatus($"Neues unabhängiges Projekt aus „{template.Manifest.Name}“ erstellt. Bitte ID und Namen prüfen.", false);
}
public void PreviewTemplate(LoadedTemplate template)
{
var provider = BuildSampleDataProvider(template.Manifest);
var bytes = new QuestTemplateRenderer().RenderFirstPageToPng(template, provider);
using var stream = new MemoryStream(bytes);
PreviewImage?.Dispose(); PreviewImage = new Bitmap(stream);
SetStatus($"Vorschau von „{template.Manifest.Name}“.", false);
}
public void SetStarterTemplates(IEnumerable<StarterTemplateItem> templates, string? selectedId = null)
{
StarterTemplates.Clear();
foreach (var template in templates) StarterTemplates.Add(template);
SelectedStarterTemplate = StarterTemplates.FirstOrDefault(x => x.Id.Equals(selectedId, StringComparison.OrdinalIgnoreCase))
?? StarterTemplates.FirstOrDefault();
}
private static ITemplateDataProvider BuildSampleDataProvider(TemplateManifest manifest) =>
new DesignerDataProvider(manifest.Placeholders.ToDictionary(x => x.Name,
x => new DesignerPlaceholder(x.Name, x.Type, x.Required, DesignerPlaceholder.SampleFor(x.Type)).ToValue(),
StringComparer.Ordinal));
public void AddElement()
{
var attrs = string.IsNullOrWhiteSpace(NewAttributes) ? "" : " " + NewAttributes.Trim();
var line = NewElementType switch
{
"TEXT" => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}",
"TEXTBOX" => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
"IMG" => $"IMG {NewContent} {NewX} {NewY} {NewWidth} {NewHeight} scale={NormalizedImageScale()}%",
"TABLE" => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
"CHART" => $"CHART {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
_ => throw new InvalidOperationException("Unbekannter Elementtyp."),
};
LayoutSource = LayoutSource.TrimEnd() + Environment.NewLine + line + Environment.NewLine;
CanExport = false; SetStatus("Element ergänzt. Vorschau zur Prüfung aktualisieren.", false);
}
public DesignerAsset ImportAsset(string sourceName, byte[] bytes) => AddOrReplaceAsset(sourceName, bytes, keepName: false);
public DesignerAsset ImportBackground(string sourceName, byte[] bytes)
{
var asset = AddOrReplaceAsset(sourceName, bytes, keepName: false);
var lines = LayoutSource.Split('\n').Where(x => !IsAssetStatement(x, "BG", null)).ToList();
var page = lines.FindIndex(x => x.TrimStart().StartsWith("PAGE ", StringComparison.OrdinalIgnoreCase));
lines.Insert(Math.Max(0, page + 1), $"BG {asset.Name}");
LayoutSource = string.Join('\n', lines);
SelectedAsset = asset; CanExport = false;
SetStatus($"Hintergrund „{asset.Name}“ importiert. Vorschau aktualisieren.", false);
return asset;
}
public void InsertSelectedAssetAsImage()
{
if (SelectedAsset is null) throw new InvalidOperationException("Bitte zuerst ein Asset auswählen.");
NewElementType = "IMG"; NewContent = SelectedAsset.Name; NewAttributes = "";
AddElement();
SetStatus($"„{SelectedAsset.Name}“ wurde als IMG-Element eingefügt. Vorschau aktualisieren.", false);
}
public void ReplaceSelectedAsset(byte[] bytes)
{
if (SelectedAsset is null) throw new InvalidOperationException("Bitte zuerst ein Asset auswählen.");
var name = SelectedAsset.Name;
AddOrReplaceAsset(name, bytes, keepName: true);
SelectedAsset = AssetItems.First(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
CanExport = false; SetStatus($"Asset „{name}“ wurde ersetzt. Alle Referenzen bleiben erhalten.", false);
}
public void RemoveSelectedAsset()
{
if (SelectedAsset is null) throw new InvalidOperationException("Bitte zuerst ein Asset auswählen.");
var selected = SelectedAsset;
var lines = LayoutSource.Split('\n').ToList();
var removedReferences = lines.RemoveAll(line => IsAssetStatement(line, "BG", selected.Name)
|| IsAssetStatement(line, "IMG", selected.Name));
LayoutSource = string.Join('\n', lines);
Assets.Remove(selected.Name); AssetItems.Remove(selected); SelectedAsset = null;
CanExport = false;
SetStatus(removedReferences == 0
? $"Asset „{selected.Name}“ wurde entfernt."
: $"Asset „{selected.Name}“ und {removedReferences} Layout-Referenz(en) wurden entfernt.", false);
}
partial void OnLayoutSourceChanged(string value) => RefreshAssetUsage();
private DesignerAsset AddOrReplaceAsset(string sourceName, byte[] bytes, bool keepName)
{
var safeName = SafeAssetName(sourceName);
if (!keepName) safeName = UniqueAssetName(safeName);
var metadata = ImageInspector.Inspect(bytes);
Assets[safeName] = bytes;
var existing = AssetItems.FirstOrDefault(x => x.Name.Equals(safeName, StringComparison.OrdinalIgnoreCase));
if (existing is not null) AssetItems.Remove(existing);
var item = new DesignerAsset(safeName, metadata.Width, metadata.Height, bytes.LongLength);
AssetItems.Add(item); RefreshAssetUsage(); SelectedAsset = item;
return item;
}
private string UniqueAssetName(string name)
{
if (!Assets.ContainsKey(name)) return name;
var extension = Path.GetExtension(name); var stem = Path.GetFileNameWithoutExtension(name);
for (var index = 2; ; index++)
{
var candidate = $"{stem}-{index}{extension}";
if (!Assets.ContainsKey(candidate)) return candidate;
}
}
private static string SafeAssetName(string sourceName)
{
var fileName = Path.GetFileName(sourceName);
var safe = string.Concat(fileName.Select(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_' ? c : '-'));
if (string.IsNullOrWhiteSpace(safe)) throw new InvalidDataException("Der Asset-Dateiname ist ungültig.");
return safe;
}
private void RefreshAssetUsage()
{
foreach (var asset in AssetItems)
{
var count = LayoutSource.Split('\n').Count(line => IsAssetStatement(line, "BG", asset.Name)
|| IsAssetStatement(line, "IMG", asset.Name));
asset.SetUsage(count);
}
}
private static bool IsAssetStatement(string line, string keyword, string? name)
{
var tokens = line.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
return tokens.Length >= 2 && tokens[0].Equals(keyword, StringComparison.OrdinalIgnoreCase)
&& (name is null || tokens[1].Equals(name, StringComparison.OrdinalIgnoreCase));
}
private string NormalizedImageScale()
{
var raw = NewImageScale.Trim().TrimEnd('%').Replace(',', '.');
if (!float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
&& !float.TryParse(raw, NumberStyles.Float, CultureInfo.CurrentCulture, out value))
throw new InvalidDataException("Die IMG-Skalierung muss eine Zahl sein.");
if (value <= 0 || value > 1000)
throw new InvalidDataException("Die IMG-Skalierung muss zwischen 0 und 1000 Prozent liegen.");
return value.ToString("0.###", CultureInfo.InvariantCulture);
}
public void SetStatus(string text, bool error)
{ Status = text; StatusColor = error ? "#B91C1C" : "#166534"; }
private static string QuoteIfLiteral(string value) => value.StartsWith('$') ? value : $"\"{value.Replace("\"", "\\\"")}\"";
private const string DefaultLayout = """
# Elternbrief Standard
PAGE 210 297 mm
TEXT 20 25 "Elternbrief" size=18 bold=true color=#1E3A8A
TEXT 20 43 $Datum|dd.MM.yyyy size=10
TEXT 20 55 $Empfaenger size=11
TEXT 20 75 "Sehr geehrte/r $Anrede," size=11
TEXTBOX 20 90 170 155 $Brieftext size=11 wrap=true
TEXT 20 265 $LehrerName size=10 italic=true
""";
}
public partial class DesignerAsset(string name, int pixelWidth, int pixelHeight, long byteCount) : ObservableObject
{
[ObservableProperty] private int _usageCount;
public string Name { get; } = name;
public int PixelWidth { get; } = pixelWidth;
public int PixelHeight { get; } = pixelHeight;
public long ByteCount { get; } = byteCount;
public string Dimensions => $"{PixelWidth} × {PixelHeight} px";
public string FileSize => ByteCount >= 1024 * 1024
? $"{ByteCount / (1024d * 1024d):0.0} MB"
: $"{Math.Max(1, ByteCount / 1024d):0} KB";
public string Usage => UsageCount == 0 ? "nicht verwendet" : UsageCount == 1 ? "1 Referenz" : $"{UsageCount} Referenzen";
public void SetUsage(int count) { UsageCount = count; OnPropertyChanged(nameof(Usage)); }
}
public partial class DesignerPlaceholder : ObservableObject
{
[ObservableProperty] private string _name;
[ObservableProperty] private PlaceholderType _type;
[ObservableProperty] private bool _required;
[ObservableProperty] private string _sample;
public DesignerPlaceholder(string name, PlaceholderType type, bool required, string sample)
{ _name = name; _type = type; _required = required; _sample = sample; }
public PlaceholderValue ToValue() => Type switch
{
PlaceholderType.Text => new TextValue(Sample),
PlaceholderType.Multiline => new MultilineValue(Sample),
PlaceholderType.Date => new DateValue(DateOnly.TryParse(Sample, CultureInfo.InvariantCulture, out var date) ? date : DateOnly.FromDateTime(DateTime.Today)),
PlaceholderType.Number => new NumberValue(decimal.TryParse(Sample, NumberStyles.Number, CultureInfo.InvariantCulture, out var number) ? number : 0),
PlaceholderType.Image => new ImageValue([], "image/png"),
PlaceholderType.Table => ParseTable(Sample),
PlaceholderType.Chart => ParseChart(Sample),
_ => new TextValue(Sample),
};
public static string SampleFor(PlaceholderType type) => type switch
{ PlaceholderType.Date => DateTime.Today.ToString("yyyy-MM-dd"), PlaceholderType.Number => "42,5",
PlaceholderType.Table => "Datum;Grund|01.09.;Krank", PlaceholderType.Chart => "Sep:2;Okt:3;Nov:1", _ => "Beispielwert" };
private static TableValue ParseTable(string value)
{
var lines = value.Split('|', StringSplitOptions.RemoveEmptyEntries);
var columns = (lines.FirstOrDefault() ?? "Spalte").Split(';');
return new(columns, lines.Skip(1).Select(x => (IReadOnlyList<string>)x.Split(';')).ToList());
}
private static ChartValue ParseChart(string value) => new([new("Werte", value.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Select((x, i) => { var parts = x.Split(':', 2); return new ChartPoint(parts[0], parts.Length == 2 && decimal.TryParse(parts[1], CultureInfo.InvariantCulture, out var y) ? y : i + 1); }).ToList())]);
}
internal sealed class DesignerDataProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }