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
@@ -1,316 +0,0 @@
using System.IO.Compression;
using System.Text.Json;
using System.Xml.Linq;
namespace LehrerApp.Core.Services;
public enum TemplateIssueSeverity
{
Warning,
StrongWarning,
Error,
}
public sealed record LetterPlaceholder(string Tag, string Description);
public sealed record TemplateValidationIssue(
TemplateIssueSeverity Severity,
string Message,
string? Tag = null,
string? SuggestedTag = null);
public sealed record TemplateValidationResult(
IReadOnlyList<string> Tags,
IReadOnlyList<TemplateValidationIssue> Issues)
{
public bool CanGenerate => Issues.All(i => i.Severity != TemplateIssueSeverity.Error);
public bool HasStrongWarnings => Issues.Any(i => i.Severity == TemplateIssueSeverity.StrongWarning);
}
public sealed class LetterTemplateInfo
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
public string StoredFileName { get; set; } = "";
public string OriginalFileName { get; set; } = "";
public DateTime ImportedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Verwaltet DOCX-Briefvorlagen und befüllt Word-Inhaltssteuerelemente anhand ihres Tags.
/// Die Originalvorlage wird nie verändert.
/// </summary>
public sealed class LetterTemplateService
{
private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
public static IReadOnlyList<LetterPlaceholder> SupportedPlaceholders { get; } =
[
new("Student.FirstName", "Vorname des Schülers"),
new("Student.LastName", "Nachname des Schülers"),
new("Contact.Name", "Name des ausgewählten Kontakts"),
new("Contact.Address", "Vollständige Anschrift (mehrzeilig)"),
new("Contact.Street", "Straße und Hausnummer"),
new("Contact.PostalCode", "Postleitzahl"),
new("Contact.City", "Ort"),
new("Letter.Salutation", "Gespeicherte Briefanrede des Kontakts"),
new("Group.Name", "Ausgewählte Lerngruppe"),
new("SchoolYear", "Schuljahr der ausgewählten Gruppe"),
new("CurrentDate", "Gewähltes Briefdatum"),
];
private readonly string _templateDirectory;
private readonly string _indexPath;
public LetterTemplateService(string appDataPath)
{
_templateDirectory = Path.Combine(appDataPath, "letter-templates");
_indexPath = Path.Combine(_templateDirectory, "templates.json");
Directory.CreateDirectory(_templateDirectory);
}
public IReadOnlyList<LetterTemplateInfo> GetTemplates() => LoadIndex()
.OrderBy(t => t.Name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
public string GetTemplatePath(LetterTemplateInfo template) =>
Path.Combine(_templateDirectory, template.StoredFileName);
public LetterTemplateInfo Import(string sourcePath, string? displayName = null)
{
var validation = Validate(sourcePath);
if (!validation.CanGenerate)
throw new InvalidDataException(validation.Issues.First(i => i.Severity == TemplateIssueSeverity.Error).Message);
var templates = LoadIndex();
var info = new LetterTemplateInfo
{
Name = string.IsNullOrWhiteSpace(displayName)
? Path.GetFileNameWithoutExtension(sourcePath)
: displayName.Trim(),
OriginalFileName = Path.GetFileName(sourcePath),
};
info.StoredFileName = $"{info.Id:N}.docx";
File.Copy(sourcePath, GetTemplatePath(info), overwrite: false);
templates.Add(info);
SaveIndex(templates);
return info;
}
public void Delete(Guid id)
{
var templates = LoadIndex();
var template = templates.FirstOrDefault(t => t.Id == id);
if (template is null) return;
var path = GetTemplatePath(template);
if (File.Exists(path)) File.Delete(path);
templates.Remove(template);
SaveIndex(templates);
}
public TemplateValidationResult Validate(LetterTemplateInfo template) =>
Validate(GetTemplatePath(template));
public TemplateValidationResult Validate(string path)
{
var tags = new List<string>();
var issues = new List<TemplateValidationIssue>();
if (!File.Exists(path))
return Error("Die Vorlagendatei wurde nicht gefunden.");
if (!string.Equals(Path.GetExtension(path), ".docx", StringComparison.OrdinalIgnoreCase))
return Error("Die Vorlage muss eine DOCX-Datei sein.");
try
{
using var archive = ZipFile.OpenRead(path);
var xmlEntries = WordXmlEntries(archive).ToList();
if (xmlEntries.All(e => !string.Equals(e.FullName, "word/document.xml", StringComparison.OrdinalIgnoreCase)))
return Error("Die Datei ist kein gültiges Word-DOCX-Dokument.");
foreach (var entry in xmlEntries)
{
using var stream = entry.Open();
var document = XDocument.Load(stream, LoadOptions.PreserveWhitespace);
foreach (var control in document.Descendants(W + "sdt"))
{
var tag = control.Element(W + "sdtPr")?.Element(W + "tag")?.Attribute(W + "val")?.Value?.Trim();
if (string.IsNullOrWhiteSpace(tag))
{
issues.Add(new(TemplateIssueSeverity.StrongWarning,
$"Ein Inhaltssteuerelement in {PartLabel(entry.FullName)} besitzt keinen Tag und kann nicht befüllt werden."));
continue;
}
tags.Add(tag);
}
}
}
catch (InvalidDataException)
{
return Error("Die Datei ist beschädigt oder kein gültiges DOCX-Dokument.");
}
catch (System.Xml.XmlException)
{
return Error("Die Word-Vorlage enthält ungültiges XML und kann nicht gelesen werden.");
}
catch (IOException ex)
{
return Error($"Die Vorlage konnte nicht geöffnet werden: {ex.Message}");
}
if (tags.Count == 0 && issues.Count == 0)
issues.Add(new(TemplateIssueSeverity.Warning,
"Die Vorlage enthält keine Inhaltssteuerelemente. Es werden keine Felder befüllt."));
foreach (var tag in tags.Distinct(StringComparer.Ordinal))
{
if (SupportedPlaceholders.Any(p => p.Tag == tag)) continue;
var suggestion = FindSuggestion(tag);
issues.Add(suggestion is null
? new(TemplateIssueSeverity.Warning,
$"Der unbekannte Tag „{tag}“ wird nicht befüllt.", tag)
: new(TemplateIssueSeverity.StrongWarning,
$"Wahrscheinlicher Schreibfehler: „{tag}“. Meinten Sie „{suggestion}“?", tag, suggestion));
}
return new(tags.Distinct(StringComparer.Ordinal).OrderBy(t => t).ToList(), issues);
TemplateValidationResult Error(string message) =>
new([], [new(TemplateIssueSeverity.Error, message)]);
}
public void Generate(string templatePath, string outputPath,
IReadOnlyDictionary<string, string?> values)
{
var validation = Validate(templatePath);
if (!validation.CanGenerate)
throw new InvalidDataException(validation.Issues.First(i => i.Severity == TemplateIssueSeverity.Error).Message);
var outputDirectory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(outputDirectory)) Directory.CreateDirectory(outputDirectory);
using var source = ZipFile.OpenRead(templatePath);
using var target = ZipFile.Open(outputPath, ZipArchiveMode.Create);
foreach (var entry in source.Entries)
{
var targetEntry = target.CreateEntry(entry.FullName, CompressionLevel.Optimal);
targetEntry.LastWriteTime = entry.LastWriteTime;
using var input = entry.Open();
using var output = targetEntry.Open();
if (!IsWordXmlEntry(entry))
{
input.CopyTo(output);
continue;
}
var document = XDocument.Load(input, LoadOptions.PreserveWhitespace);
foreach (var control in document.Descendants(W + "sdt").ToList())
{
var tag = control.Element(W + "sdtPr")?.Element(W + "tag")?.Attribute(W + "val")?.Value?.Trim();
if (tag is null || !values.TryGetValue(tag, out var value)) continue;
ReplaceContent(control.Element(W + "sdtContent"), value ?? "");
}
document.Save(output, SaveOptions.DisableFormatting);
}
}
private static void ReplaceContent(XElement? content, string value)
{
if (content is null) return;
var paragraph = content.Elements(W + "p").FirstOrDefault();
XElement run;
if (paragraph is not null)
{
run = paragraph.Descendants(W + "r").FirstOrDefault() ?? new XElement(W + "r");
var paragraphProperties = paragraph.Element(W + "pPr");
var runProperties = run.Element(W + "rPr") is { } rp ? new XElement(rp) : null;
paragraph.RemoveNodes();
if (paragraphProperties is not null) paragraph.Add(paragraphProperties);
run = new XElement(W + "r");
if (runProperties is not null) run.Add(runProperties);
paragraph.Add(run);
foreach (var extra in content.Elements(W + "p").Skip(1).ToList()) extra.Remove();
foreach (var node in content.Nodes().Where(n => n is XElement e && e.Name != W + "p").ToList()) node.Remove();
}
else
{
var firstRun = content.Descendants(W + "r").FirstOrDefault();
var runProperties = firstRun?.Element(W + "rPr") is { } rp ? new XElement(rp) : null;
content.RemoveNodes();
run = new XElement(W + "r");
if (runProperties is not null) run.Add(runProperties);
content.Add(run);
}
var lines = value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
for (var i = 0; i < lines.Length; i++)
{
if (i > 0) run.Add(new XElement(W + "br"));
run.Add(new XElement(W + "t", new XAttribute(XNamespace.Xml + "space", "preserve"), lines[i]));
}
}
private static IEnumerable<ZipArchiveEntry> WordXmlEntries(ZipArchive archive) =>
archive.Entries.Where(IsWordXmlEntry);
private static bool IsWordXmlEntry(ZipArchiveEntry entry) =>
entry.FullName.StartsWith("word/", StringComparison.OrdinalIgnoreCase)
&& entry.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase);
private static string PartLabel(string path) => path switch
{
"word/document.xml" => "Dokumenttext",
var p when p.StartsWith("word/header", StringComparison.OrdinalIgnoreCase) => "einer Kopfzeile",
var p when p.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase) => "einer Fußzeile",
_ => $"dem Dokumentteil „{Path.GetFileName(path)}“",
};
private static string? FindSuggestion(string unknown)
{
var candidate = SupportedPlaceholders
.Select(p => (p.Tag, Distance: Levenshtein(unknown.ToLowerInvariant(), p.Tag.ToLowerInvariant())))
.OrderBy(x => x.Distance)
.ThenBy(x => x.Tag)
.First();
var threshold = candidate.Tag.Length >= 12 ? 3 : 2;
return candidate.Distance <= threshold ? candidate.Tag : null;
}
private static int Levenshtein(string left, string right)
{
var previous = Enumerable.Range(0, right.Length + 1).ToArray();
for (var i = 1; i <= left.Length; i++)
{
var current = new int[right.Length + 1];
current[0] = i;
for (var j = 1; j <= right.Length; j++)
current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1),
previous[j - 1] + (left[i - 1] == right[j - 1] ? 0 : 1));
previous = current;
}
return previous[right.Length];
}
private List<LetterTemplateInfo> LoadIndex()
{
if (!File.Exists(_indexPath)) return [];
try
{
return JsonSerializer.Deserialize<List<LetterTemplateInfo>>(File.ReadAllText(_indexPath), JsonOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
private void SaveIndex(List<LetterTemplateInfo> templates)
{
var temporaryPath = _indexPath + ".tmp";
File.WriteAllText(temporaryPath, JsonSerializer.Serialize(templates, JsonOptions));
File.Move(temporaryPath, _indexPath, overwrite: true);
}
}