This commit is contained in:
Vendored
+15
-1
@@ -5,7 +5,7 @@
|
||||
"name": "Desktop App starten",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "Desktop bauen",
|
||||
"preLaunchTask": "TemplateDesigner bauen",
|
||||
"program": "${workspaceFolder}/LehrerApp.Desktop/bin/Debug/net10.0/LehrerApp.Desktop.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/LehrerApp.Desktop",
|
||||
@@ -15,6 +15,20 @@
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "TemplateDesigner App starten",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "Desktop bauen",
|
||||
"program": "${workspaceFolder}/LehrerApp.TemplateDesigner/bin/Debug/net10.0/LehrerApp.TemplateDesigner.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/LehrerApp.TemplateDesigner",
|
||||
"stopAtEntry": false,
|
||||
"console": "internalConsole",
|
||||
"env": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "API Server starten",
|
||||
"type": "coreclr",
|
||||
|
||||
Vendored
+13
@@ -27,6 +27,19 @@
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "TemplateDesigner bauen",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/LehrerApp.TemplateDesigner/LehrerApp.TemplateDesigner.csproj",
|
||||
"--configuration", "Debug",
|
||||
"--verbosity", "minimal"
|
||||
],
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "Solution bauen",
|
||||
"command": "dotnet",
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<!-- Datenbank -->
|
||||
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
||||
|
||||
<!-- PDF-Erzeugung (11.3) - nur LehrerApp.Desktop: zieht SkiaSharp-Native-Binaries mit,
|
||||
die im Server-Image (LehrerApp.Api) nichts verloren haben. -->
|
||||
<!-- PDF-Erzeugung: Desktop-Exporte und die unabhängige Templating-Library. -->
|
||||
<PackageVersion Include="QuestPDF" Version="2025.7.0" />
|
||||
<PackageVersion Include="PDFtoImage" Version="5.4.0" />
|
||||
|
||||
<!-- API -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.IO.Compression;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
@@ -9,16 +8,12 @@ namespace LehrerApp.Desktop.Tests;
|
||||
public sealed class CreateLetterDialogViewModelTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-vm-tests-{Guid.NewGuid():N}");
|
||||
|
||||
public CreateLetterDialogViewModelTests() => Directory.CreateDirectory(_directory);
|
||||
|
||||
[Fact]
|
||||
public void OhneVorlage_KannKeinenBriefErzeugen()
|
||||
{
|
||||
var student = StudentWithContact("Sehr geehrte Frau Muster,");
|
||||
|
||||
var vm = Build(student, new LetterTemplateService(_directory));
|
||||
|
||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), new TemplateStore(_directory));
|
||||
Assert.False(vm.CanGenerate);
|
||||
Assert.Contains(vm.Issues, i => i.Message.Contains("Vorlage", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -26,83 +21,50 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
|
||||
[Fact]
|
||||
public void VerwendeteBriefanredeFehlt_BlockiertErzeugung()
|
||||
{
|
||||
var service = ServiceWithTemplate("Letter.Salutation");
|
||||
var vm = Build(StudentWithContact(null), service);
|
||||
|
||||
var vm = Build(StudentWithContact(null), StoreWithTemplate(
|
||||
new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
|
||||
Assert.False(vm.CanGenerate);
|
||||
Assert.Contains(vm.Issues, i => i.Message.Contains("Briefanrede"));
|
||||
Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VollstaendigeDaten_ErzeugenEditierbareDocxKopie()
|
||||
public void VollstaendigeDaten_ErzeugenFlachesPdf()
|
||||
{
|
||||
var service = ServiceWithTemplate("Letter.Salutation", "Student.FirstName", "CurrentDate");
|
||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), service);
|
||||
var output = Path.Combine(_directory, "Brief.docx");
|
||||
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
|
||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||
vm.LetterText = "Dies ist der Inhalt.";
|
||||
var output = Path.Combine(_directory, "Brief.pdf");
|
||||
|
||||
var generated = vm.Generate(output);
|
||||
|
||||
Assert.True(generated);
|
||||
Assert.True(File.Exists(output));
|
||||
using var archive = ZipFile.OpenRead(output);
|
||||
using var reader = new StreamReader(archive.GetEntry("word/document.xml")!.Open());
|
||||
var xml = reader.ReadToEnd();
|
||||
Assert.Contains("Sehr geehrte Frau Muster,", xml);
|
||||
Assert.Contains("Lena", xml);
|
||||
Assert.True(vm.Generate(output));
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KontaktBearbeiten_SpeichertExpliziteBriefanrede()
|
||||
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
|
||||
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
|
||||
|
||||
private TemplateStore StoreWithTemplate(params PlaceholderDefinition[] definitions)
|
||||
{
|
||||
var vm = new ContactEditDialogViewModel(new Contact { Name = "Frau Muster" });
|
||||
vm.LetterSalutation = "Sehr geehrte Frau Muster,";
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Equal("Sehr geehrte Frau Muster,", vm.Result!.LetterSalutation);
|
||||
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
|
||||
var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief", Placeholders = [.. definitions] };
|
||||
var lines = new List<string> { "PAGE 210 297 mm" };
|
||||
var y = 20;
|
||||
foreach (var definition in definitions)
|
||||
{
|
||||
var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT";
|
||||
lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}");
|
||||
y += 20;
|
||||
}
|
||||
|
||||
private CreateLetterDialogViewModel Build(Student student, LetterTemplateService service) =>
|
||||
new(student, service, new FakeMemberships([]), new FakeGroups([]));
|
||||
|
||||
private LetterTemplateService ServiceWithTemplate(params string[] tags)
|
||||
{
|
||||
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.docx");
|
||||
using (var archive = ZipFile.Open(source, ZipArchiveMode.Create))
|
||||
{
|
||||
var entry = archive.CreateEntry("word/document.xml");
|
||||
using var writer = new StreamWriter(entry.Open());
|
||||
var controls = string.Join("", tags.Select(tag =>
|
||||
$"<w:sdt><w:sdtPr><w:tag w:val=\"{tag}\"/></w:sdtPr><w:sdtContent>" +
|
||||
"<w:p><w:r><w:t>Platzhalter</w:t></w:r></w:p></w:sdtContent></w:sdt>"));
|
||||
writer.Write("<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">" +
|
||||
$"<w:body>{controls}</w:body></w:document>");
|
||||
}
|
||||
var service = new LetterTemplateService(Path.Combine(_directory, $"service-{Guid.NewGuid():N}"));
|
||||
service.Import(source, "Elternbrief");
|
||||
return service;
|
||||
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
|
||||
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")); store.Import(source); return store;
|
||||
}
|
||||
|
||||
private static Student StudentWithContact(string? salutation) => new()
|
||||
{
|
||||
FirstName = "Lena",
|
||||
LastName = "Beispiel",
|
||||
Contacts =
|
||||
[
|
||||
new Contact
|
||||
{
|
||||
Name = "Frau Muster",
|
||||
Relation = "Mutter",
|
||||
LetterSalutation = salutation,
|
||||
Street = "Hauptstraße 1",
|
||||
PostalCode = "12345",
|
||||
City = "Musterstadt",
|
||||
},
|
||||
],
|
||||
FirstName = "Lena", LastName = "Beispiel",
|
||||
Contacts = [new Contact { Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation,
|
||||
Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt" }],
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Models;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
@@ -34,7 +35,7 @@ public sealed class SettingsViewModelTests
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
||||
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
||||
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
|
||||
@@ -320,7 +321,7 @@ public sealed class SettingsViewModelTests
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
@@ -348,7 +349,7 @@ public sealed class SettingsViewModelTests
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
@@ -380,7 +381,7 @@ public sealed class SettingsViewModelTests
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
|
||||
@@ -16,6 +16,7 @@ using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Sync.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop;
|
||||
|
||||
@@ -117,6 +118,9 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<NotificationService>();
|
||||
services.AddSingleton<ExportService>();
|
||||
services.AddSingleton<PdfExportService>();
|
||||
services.AddSingleton<ITemplateLoader, TemplateLoader>();
|
||||
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
|
||||
services.AddSingleton(_ => new TemplateStore(appData));
|
||||
|
||||
// ── Datensicherheit (13.3) ───────────────────────────────────────────
|
||||
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
|
||||
@@ -193,7 +197,6 @@ public static class AppBootstrapper
|
||||
services.AddSingleton(_ => new DashboardSettingsService(appData));
|
||||
services.AddSingleton(_ => new WindowSettingsService(appData));
|
||||
services.AddSingleton(_ => new AppearanceSettingsService(appData));
|
||||
services.AddSingleton(_ => new LetterTemplateService(appData));
|
||||
services.AddSingleton<PlanningExchangeService>();
|
||||
|
||||
// ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ──────
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
|
||||
@@ -1,37 +1,19 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Templating;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Word-Briefvorlagen (7.1.4 / 11.5) ───────────────────────────────────
|
||||
|
||||
[ObservableProperty] private string _letterTemplateStatus = "";
|
||||
public ObservableCollection<LetterTemplateListItem> LetterTemplateList { get; } = [];
|
||||
public IReadOnlyList<LetterPlaceholder> SupportedLetterPlaceholders =>
|
||||
LetterTemplateService.SupportedPlaceholders;
|
||||
|
||||
// ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
|
||||
|
||||
private void LoadLetterTemplates()
|
||||
{
|
||||
LetterTemplateList.Clear();
|
||||
foreach (var template in _letterTemplates.GetTemplates())
|
||||
LetterTemplateList.Add(new LetterTemplateListItem(template, _letterTemplates.Validate(template)));
|
||||
foreach (var template in _letterTemplates.GetTemplates()) LetterTemplateList.Add(CreateItem(template));
|
||||
}
|
||||
|
||||
public void ImportLetterTemplate(string path)
|
||||
@@ -40,75 +22,59 @@ public partial class SettingsViewModel
|
||||
try
|
||||
{
|
||||
var template = _letterTemplates.Import(path);
|
||||
var validation = _letterTemplates.Validate(template);
|
||||
LoadLetterTemplates();
|
||||
LetterTemplateStatus = validation.Issues.Count == 0
|
||||
? "Vorlage importiert und ohne Auffälligkeiten geprüft."
|
||||
: $"Vorlage importiert. Die Prüfung meldet {validation.Issues.Count} Hinweis(e).";
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
|
||||
{
|
||||
LetterTemplateStatus = $"Import fehlgeschlagen: {ex.Message}";
|
||||
LetterTemplateStatus = $"„{template.Name}“ wurde geprüft und lokal importiert.";
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
|
||||
{ LetterTemplateStatus = $"Import fehlgeschlagen: {ex.Message}"; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ValidateLetterTemplate(LetterTemplateListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
try
|
||||
{
|
||||
var refreshed = CreateItem(item.Model);
|
||||
var index = LetterTemplateList.IndexOf(item);
|
||||
var refreshed = new LetterTemplateListItem(item.Model, _letterTemplates.Validate(item.Model));
|
||||
if (index >= 0) LetterTemplateList[index] = refreshed;
|
||||
LetterTemplateStatus = refreshed.Validation.Issues.Count == 0
|
||||
? $"„{item.Name}“ ist ohne Auffälligkeiten."
|
||||
: $"„{item.Name}“: {refreshed.Validation.Issues.Count} Hinweis(e).";
|
||||
LetterTemplateStatus = $"„{item.Name}“ ist gültig (Schema {refreshed.SchemaVersion}).";
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
|
||||
{ LetterTemplateStatus = $"„{item.Name}“ ist ungültig: {ex.Message}"; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DeleteLetterTemplate(LetterTemplateListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_letterTemplates.Delete(item.Id);
|
||||
LetterTemplateList.Remove(item);
|
||||
LetterTemplateStatus = "Vorlage gelöscht.";
|
||||
_letterTemplates.Delete(item.Id); LetterTemplateList.Remove(item); LetterTemplateStatus = "Vorlage gelöscht.";
|
||||
}
|
||||
|
||||
private LetterTemplateListItem CreateItem(InstalledTemplate template)
|
||||
{
|
||||
var loaded = _letterTemplates.Load(template);
|
||||
return new(template, loaded.Manifest.SchemaVersion, loaded.Manifest.Placeholders.Count,
|
||||
loaded.Manifest.Placeholders.Count(x => x.Required));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LetterTemplateListItem
|
||||
public sealed class LetterTemplateListItem(InstalledTemplate model, int schemaVersion, int fieldCount, int requiredCount)
|
||||
{
|
||||
public LetterTemplateInfo Model { get; }
|
||||
public TemplateValidationResult Validation { get; }
|
||||
public Guid Id => Model.Id;
|
||||
public InstalledTemplate Model { get; } = model;
|
||||
public string Id => Model.Id;
|
||||
public string Name => Model.Name;
|
||||
public string OriginalFileName => Model.OriginalFileName;
|
||||
public bool HasIssues => Validation.Issues.Count > 0;
|
||||
public bool HasNoIssues => !HasIssues;
|
||||
public string ValidationSummary => HasNoIssues
|
||||
? $"{Validation.Tags.Count} Feld(er) · keine Auffälligkeiten"
|
||||
: $"{Validation.Tags.Count} Feld(er) · {Validation.Issues.Count} Hinweis(e)";
|
||||
public ObservableCollection<LetterTemplateIssueItem> Issues { get; }
|
||||
|
||||
public LetterTemplateListItem(LetterTemplateInfo model, TemplateValidationResult validation)
|
||||
{
|
||||
Model = model;
|
||||
Validation = validation;
|
||||
Issues = new(validation.Issues.Select(i => new LetterTemplateIssueItem(i)));
|
||||
}
|
||||
public string PackageFileName => Path.GetFileName(Model.PackagePath);
|
||||
public int SchemaVersion { get; } = schemaVersion;
|
||||
public bool HasIssues => false;
|
||||
public bool HasNoIssues => true;
|
||||
public string ValidationSummary => $"Schema {SchemaVersion} · {fieldCount} Felder ({requiredCount} Pflicht)";
|
||||
public ObservableCollection<LetterTemplateIssueItem> Issues { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class LetterTemplateIssueItem(TemplateValidationIssue issue)
|
||||
public sealed class LetterTemplateIssueItem(ValidationIssue issue)
|
||||
{
|
||||
public string Icon => issue.Severity switch
|
||||
{
|
||||
TemplateIssueSeverity.Error => "⛔",
|
||||
TemplateIssueSeverity.StrongWarning => "⚠",
|
||||
_ => "ⓘ",
|
||||
};
|
||||
public string Icon => issue.Severity == ValidationSeverity.Error ? "⛔" : "ⓘ";
|
||||
public string Message => issue.Message;
|
||||
public string Color => issue.Severity switch
|
||||
{
|
||||
TemplateIssueSeverity.Error => "#DC2626",
|
||||
TemplateIssueSeverity.StrongWarning => "#D97706",
|
||||
_ => "#6B7280",
|
||||
};
|
||||
public string Color => issue.Severity == ValidationSeverity.Error ? "#DC2626" : "#6B7280";
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
@@ -59,7 +60,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
private readonly IDocumentationRepository _documentation;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IShorthandCodeRepository _shorthandCodes;
|
||||
private readonly LetterTemplateService _letterTemplates;
|
||||
private readonly TemplateStore _letterTemplates;
|
||||
|
||||
[ObservableProperty] private int _activeTabIndex;
|
||||
|
||||
@@ -97,7 +98,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
IDocumentationRepository documentation, IStudentRepository students,
|
||||
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
||||
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
||||
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
|
||||
ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates,
|
||||
AiSettingsService aiSettings, AiPlanningService aiPlanning,
|
||||
WebUntisSettingsService untisSettings,
|
||||
AnnualPlanSettingsService annualPlanSettings,
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Templating;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
public partial class CreateLetterDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly Student _student;
|
||||
private readonly LetterTemplateService _templates;
|
||||
private readonly TemplateStore _templates;
|
||||
private readonly ITemplateRenderer _renderer;
|
||||
|
||||
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
|
||||
[ObservableProperty] private LetterContactChoice? _selectedContact;
|
||||
[ObservableProperty] private LetterGroupChoice? _selectedGroup;
|
||||
[ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _letterText = "";
|
||||
[ObservableProperty] private string _teacherName = "";
|
||||
[ObservableProperty] private string _generationError = "";
|
||||
[ObservableProperty] private bool _canGenerate;
|
||||
|
||||
@@ -28,155 +30,92 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
||||
public bool HasNoTemplates => Templates.Count == 0;
|
||||
public bool HasNoContacts => Contacts.Count == 0;
|
||||
public string SuggestedFileName => SanitizeFileName(
|
||||
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.docx");
|
||||
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf");
|
||||
|
||||
public CreateLetterDialogViewModel(Student student, LetterTemplateService templates,
|
||||
public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer,
|
||||
IGroupMembershipRepository memberships, IGroupRepository groups)
|
||||
{
|
||||
_student = student;
|
||||
_templates = templates;
|
||||
|
||||
foreach (var template in templates.GetTemplates())
|
||||
Templates.Add(new LetterTemplateChoice(template));
|
||||
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name))
|
||||
Contacts.Add(new LetterContactChoice(contact));
|
||||
_student = student; _templates = templates; _renderer = renderer;
|
||||
foreach (var template in templates.GetTemplates()) Templates.Add(new(template));
|
||||
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact));
|
||||
foreach (var membership in memberships.GetByStudent(student.Id))
|
||||
{
|
||||
var group = groups.GetById(membership.GroupId);
|
||||
if (group is not null) Groups.Add(new LetterGroupChoice(group));
|
||||
}
|
||||
|
||||
SelectedTemplate = Templates.FirstOrDefault();
|
||||
SelectedContact = Contacts.FirstOrDefault();
|
||||
SelectedGroup = Groups.FirstOrDefault();
|
||||
if (groups.GetById(membership.GroupId) is { } group) Groups.Add(new(group));
|
||||
SelectedTemplate = Templates.FirstOrDefault(); SelectedContact = Contacts.FirstOrDefault(); SelectedGroup = Groups.FirstOrDefault();
|
||||
RefreshValidation();
|
||||
}
|
||||
|
||||
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(SuggestedFileName));
|
||||
RefreshValidation();
|
||||
}
|
||||
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) { OnPropertyChanged(nameof(SuggestedFileName)); RefreshValidation(); }
|
||||
partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation();
|
||||
partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation();
|
||||
partial void OnLetterDateChanged(DateTimeOffset? value) => RefreshValidation();
|
||||
partial void OnLetterTextChanged(string value) => RefreshValidation();
|
||||
partial void OnTeacherNameChanged(string value) => RefreshValidation();
|
||||
|
||||
public bool Generate(string outputPath)
|
||||
{
|
||||
RefreshValidation();
|
||||
if (!CanGenerate || SelectedTemplate is null) return false;
|
||||
GenerationError = "";
|
||||
RefreshValidation(); if (!CanGenerate || SelectedTemplate is null) return false;
|
||||
try
|
||||
{
|
||||
_templates.Generate(_templates.GetTemplatePath(SelectedTemplate.Model), outputPath, BuildValues());
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
|
||||
{
|
||||
GenerationError = $"Der Brief konnte nicht erzeugt werden: {ex.Message}";
|
||||
return false;
|
||||
var pdf = _renderer.RenderToPdf(_templates.Load(SelectedTemplate.Model), new LetterDataProvider(BuildValues()));
|
||||
var directory = Path.GetDirectoryName(outputPath); if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
File.WriteAllBytes(outputPath, pdf); GenerationError = ""; return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
|
||||
{ GenerationError = $"Der Brief konnte nicht erzeugt werden: {ex.Message}"; return false; }
|
||||
}
|
||||
|
||||
private void RefreshValidation()
|
||||
{
|
||||
Issues.Clear();
|
||||
GenerationError = "";
|
||||
|
||||
if (SelectedTemplate is null)
|
||||
Issues.Add(new("Bitte zuerst in den Einstellungen eine DOCX-Briefvorlage importieren.", true));
|
||||
if (SelectedContact is null)
|
||||
Issues.Add(new("Für den Schüler ist kein aktueller Kontakt ausgewählt.", true));
|
||||
if (LetterDate is null)
|
||||
Issues.Add(new("Bitte ein Briefdatum auswählen.", true));
|
||||
|
||||
Issues.Clear(); GenerationError = "";
|
||||
if (SelectedTemplate is null) Issues.Add(new("Bitte zuerst in den Einstellungen ein .lavorlage-Paket importieren.", true));
|
||||
if (SelectedContact is null) Issues.Add(new("Für den Schüler ist kein aktueller Kontakt ausgewählt.", true));
|
||||
if (LetterDate is null) Issues.Add(new("Bitte ein Briefdatum auswählen.", true));
|
||||
if (SelectedTemplate is not null)
|
||||
{
|
||||
var validation = _templates.Validate(SelectedTemplate.Model);
|
||||
foreach (var issue in validation.Issues)
|
||||
Issues.Add(new(issue.Message, issue.Severity is TemplateIssueSeverity.StrongWarning or TemplateIssueSeverity.Error));
|
||||
|
||||
var tags = validation.Tags.ToHashSet(StringComparer.Ordinal);
|
||||
var values = BuildValues();
|
||||
foreach (var tag in tags.Where(t => values.TryGetValue(t, out var value) && string.IsNullOrWhiteSpace(value)))
|
||||
try
|
||||
{
|
||||
var message = tag switch
|
||||
{
|
||||
"Letter.Salutation" => "Beim ausgewählten Kontakt fehlt die Briefanrede.",
|
||||
"Contact.Address" or "Contact.Street" or "Contact.PostalCode" or "Contact.City" =>
|
||||
$"Beim ausgewählten Kontakt fehlt der Wert für „{tag}“.",
|
||||
"Group.Name" or "SchoolYear" => "Die Vorlage verwendet Gruppendaten; bitte eine Lerngruppe auswählen.",
|
||||
_ => $"Für das Vorlagenfeld „{tag}“ ist kein Wert vorhanden.",
|
||||
};
|
||||
Issues.Add(new(message, true));
|
||||
var loaded = _templates.Load(SelectedTemplate.Model); var values = BuildValues();
|
||||
var validation = new TemplateLoader().Validate(loaded, values.ToDictionary(x => x.Key, x => x.Value.Type));
|
||||
foreach (var issue in validation.Issues) Issues.Add(new(issue.Message, issue.Severity == ValidationSeverity.Error));
|
||||
foreach (var required in loaded.Manifest.Placeholders.Where(x => x.Required && values.TryGetValue(x.Name, out var value) && IsEmpty(value)))
|
||||
Issues.Add(new($"Für das Pflichtfeld „{required.Name}“ ist kein Wert vorhanden.", true));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
|
||||
{ Issues.Add(new($"Vorlage ist ungültig: {ex.Message}", true)); }
|
||||
}
|
||||
|
||||
CanGenerate = SelectedTemplate is not null && SelectedContact is not null && LetterDate is not null
|
||||
&& Issues.Count == 0;
|
||||
CanGenerate = SelectedTemplate is not null && SelectedContact is not null && LetterDate is not null && Issues.Count == 0;
|
||||
OnPropertyChanged(nameof(HasIssues));
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, string?> BuildValues()
|
||||
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues()
|
||||
{
|
||||
var contact = SelectedContact?.Model;
|
||||
var group = SelectedGroup?.Model;
|
||||
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v)));
|
||||
var address = string.Join(Environment.NewLine, new[] { contact?.Street, cityLine }
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v)));
|
||||
var date = LetterDate is null ? null : DateOnly.FromDateTime(LetterDate.Value.LocalDateTime)
|
||||
.ToString("dd.MM.yyyy", CultureInfo.GetCultureInfo("de-DE"));
|
||||
|
||||
return new Dictionary<string, string?>
|
||||
var contact = SelectedContact?.Model; var group = SelectedGroup?.Model;
|
||||
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
var date = DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
|
||||
{
|
||||
["Student.FirstName"] = _student.FirstName,
|
||||
["Student.LastName"] = _student.LastName,
|
||||
["Contact.Name"] = contact?.Name,
|
||||
["Contact.Address"] = address,
|
||||
["Contact.Street"] = contact?.Street,
|
||||
["Contact.PostalCode"] = contact?.PostalCode,
|
||||
["Contact.City"] = contact?.City,
|
||||
["Letter.Salutation"] = contact?.LetterSalutation,
|
||||
["Group.Name"] = group?.Name,
|
||||
["SchoolYear"] = group?.SchoolYear,
|
||||
["CurrentDate"] = date,
|
||||
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
|
||||
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||
["Brieftext"] = new MultilineValue(LetterText), ["LehrerName"] = new TextValue(TeacherName),
|
||||
["Student.FirstName"] = new TextValue(_student.FirstName), ["Student.LastName"] = new TextValue(_student.LastName),
|
||||
["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
|
||||
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
|
||||
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||
["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsEmpty(PlaceholderValue value) => value switch
|
||||
{ TextValue x => string.IsNullOrWhiteSpace(x.Value), MultilineValue x => string.IsNullOrWhiteSpace(x.Value), _ => false };
|
||||
private static string SanitizeFileName(string value)
|
||||
{
|
||||
foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_');
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LetterTemplateChoice
|
||||
{
|
||||
public LetterTemplateInfo Model { get; }
|
||||
public string Name => Model.Name;
|
||||
public LetterTemplateChoice(LetterTemplateInfo model) => Model = model;
|
||||
}
|
||||
|
||||
public sealed class LetterContactChoice
|
||||
{
|
||||
public Contact Model { get; }
|
||||
public string Display => string.IsNullOrWhiteSpace(Model.Relation)
|
||||
? Model.Name
|
||||
: $"{Model.Name} · {Model.Relation}";
|
||||
public LetterContactChoice(Contact model) => Model = model;
|
||||
}
|
||||
|
||||
public sealed class LetterGroupChoice
|
||||
{
|
||||
public LearningGroup Model { get; }
|
||||
public string Display => $"{Model.Name} · {Model.SchoolYear}";
|
||||
public LetterGroupChoice(LearningGroup model) => Model = model;
|
||||
{ foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; }
|
||||
}
|
||||
|
||||
internal sealed class LetterDataProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
|
||||
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }
|
||||
public sealed class LetterTemplateChoice(InstalledTemplate model) { public InstalledTemplate Model { get; } = model; public string Name => Model.Name; }
|
||||
public sealed class LetterContactChoice(Contact model) { public Contact Model { get; } = model; public string Display => string.IsNullOrWhiteSpace(Model.Relation) ? Model.Name : $"{Model.Name} · {Model.Relation}"; }
|
||||
public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; }
|
||||
public sealed class LetterGenerationIssue(string message, bool isStrong)
|
||||
{
|
||||
public string Icon { get; } = isStrong ? "⚠" : "ⓘ";
|
||||
public string Message { get; } = message;
|
||||
public string Color { get; } = isStrong ? "#D97706" : "#6B7280";
|
||||
}
|
||||
{ public string Icon { get; } = isStrong ? "⚠" : "ⓘ"; public string Message { get; } = message; public string Color { get; } = isStrong ? "#D97706" : "#6B7280"; }
|
||||
|
||||
@@ -328,15 +328,15 @@
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
<!-- Tab: Word-Briefvorlagen (7.1.4 / 11.5) -->
|
||||
<!-- Tab: portable PDF-Briefvorlagen -->
|
||||
<ContentPage Header="Briefvorlagen">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="32,20,32,28" Spacing="16" MaxWidth="760">
|
||||
<TextBlock Text="Word-Vorlagen bleiben vollständig in Word gestaltet. Die App befüllt Inhaltssteuerelemente anhand ihres Tags und prüft die Vorlage bereits beim Import."
|
||||
<TextBlock Text="Portable .lavorlage-Pakete werden beim Import vollständig geprüft und lokal kopiert. Briefe entstehen anschließend als flache, durchsuchbare PDF-Dateien."
|
||||
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
|
||||
<Button Grid.Column="0" Content="+ DOCX-Vorlage importieren" Click="OnImportLetterTemplateClick"/>
|
||||
<Button Grid.Column="0" Content="+ .lavorlage importieren" Click="OnImportLetterTemplateClick"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding LetterTemplateStatus}" FontSize="12"
|
||||
VerticalAlignment="Center" TextWrapping="Wrap"
|
||||
IsVisible="{Binding LetterTemplateStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
@@ -355,7 +355,7 @@
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="14"/>
|
||||
<TextBlock FontSize="11" Opacity="0.55">
|
||||
<Run Text="{Binding OriginalFileName}"/><Run Text=" · "/>
|
||||
<Run Text="{Binding PackageFileName}"/><Run Text=" · "/>
|
||||
<Run Text="{Binding ValidationSummary}"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
@@ -391,19 +391,8 @@
|
||||
</ItemsControl>
|
||||
|
||||
<Separator/>
|
||||
<TextBlock Text="Unterstützte Tags" FontSize="15" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="In Word: Entwicklertools → Rich-Text- oder Nur-Text-Inhaltssteuerelement → Eigenschaften → Tag. Der Titel ist frei wählbar; ausgewertet wird der Tag."
|
||||
<TextBlock Text="Vorlagen werden mit dem separaten LehrerApp Vorlagen-Designer erstellt. Designer und Hauptapp verwenden exakt dieselbe QuestPDF-Renderingbibliothek."
|
||||
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
|
||||
<ItemsControl ItemsSource="{Binding SupportedLetterPlaceholders}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="services:LetterPlaceholder">
|
||||
<Grid ColumnDefinitions="190,*" Margin="0,3">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Tag}" FontFamily="Monospace" FontSize="12"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Description}" FontSize="12" Opacity="0.7"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
@@ -263,9 +263,9 @@ public partial class SettingsView : UserControl
|
||||
|
||||
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Word-Briefvorlage importieren",
|
||||
Title = "LehrerApp-Briefvorlage importieren",
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter = [new FilePickerFileType("Word-Dokumente") { Patterns = ["*.docx"] }],
|
||||
FileTypeFilter = [new FilePickerFileType("LehrerApp-Vorlagen") { Patterns = ["*.lavorlage"] }],
|
||||
});
|
||||
if (files.Count > 0) vm.ImportLetterTemplate(files[0].Path.LocalPath);
|
||||
}
|
||||
@@ -273,8 +273,7 @@ public partial class SettingsView : UserControl
|
||||
private void OnOpenLetterTemplateClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Button { Tag: LetterTemplateListItem item }) return;
|
||||
var service = App.Services.GetRequiredService<LehrerApp.Core.Services.LetterTemplateService>();
|
||||
var path = service.GetTemplatePath(item.Model);
|
||||
var path = item.Model.PackagePath;
|
||||
if (!File.Exists(path)) return;
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<TextBlock Text="Briefanrede" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding LetterSalutation}"
|
||||
PlaceholderText="z.B. Sehr geehrte Frau Mustermann,"/>
|
||||
<TextBlock Text="Wird unverändert in Word-Vorlagen für Letter.Salutation eingesetzt."
|
||||
<TextBlock Text="Wird unverändert für den Vorlagen-Platzhalter Anrede eingesetzt."
|
||||
FontSize="11" Opacity="0.55" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
||||
x:Class="LehrerApp.Desktop.Views.Students.CreateLetterDialog"
|
||||
x:DataType="vm:CreateLetterDialogViewModel"
|
||||
Title="Word-Brief erstellen" Width="580" Height="650"
|
||||
Title="PDF-Brief erstellen" Width="580" Height="760"
|
||||
MinWidth="500" MinHeight="540" CanResize="True"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
<Grid RowDefinitions="*,Auto" Margin="24,20">
|
||||
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="15">
|
||||
<TextBlock Text="Word-Brief erstellen" Classes="dialogtitle"/>
|
||||
<TextBlock Text="PDF-Brief erstellen" Classes="dialogtitle"/>
|
||||
<TextBlock FontSize="12" Opacity="0.65" TextWrapping="Wrap"
|
||||
Text="Die Vorlage wird vor der Erzeugung erneut geprüft. Das Original bleibt unverändert; gespeichert wird eine frei bearbeitbare DOCX-Kopie."/>
|
||||
Text="Die portable Vorlage und alle Pflichtfelder werden vor der Erzeugung geprüft. Gespeichert wird ein flaches, durchsuchbares PDF."/>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Schüler" FontSize="12" Opacity="0.7"/>
|
||||
@@ -43,6 +43,15 @@
|
||||
<CalendarDatePicker SelectedDate="{Binding LetterDate}" HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Brieftext" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding LetterText}" AcceptsReturn="True" TextWrapping="Wrap" MinHeight="110"
|
||||
PlaceholderText="Inhalt des Briefes"/>
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Lehrkraft / Unterschrift" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding TeacherName}" PlaceholderText="Name der Lehrkraft"/>
|
||||
</StackPanel>
|
||||
|
||||
<Border BorderBrush="#D97706" BorderThickness="1" CornerRadius="6" Padding="10"
|
||||
IsVisible="{Binding HasIssues}">
|
||||
@@ -68,7 +77,7 @@
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,10,*" Margin="0,18,0,0">
|
||||
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
|
||||
<Button Grid.Column="2" Content="DOCX speichern …" HorizontalAlignment="Stretch"
|
||||
<Button Grid.Column="2" Content="PDF speichern …" HorizontalAlignment="Stretch"
|
||||
IsEnabled="{Binding CanGenerate}" Click="OnGenerate"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -14,10 +14,10 @@ public partial class CreateLetterDialog : Window
|
||||
if (DataContext is not CreateLetterDialogViewModel vm) return;
|
||||
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Word-Brief speichern",
|
||||
Title = "PDF-Brief speichern",
|
||||
SuggestedFileName = vm.SuggestedFileName,
|
||||
DefaultExtension = "docx",
|
||||
FileTypeChoices = [new FilePickerFileType("Word-Dokumente") { Patterns = ["*.docx"] }],
|
||||
DefaultExtension = "pdf",
|
||||
FileTypeChoices = [new FilePickerFileType("PDF-Dateien") { Patterns = ["*.pdf"] }],
|
||||
});
|
||||
if (file is null || !vm.Generate(file.Path.LocalPath)) return;
|
||||
Close(file.Path.LocalPath);
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top">
|
||||
<TextBlock Text="{Binding StudentStatus}" VerticalAlignment="Center" Opacity="0.65"/>
|
||||
<Button Content="Word-Brief" Command="{Binding CreateLetterCommand}"
|
||||
<Button Content="PDF-Brief" Command="{Binding CreateLetterCommand}"
|
||||
IsVisible="{Binding !IsEditing}"/>
|
||||
<Button Content="Bearbeiten" Command="{Binding StartEditCommand}"
|
||||
IsVisible="{Binding !IsEditing}"/>
|
||||
|
||||
@@ -8,6 +8,7 @@ using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Desktop.Views.Shared;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Students;
|
||||
|
||||
@@ -57,7 +58,8 @@ public partial class StudentDetailView : UserControl
|
||||
if (owner is null || DataContext is not StudentDetailViewModel { Student: { } student }) return;
|
||||
|
||||
var vm = new CreateLetterDialogViewModel(student,
|
||||
App.Services.GetRequiredService<LehrerApp.Core.Services.LetterTemplateService>(),
|
||||
App.Services.GetRequiredService<TemplateStore>(),
|
||||
App.Services.GetRequiredService<ITemplateRenderer>(),
|
||||
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||
App.Services.GetRequiredService<IGroupRepository>());
|
||||
var dialog = new CreateLetterDialog { DataContext = vm };
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using LehrerApp.TemplateDesigner;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner.Tests;
|
||||
|
||||
public sealed class AssetManagementTests
|
||||
{
|
||||
private static readonly byte[] OnePixelPng = Convert.FromBase64String(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=");
|
||||
|
||||
[Fact]
|
||||
public void Import_ZeigtMetadatenUndBenenntKollisionenUm()
|
||||
{
|
||||
var viewModel = new DesignerViewModel();
|
||||
|
||||
var first = viewModel.ImportAsset("mein logo.png", OnePixelPng);
|
||||
var second = viewModel.ImportAsset("mein logo.png", OnePixelPng);
|
||||
|
||||
Assert.Equal("mein-logo.png", first.Name);
|
||||
Assert.Equal("mein-logo-2.png", second.Name);
|
||||
Assert.Equal("1 × 1 px", first.Dimensions);
|
||||
Assert.Equal(2, viewModel.AssetItems.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlsImageEinfuegen_VerwendetKoordinatenUndAktualisiertReferenzzaehler()
|
||||
{
|
||||
var viewModel = new DesignerViewModel
|
||||
{ NewX = "15", NewY = "18", NewWidth = "32", NewHeight = "14" };
|
||||
viewModel.ImportAsset("logo.png", OnePixelPng);
|
||||
|
||||
viewModel.InsertSelectedAssetAsImage();
|
||||
|
||||
Assert.Contains("IMG logo.png 15 18 32 14 scale=100%", viewModel.LayoutSource);
|
||||
Assert.Equal(1, viewModel.SelectedAsset!.UsageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlsImageEinfuegen_SchreibtProzentualeSkalierung()
|
||||
{
|
||||
var viewModel = new DesignerViewModel { NewImageScale = "37,5" };
|
||||
viewModel.ImportAsset("logo.png", OnePixelPng);
|
||||
|
||||
viewModel.InsertSelectedAssetAsImage();
|
||||
|
||||
Assert.Contains("scale=37.5%", viewModel.LayoutSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Entfernen_LoeschtAssetUndAlleLayoutReferenzen()
|
||||
{
|
||||
var viewModel = new DesignerViewModel();
|
||||
viewModel.ImportAsset("logo.png", OnePixelPng);
|
||||
viewModel.InsertSelectedAssetAsImage();
|
||||
|
||||
viewModel.RemoveSelectedAsset();
|
||||
|
||||
Assert.Empty(viewModel.AssetItems);
|
||||
Assert.Empty(viewModel.Assets);
|
||||
Assert.DoesNotContain("IMG logo.png", viewModel.LayoutSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ersetzen_BehältDateinamenUndLayoutReferenzen()
|
||||
{
|
||||
var viewModel = new DesignerViewModel();
|
||||
viewModel.ImportAsset("logo.png", OnePixelPng);
|
||||
viewModel.InsertSelectedAssetAsImage();
|
||||
|
||||
viewModel.ReplaceSelectedAsset(OnePixelPng);
|
||||
|
||||
Assert.Equal("logo.png", Assert.Single(viewModel.AssetItems).Name);
|
||||
Assert.Contains("IMG logo.png", viewModel.LayoutSource);
|
||||
Assert.Equal(1, viewModel.SelectedAsset!.UsageCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><IsPackable>false</IsPackable></PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LehrerApp.TemplateDesigner\LehrerApp.TemplateDesigner.csproj" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio"><PrivateAssets>all</PrivateAssets></PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,80 @@
|
||||
using LehrerApp.TemplateDesigner;
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner.Tests;
|
||||
|
||||
public sealed class StarterTemplateLibraryTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"starter-library-{Guid.NewGuid():N}");
|
||||
public StarterTemplateLibraryTests() => Directory.CreateDirectory(_directory);
|
||||
|
||||
[Fact]
|
||||
public void SpeichernUndAktualisieren_VerwendetStabileIdUndErsetztInhalt()
|
||||
{
|
||||
var library = new StarterTemplateLibrary(_directory);
|
||||
var manifest = Manifest("briefkopf", "Briefkopf");
|
||||
library.Save(manifest, "PAGE 210 297 mm\nTEXT 10 10 \"Version 1\"", EmptyAssets());
|
||||
|
||||
library.Save(manifest, "PAGE 210 297 mm\nTEXT 10 10 \"Version 2\"", EmptyAssets());
|
||||
|
||||
var item = Assert.Single(library.GetAll());
|
||||
var (_, layout) = library.Load(item);
|
||||
Assert.Equal("briefkopf", item.Id);
|
||||
Assert.Contains("Version 2", layout);
|
||||
Assert.DoesNotContain("Version 1", layout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Duplizieren_ErzeugtUnabhaengigeIdUndLaesstOriginalUnveraendert()
|
||||
{
|
||||
var library = new StarterTemplateLibrary(_directory);
|
||||
var original = library.Save(Manifest("briefkopf", "Briefkopf"),
|
||||
"PAGE 210 297 mm", EmptyAssets());
|
||||
|
||||
var firstCopy = library.Duplicate(original);
|
||||
var secondCopy = library.Duplicate(original);
|
||||
|
||||
Assert.Equal("briefkopf-kopie", firstCopy.Id);
|
||||
Assert.Equal("briefkopf-kopie-2", secondCopy.Id);
|
||||
Assert.Equal(3, library.GetAll().Count);
|
||||
Assert.Equal("Briefkopf", library.Load(original).Template.Manifest.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportUndExport_UebertragenPortablesPaket()
|
||||
{
|
||||
var sourceLibrary = new StarterTemplateLibrary(Path.Combine(_directory, "source"));
|
||||
var targetLibrary = new StarterTemplateLibrary(Path.Combine(_directory, "target"));
|
||||
var item = sourceLibrary.Save(Manifest("kopf", "Schulbriefkopf"), "PAGE 210 297 mm", EmptyAssets());
|
||||
var exported = Path.Combine(_directory, "export.lavorlage");
|
||||
|
||||
sourceLibrary.Export(item, exported);
|
||||
var imported = targetLibrary.Import(exported);
|
||||
|
||||
Assert.Equal("kopf", imported.Id);
|
||||
Assert.Equal("Schulbriefkopf", Assert.Single(targetLibrary.GetAll()).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlsNeuesProjekt_BehaeltLayoutUndVergibtNeueMetadaten()
|
||||
{
|
||||
var library = new StarterTemplateLibrary(_directory);
|
||||
var item = library.Save(Manifest("kopf", "Schulbriefkopf"),
|
||||
"PAGE 210 297 mm\nTEXT 10 10 \"Schule\"", EmptyAssets());
|
||||
var (template, layout) = library.Load(item);
|
||||
var viewModel = new DesignerViewModel();
|
||||
|
||||
viewModel.LoadAsNewProject(template, layout);
|
||||
|
||||
Assert.Equal("kopf-neu", viewModel.TemplateId);
|
||||
Assert.Equal("Schulbriefkopf - Neu", viewModel.TemplateName);
|
||||
Assert.Contains("Schule", viewModel.LayoutSource);
|
||||
Assert.Equal("kopf", library.Load(item).Template.Manifest.Id);
|
||||
}
|
||||
|
||||
private static TemplateManifest Manifest(string id, string name) => new()
|
||||
{ Id = id, Name = name, Description = "Wiederverwendbarer Ausgangspunkt" };
|
||||
private static IReadOnlyDictionary<string, byte[]> EmptyAssets() => new Dictionary<string, byte[]>();
|
||||
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<Application xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LehrerApp.TemplateDesigner.App" RequestedThemeVariant="Default">
|
||||
<Application.Styles>
|
||||
<FluentTheme/>
|
||||
<Style Selector="TextBlock.section"><Setter Property="FontWeight" Value="SemiBold"/><Setter Property="FontSize" Value="15"/></Style>
|
||||
<Style Selector="TextBlock.label"><Setter Property="Opacity" Value="0.7"/><Setter Property="FontSize" Value="12"/></Style>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
@@ -0,0 +1,16 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner;
|
||||
|
||||
public sealed class App : Application
|
||||
{
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
desktop.MainWindow = new MainWindow();
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="PDFtoImage" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,130 @@
|
||||
<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:LehrerApp.TemplateDesigner"
|
||||
x:Class="LehrerApp.TemplateDesigner.MainWindow" x:DataType="local:DesignerViewModel" Title="LehrerApp Vorlagen-Designer"
|
||||
Width="1320" Height="860" MinWidth="1050" MinHeight="700" WindowStartupLocation="CenterScreen">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Border Padding="18,12" Background="#172554">
|
||||
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto">
|
||||
<StackPanel><TextBlock Text="Vorlagen-Designer" Foreground="White" FontWeight="Bold" FontSize="20"/>
|
||||
<TextBlock Text="Portable PDF-Briefpakete · Schema 1" Foreground="#BFDBFE" FontSize="12"/></StackPanel>
|
||||
<Button Grid.Column="1" Content="Neues Projekt" Click="OnNew"/>
|
||||
<Button Grid.Column="3" Content="Paket öffnen …" Click="OnOpenPackage"/>
|
||||
<Button Grid.Column="5" Content="Exportieren …" Click="OnExport" IsEnabled="{Binding CanExport}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid Grid.Row="1" ColumnDefinitions="460,*,390">
|
||||
<ScrollViewer Grid.Column="0" Padding="18">
|
||||
<StackPanel Spacing="14">
|
||||
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Meine Ausgangsvorlagen" Classes="section"/>
|
||||
<Button Grid.Column="1" Content="Importieren …" Click="OnImportStarterTemplate"/></Grid>
|
||||
<ComboBox ItemsSource="{Binding StarterTemplates}" SelectedItem="{Binding SelectedStarterTemplate}"
|
||||
DisplayMemberBinding="{Binding Display}" PlaceholderText="Noch keine Ausgangsvorlage"/>
|
||||
<TextBlock Text="{Binding SelectedStarterTemplate.Description}" FontSize="12" Opacity="0.7"
|
||||
TextWrapping="Wrap"/>
|
||||
<Grid ColumnDefinitions="*,8,*,8,*" RowDefinitions="Auto,8,Auto,8,Auto">
|
||||
<Button Grid.Column="0" Content="Vorschau" Click="OnPreviewStarterTemplate"/>
|
||||
<Button Grid.Column="2" Content="Bearbeiten" Click="OnEditStarterTemplate"/>
|
||||
<Button Grid.Column="4" Content="Als neues Projekt" Click="OnUseStarterTemplate"/>
|
||||
<Button Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" Content="Aktuelles Projekt speichern/aktualisieren"
|
||||
Click="OnSaveStarterTemplate"/>
|
||||
<Button Grid.Row="2" Grid.Column="4" Content="Duplizieren" Click="OnDuplicateStarterTemplate"/>
|
||||
<Button Grid.Row="4" Grid.Column="0" Content="Exportieren …" Click="OnExportStarterTemplate"/>
|
||||
<Button Grid.Row="4" Grid.Column="2" Content="Löschen" Click="OnDeleteStarterTemplate"/>
|
||||
</Grid>
|
||||
<TextBlock Text="Ausgangsvorlagen werden lokal gespeichert. Beim Erstellen eines neuen Projekts bleiben Original und Briefkopf unverändert."
|
||||
FontSize="11" Opacity="0.6" TextWrapping="Wrap"/>
|
||||
<Separator/>
|
||||
|
||||
<TextBlock Text="Paket" Classes="section"/>
|
||||
<Grid ColumnDefinitions="*,10,*" RowDefinitions="Auto,Auto">
|
||||
<StackPanel><TextBlock Text="Stabile ID" Classes="label"/><TextBox Text="{Binding TemplateId}"/></StackPanel>
|
||||
<StackPanel Grid.Column="2"><TextBlock Text="Name" Classes="label"/><TextBox Text="{Binding TemplateName}"/></StackPanel>
|
||||
<StackPanel Grid.Row="1" Grid.ColumnSpan="3" Margin="0,8,0,0"><TextBlock Text="Beschreibung" Classes="label"/><TextBox Text="{Binding Description}"/></StackPanel>
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="*,8,*,8,*">
|
||||
<StackPanel><TextBlock Text="Breite" Classes="label"/><NumericUpDown Value="{Binding PageWidth}" Minimum="10" Maximum="2000"/></StackPanel>
|
||||
<StackPanel Grid.Column="2"><TextBlock Text="Höhe" Classes="label"/><NumericUpDown Value="{Binding PageHeight}" Minimum="10" Maximum="2000"/></StackPanel>
|
||||
<StackPanel Grid.Column="4"><TextBlock Text="Einheit" Classes="label"/><ComboBox ItemsSource="{Binding Units}" SelectedItem="{Binding Unit}"/></StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Platzhalter & Beispieldaten" Classes="section"/>
|
||||
<Button Grid.Column="1" Content="+" Click="OnAddPlaceholder"/></Grid>
|
||||
<DataGrid x:Name="PlaceholderGrid" ItemsSource="{Binding Placeholders}" SelectedItem="{Binding SelectedPlaceholder}"
|
||||
AutoGenerateColumns="False" Height="210" CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="*"/>
|
||||
<DataGridTextColumn Header="Typ" Binding="{Binding Type}" Width="105" IsReadOnly="True"/>
|
||||
<DataGridCheckBoxColumn Header="Pflicht" Binding="{Binding Required}" Width="58"/>
|
||||
<DataGridTextColumn Header="Beispiel" Binding="{Binding Sample}" Width="*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<Grid ColumnDefinitions="Auto,10,*">
|
||||
<Button Content="Markierten Platzhalter entfernen" Click="OnRemovePlaceholder"/>
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<TextBlock Text="Typ:" VerticalAlignment="Center"/>
|
||||
<ComboBox Width="130" ItemsSource="{Binding PlaceholderTypes}" SelectedItem="{Binding SelectedPlaceholder.Type}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Paket-Assets" Classes="section"/>
|
||||
<Button Grid.Column="1" Content="+ Bild importieren …" Click="OnImportAsset"/></Grid>
|
||||
<DataGrid ItemsSource="{Binding AssetItems}" SelectedItem="{Binding SelectedAsset}"
|
||||
AutoGenerateColumns="False" Height="150" IsReadOnly="True" CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Datei" Binding="{Binding Name}" Width="*"/>
|
||||
<DataGridTextColumn Header="Auflösung" Binding="{Binding Dimensions}" Width="125"/>
|
||||
<DataGridTextColumn Header="Größe" Binding="{Binding FileSize}" Width="70"/>
|
||||
<DataGridTextColumn Header="Verwendung" Binding="{Binding Usage}" Width="105"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<Grid ColumnDefinitions="*,8,*,8,*">
|
||||
<Button Grid.Column="0" Content="Als IMG einfügen" Click="OnInsertSelectedAsset"/>
|
||||
<Button Grid.Column="2" Content="Ersetzen …" Click="OnReplaceSelectedAsset"/>
|
||||
<Button Grid.Column="4" Content="Entfernen" Click="OnRemoveSelectedAsset"/>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="Element hinzufügen" Classes="section"/>
|
||||
<ComboBox ItemsSource="{Binding ElementTypes}" SelectedItem="{Binding NewElementType}"/>
|
||||
<Grid ColumnDefinitions="*,6,*,6,*,6,*">
|
||||
<StackPanel><TextBlock Text="X" Classes="label"/><TextBox Text="{Binding NewX}"/></StackPanel>
|
||||
<StackPanel Grid.Column="2"><TextBlock Text="Y" Classes="label"/><TextBox Text="{Binding NewY}"/></StackPanel>
|
||||
<StackPanel Grid.Column="4"><TextBlock Text="Breite" Classes="label"/><TextBox Text="{Binding NewWidth}"/></StackPanel>
|
||||
<StackPanel Grid.Column="6"><TextBlock Text="Höhe" Classes="label"/><TextBox Text="{Binding NewHeight}"/></StackPanel>
|
||||
</Grid>
|
||||
<StackPanel>
|
||||
<TextBlock Text="IMG-Skalierung in % (nur für IMG)" Classes="label"/>
|
||||
<TextBox Text="{Binding NewImageScale}" PlaceholderText="100"/>
|
||||
</StackPanel>
|
||||
<StackPanel><TextBlock Text="Inhalt / $Platzhalter / Assetpfad" Classes="label"/><TextBox Text="{Binding NewContent}" PlaceholderText="$Brieftext oder ein Literal"/></StackPanel>
|
||||
<StackPanel><TextBlock Text="Attribute" Classes="label"/><TextBox Text="{Binding NewAttributes}" PlaceholderText="size=11 bold=true"/></StackPanel>
|
||||
<Button Content="Element ins Layout übernehmen" Click="OnAddElement"/>
|
||||
<Button Content="PNG/JPEG als Hintergrund importieren …" Click="OnImportBackground"/>
|
||||
<Button Content="PDF als Hintergrund (300 DPI) importieren …" Click="OnImportPdfBackground"/>
|
||||
<TextBlock Text="Bekannte Einschränkung: TEXTBOX-Überlauf wird in v1 nicht automatisch auf Folgeseiten verteilt."
|
||||
TextWrapping="Wrap" Foreground="#B45309" FontSize="12"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,*" Margin="0,18">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="12,0,12,10"><TextBlock Text="Layout-DSL" Classes="section"/>
|
||||
<Button Grid.Column="1" Content="Prüfen & Vorschau" Click="OnPreview"/></Grid>
|
||||
<TextBox Grid.Row="1" Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap"
|
||||
FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto" Margin="12"/>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Column="2" Background="#E2E8F0" Padding="18">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<TextBlock Text="PDF-Vorschau" Classes="section"/>
|
||||
<Border Grid.Row="1" Margin="0,12" Background="White" BorderBrush="#94A3B8" BorderThickness="1">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
|
||||
<Image Source="{Binding PreviewImage}" Stretch="Uniform" MaxWidth="740"/>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
<StackPanel Grid.Row="2" Spacing="4"><TextBlock Text="{Binding Status}" TextWrapping="Wrap" Foreground="{Binding StatusColor}"/>
|
||||
<TextBlock Text="Die Vorschau wird mit derselben QuestPDF-Pipeline wie in LehrerApp erzeugt." FontSize="11" Opacity="0.65" TextWrapping="Wrap"/></StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,241 @@
|
||||
using System.IO.Compression;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LehrerApp.Templating;
|
||||
using PDFtoImage;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private readonly DesignerViewModel _viewModel = new();
|
||||
private readonly StarterTemplateLibrary _starterTemplates = new();
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent(); DataContext = _viewModel;
|
||||
RefreshStarterTemplates();
|
||||
}
|
||||
|
||||
private void OnNew(object? sender, RoutedEventArgs e) => _viewModel.Reset();
|
||||
private void OnAddPlaceholder(object? sender, RoutedEventArgs e) =>
|
||||
_viewModel.Placeholders.Add(new($"Feld{_viewModel.Placeholders.Count + 1}", PlaceholderType.Text, false, "Beispiel"));
|
||||
private void OnRemovePlaceholder(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (this.FindControl<DataGrid>("PlaceholderGrid")?.SelectedItem is DesignerPlaceholder selected)
|
||||
_viewModel.Placeholders.Remove(selected);
|
||||
}
|
||||
private void OnAddElement(object? sender, RoutedEventArgs e)
|
||||
{ try { _viewModel.AddElement(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
|
||||
private void OnInsertSelectedAsset(object? sender, RoutedEventArgs e)
|
||||
{ try { _viewModel.InsertSelectedAssetAsImage(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
|
||||
private void OnRemoveSelectedAsset(object? sender, RoutedEventArgs e)
|
||||
{ try { _viewModel.RemoveSelectedAsset(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
|
||||
|
||||
private async void OnImportAsset(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new()
|
||||
{ Title = "Bilder ins Vorlagenpaket importieren", AllowMultiple = true, FileTypeFilter = [ImageType()] });
|
||||
if (files.Count == 0) return;
|
||||
try
|
||||
{
|
||||
foreach (var file in files)
|
||||
_viewModel.ImportAsset(file.Name, await File.ReadAllBytesAsync(file.Path.LocalPath));
|
||||
_viewModel.CanExport = false;
|
||||
_viewModel.SetStatus(files.Count == 1
|
||||
? $"Asset „{_viewModel.SelectedAsset?.Name}“ importiert."
|
||||
: $"{files.Count} Assets importiert.", false);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Bildimport fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private async void OnReplaceSelectedAsset(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedAsset is null) { _viewModel.SetStatus("Bitte zuerst ein Asset auswählen.", true); return; }
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new()
|
||||
{ Title = $"„{_viewModel.SelectedAsset.Name}“ ersetzen", AllowMultiple = false, FileTypeFilter = [ImageType()] });
|
||||
if (files.Count == 0) return;
|
||||
try { _viewModel.ReplaceSelectedAsset(await File.ReadAllBytesAsync(files[0].Path.LocalPath)); }
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Ersetzen fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private void OnPreview(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = new QuestTemplateRenderer().RenderFirstPageToPng(_viewModel.BuildLoaded(), _viewModel.BuildDataProvider());
|
||||
using var stream = new MemoryStream(bytes);
|
||||
_viewModel.PreviewImage?.Dispose(); _viewModel.PreviewImage = new Bitmap(stream);
|
||||
_viewModel.CanExport = true; _viewModel.SetStatus("Validierung erfolgreich. Vorschau ist aktuell.", false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{ _viewModel.CanExport = false; _viewModel.SetStatus(ex.Message, true); }
|
||||
}
|
||||
|
||||
private void OnPreviewStarterTemplate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedStarterTemplate is not { } selected)
|
||||
{ _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; }
|
||||
try { var (template, _) = _starterTemplates.Load(selected); _viewModel.PreviewTemplate(template); }
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Vorschau fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private void OnEditStarterTemplate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedStarterTemplate is not { } selected)
|
||||
{ _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; }
|
||||
try
|
||||
{
|
||||
var (template, layout) = _starterTemplates.Load(selected);
|
||||
_viewModel.Load(template, layout); OnPreview(sender, e);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Öffnen fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private void OnUseStarterTemplate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedStarterTemplate is not { } selected)
|
||||
{ _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; }
|
||||
try
|
||||
{
|
||||
var (template, layout) = _starterTemplates.Load(selected);
|
||||
var projectId = _starterTemplates.CreateUniqueId(template.Manifest.Id + "-neu");
|
||||
_viewModel.LoadAsNewProject(template, layout, projectId); OnPreview(sender, e);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Klonen fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private void OnSaveStarterTemplate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
_viewModel.BuildLoaded();
|
||||
var saved = _starterTemplates.Save(_viewModel.BuildManifest(), _viewModel.LayoutSource, _viewModel.Assets);
|
||||
RefreshStarterTemplates(saved.Id);
|
||||
_viewModel.SetStatus($"Ausgangsvorlage „{saved.Name}“ lokal gespeichert/aktualisiert.", false);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Speichern fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private void OnDuplicateStarterTemplate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedStarterTemplate is not { } selected)
|
||||
{ _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; }
|
||||
try
|
||||
{
|
||||
var duplicate = _starterTemplates.Duplicate(selected);
|
||||
RefreshStarterTemplates(duplicate.Id);
|
||||
_viewModel.SetStatus($"Ausgangsvorlage als „{duplicate.Name}“ dupliziert.", false);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Duplizieren fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private async void OnImportStarterTemplate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new()
|
||||
{ Title = "Ausgangsvorlage importieren", AllowMultiple = false, FileTypeFilter = [PackageType()] });
|
||||
if (files.Count == 0) return;
|
||||
try
|
||||
{
|
||||
var imported = _starterTemplates.Import(files[0].Path.LocalPath);
|
||||
RefreshStarterTemplates(imported.Id);
|
||||
_viewModel.SetStatus($"Ausgangsvorlage „{imported.Name}“ importiert.", false);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Import fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private async void OnExportStarterTemplate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedStarterTemplate is not { } selected)
|
||||
{ _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; }
|
||||
var file = await StorageProvider.SaveFilePickerAsync(new()
|
||||
{ Title = "Ausgangsvorlage exportieren", SuggestedFileName = selected.Id + TemplatePackage.Extension,
|
||||
DefaultExtension = TemplatePackage.Extension[1..], FileTypeChoices = [PackageType()] });
|
||||
if (file is null) return;
|
||||
try
|
||||
{
|
||||
_starterTemplates.Export(selected, file.Path.LocalPath);
|
||||
_viewModel.SetStatus($"Ausgangsvorlage exportiert: {file.Name}", false);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Export fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private void OnDeleteStarterTemplate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedStarterTemplate is not { } selected)
|
||||
{ _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; }
|
||||
try
|
||||
{
|
||||
_starterTemplates.Delete(selected); RefreshStarterTemplates();
|
||||
_viewModel.SetStatus($"Ausgangsvorlage „{selected.Name}“ gelöscht.", false);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Löschen fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private async void OnOpenPackage(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new()
|
||||
{ Title = "Vorlagenpaket öffnen", AllowMultiple = false, FileTypeFilter = [PackageType()] });
|
||||
if (files.Count == 0) return;
|
||||
try
|
||||
{
|
||||
var path = files[0].Path.LocalPath; var loaded = new TemplateLoader().LoadFromPackage(path);
|
||||
using var archive = ZipFile.OpenRead(path); using var reader = new StreamReader(archive.GetEntry(loaded.Manifest.LayoutFile)!.Open());
|
||||
_viewModel.Load(loaded, reader.ReadToEnd()); OnPreview(sender, e);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Import fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private async void OnExport(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try { _viewModel.BuildLoaded(); }
|
||||
catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); return; }
|
||||
var file = await StorageProvider.SaveFilePickerAsync(new()
|
||||
{ Title = "Vorlagenpaket exportieren", SuggestedFileName = _viewModel.TemplateId + TemplatePackage.Extension,
|
||||
DefaultExtension = TemplatePackage.Extension[1..], FileTypeChoices = [PackageType()] });
|
||||
if (file is null) return;
|
||||
try
|
||||
{
|
||||
TemplatePackage.Create(file.Path.LocalPath, _viewModel.BuildManifest(), _viewModel.LayoutSource, _viewModel.Assets);
|
||||
_viewModel.SetStatus($"Paket exportiert: {file.Name}", false);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Export fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private async void OnImportBackground(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new()
|
||||
{ Title = "Hintergrundbild importieren", AllowMultiple = false,
|
||||
FileTypeFilter = [ImageType()] });
|
||||
if (files.Count == 0) return;
|
||||
try { _viewModel.ImportBackground(files[0].Name, await File.ReadAllBytesAsync(files[0].Path.LocalPath)); }
|
||||
catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); }
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
[SupportedOSPlatform("linux")]
|
||||
[SupportedOSPlatform("macos")]
|
||||
private async void OnImportPdfBackground(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new()
|
||||
{ Title = "PDF-Briefkopf importieren", AllowMultiple = false,
|
||||
FileTypeFilter = [new("PDF-Dateien") { Patterns = ["*.pdf"] }] });
|
||||
if (files.Count == 0) return;
|
||||
var temporary = Path.Combine(Path.GetTempPath(), $"lehrerapp-bg-{Guid.NewGuid():N}.png");
|
||||
try
|
||||
{
|
||||
await using var pdf = File.OpenRead(files[0].Path.LocalPath);
|
||||
Conversion.SavePng(temporary, pdf, page: 0, options: new RenderOptions(Dpi: 300));
|
||||
_viewModel.ImportBackground("background.png", await File.ReadAllBytesAsync(temporary));
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"PDF-Import fehlgeschlagen: {ex.Message}", true); }
|
||||
finally { if (File.Exists(temporary)) File.Delete(temporary); }
|
||||
}
|
||||
|
||||
private static FilePickerFileType ImageType() => new("PNG/JPEG-Bilder") { Patterns = ["*.png", "*.jpg", "*.jpeg"] };
|
||||
private static FilePickerFileType PackageType() => new("LehrerApp-Vorlagen") { Patterns = ["*.lavorlage"] };
|
||||
private void RefreshStarterTemplates(string? selectedId = null) =>
|
||||
_viewModel.SetStarterTemplates(_starterTemplates.GetAll(), selectedId);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<App>().UsePlatformDetect().WithInterFont().LogToTrace();
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.IO.Compression;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner;
|
||||
|
||||
public sealed record StarterTemplateItem(
|
||||
string Id, string Name, string Description, string PackagePath, DateTime UpdatedAtUtc)
|
||||
{
|
||||
public string Display => string.IsNullOrWhiteSpace(Description) ? Name : $"{Name} · {Description}";
|
||||
}
|
||||
|
||||
public sealed class StarterTemplateLibrary
|
||||
{
|
||||
private readonly string _directory;
|
||||
private readonly ITemplateLoader _loader;
|
||||
|
||||
public StarterTemplateLibrary(string? directory = null, ITemplateLoader? loader = null)
|
||||
{
|
||||
_directory = directory ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"LehrerApp", "TemplateDesigner", "starter-templates");
|
||||
Directory.CreateDirectory(_directory);
|
||||
_loader = loader ?? new TemplateLoader();
|
||||
}
|
||||
|
||||
public IReadOnlyList<StarterTemplateItem> GetAll()
|
||||
{
|
||||
var result = new List<StarterTemplateItem>();
|
||||
foreach (var path in Directory.EnumerateFiles(_directory, $"*{TemplatePackage.Extension}"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var template = _loader.LoadFromPackage(path);
|
||||
result.Add(ToItem(template.Manifest, path));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { }
|
||||
}
|
||||
return result.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
public StarterTemplateItem Save(TemplateManifest manifest, string layoutSource,
|
||||
IReadOnlyDictionary<string, byte[]> assets)
|
||||
{
|
||||
var path = Path.Combine(_directory, SafeId(manifest.Id) + TemplatePackage.Extension);
|
||||
TemplatePackage.Create(path, manifest, layoutSource, assets);
|
||||
return ToItem(manifest, path);
|
||||
}
|
||||
|
||||
public StarterTemplateItem Import(string sourcePath)
|
||||
{
|
||||
var (template, layout) = LoadPackage(sourcePath);
|
||||
return Save(template.Manifest, layout, template.Assets);
|
||||
}
|
||||
|
||||
public void Export(StarterTemplateItem item, string destinationPath)
|
||||
{
|
||||
if (!File.Exists(item.PackagePath)) throw new FileNotFoundException("Ausgangsvorlage nicht gefunden.", item.PackagePath);
|
||||
if (!string.Equals(Path.GetExtension(destinationPath), TemplatePackage.Extension, StringComparison.OrdinalIgnoreCase))
|
||||
destinationPath += TemplatePackage.Extension;
|
||||
var directory = Path.GetDirectoryName(destinationPath);
|
||||
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
File.Copy(item.PackagePath, destinationPath, overwrite: true);
|
||||
}
|
||||
|
||||
public StarterTemplateItem Duplicate(StarterTemplateItem item)
|
||||
{
|
||||
var (template, layout) = Load(item);
|
||||
var id = CreateUniqueId(template.Manifest.Id + "-kopie");
|
||||
var manifest = CopyManifest(template.Manifest, id, template.Manifest.Name + " - Kopie");
|
||||
return Save(manifest, layout, template.Assets);
|
||||
}
|
||||
|
||||
public (LoadedTemplate Template, string LayoutSource) Load(StarterTemplateItem item) =>
|
||||
LoadPackage(item.PackagePath);
|
||||
|
||||
public void Delete(StarterTemplateItem item)
|
||||
{
|
||||
if (File.Exists(item.PackagePath)) File.Delete(item.PackagePath);
|
||||
}
|
||||
|
||||
private (LoadedTemplate Template, string LayoutSource) LoadPackage(string path)
|
||||
{
|
||||
var template = _loader.LoadFromPackage(path);
|
||||
using var archive = ZipFile.OpenRead(path);
|
||||
var layoutEntry = archive.Entries.FirstOrDefault(x => x.FullName.Equals(
|
||||
template.Manifest.LayoutFile.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidDataException($"Layoutdatei „{template.Manifest.LayoutFile}“ fehlt.");
|
||||
using var reader = new StreamReader(layoutEntry.Open());
|
||||
return (template, reader.ReadToEnd());
|
||||
}
|
||||
|
||||
public string CreateUniqueId(string baseId)
|
||||
{
|
||||
var existing = GetAll().Select(x => x.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
if (!existing.Contains(baseId)) return baseId;
|
||||
for (var index = 2; ; index++)
|
||||
{
|
||||
var candidate = $"{baseId}-{index}";
|
||||
if (!existing.Contains(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
private static TemplateManifest CopyManifest(TemplateManifest source, string id, string name) => new()
|
||||
{
|
||||
SchemaVersion = source.SchemaVersion,
|
||||
Id = id,
|
||||
Name = name,
|
||||
Description = source.Description,
|
||||
PageSize = new(source.PageSize.Width, source.PageSize.Height, source.PageSize.Unit),
|
||||
LayoutFile = source.LayoutFile,
|
||||
Placeholders = source.Placeholders.Select(x => new PlaceholderDefinition(x.Name, x.Type, x.Required)).ToList(),
|
||||
};
|
||||
|
||||
private static StarterTemplateItem ToItem(TemplateManifest manifest, string path) =>
|
||||
new(manifest.Id, manifest.Name, manifest.Description, path, File.GetLastWriteTimeUtc(path));
|
||||
|
||||
private static string SafeId(string id)
|
||||
{
|
||||
var safe = string.Concat(id.Select(c => char.IsAsciiLetterOrDigit(c) || c == '-' ? c : '-')).Trim('-');
|
||||
return safe.Length > 0 ? safe : throw new InvalidDataException("Die Vorlagen-ID ist ungültig.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><IsPackable>false</IsPackable></PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio"><PrivateAssets>all</PrivateAssets></PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.IO.Compression;
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Templating.Tests;
|
||||
|
||||
public sealed class TemplatingTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lavorlage-{Guid.NewGuid():N}");
|
||||
public TemplatingTests() => Directory.CreateDirectory(_directory);
|
||||
|
||||
[Fact]
|
||||
public void LayoutParser_LiestAlleElementtypenUndAttribute()
|
||||
{
|
||||
var layout = new LayoutParser().Parse("""
|
||||
# Beispiel
|
||||
PAGE 210 297 mm
|
||||
BG background.png
|
||||
IMG logo.png 15 15 30 12 scale=50%
|
||||
TEXT 20 45 $Datum|dd.MM.yyyy size=10
|
||||
TEXTBOX 20 90 170 120 $Brieftext wrap=true
|
||||
TABLE 20 215 170 40 $Zeilen size=9
|
||||
CHART 20 260 170 25 $Werte type=line
|
||||
""");
|
||||
|
||||
Assert.Equal(6, layout.Elements.Count);
|
||||
Assert.Equal("50%", Assert.IsType<ImageElement>(layout.Elements[1]).Attributes["scale"]);
|
||||
Assert.Equal("dd.MM.yyyy", Assert.IsType<TextElement>(layout.Elements[2]).Format);
|
||||
Assert.Equal("line", Assert.IsType<ChartElement>(layout.Elements[5]).ChartType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("scale=0%")]
|
||||
[InlineData("scale=-10%")]
|
||||
[InlineData("scale=1001%")]
|
||||
[InlineData("scale=abc")]
|
||||
public void LayoutParser_LehntUngueltigeBildskalierungAb(string attribute)
|
||||
{
|
||||
var exception = Assert.Throws<TemplateValidationException>(() =>
|
||||
new LayoutParser().Parse($"PAGE 210 297 mm\nIMG logo.png 10 10 20 20 {attribute}"));
|
||||
|
||||
Assert.Contains(exception.Result.Issues, issue => issue.Message.Contains("scale", StringComparison.OrdinalIgnoreCase)
|
||||
|| issue.Message.Contains("keine Zahl", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LayoutParser_MeldetAlleSyntaxfehlerMitZeilen()
|
||||
{
|
||||
var exception = Assert.Throws<TemplateValidationException>(() => new LayoutParser().Parse("""
|
||||
TEXT 1 2 "vor PAGE"
|
||||
PAGE x 297 mm
|
||||
UNBEKANNT 1
|
||||
"""));
|
||||
|
||||
Assert.True(exception.Result.Issues.Count >= 3);
|
||||
Assert.Equal(3, exception.Result.Issues.Count(issue => issue.Line is not null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loader_BlockiertPathTraversal()
|
||||
{
|
||||
var path = Path.Combine(_directory, "unsafe.lavorlage");
|
||||
using (var archive = ZipFile.Open(path, ZipArchiveMode.Create))
|
||||
{
|
||||
Write(archive, "../manifest.json", "{}");
|
||||
Write(archive, "manifest.json", ManifestJson());
|
||||
Write(archive, "layout.tpl", "PAGE 210 297 mm");
|
||||
}
|
||||
|
||||
var exception = Assert.Throws<TemplateValidationException>(() => new TemplateLoader().LoadFromPackage(path));
|
||||
Assert.Contains(exception.Result.Issues, x => x.Message.Contains("Unsicherer Paketpfad"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loader_SammeltUnbekanntenPlatzhalterUndFehlendesBild()
|
||||
{
|
||||
var path = Path.Combine(_directory, "invalid.lavorlage");
|
||||
using (var archive = ZipFile.Open(path, ZipArchiveMode.Create))
|
||||
{
|
||||
Write(archive, "manifest.json", ManifestJson());
|
||||
Write(archive, "layout.tpl", "PAGE 210 297 mm\nBG fehlt.png\nTEXT 10 10 $Unbekannt");
|
||||
}
|
||||
|
||||
var exception = Assert.Throws<TemplateValidationException>(() => new TemplateLoader().LoadFromPackage(path));
|
||||
Assert.Equal(2, exception.Result.Issues.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_SammeltFehlendeUndFalscheDatentypen()
|
||||
{
|
||||
var loaded = new LoadedTemplate(new TemplateManifest
|
||||
{
|
||||
Placeholders = [new("Datum", PlaceholderType.Date, true), new("Text", PlaceholderType.Text, true)],
|
||||
}, new(210, 297, "mm", []), new Dictionary<string, byte[]>());
|
||||
|
||||
var result = new TemplateLoader().Validate(loaded,
|
||||
new Dictionary<string, PlaceholderType> { ["Datum"] = PlaceholderType.Text });
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(2, result.Issues.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Paket_RoundtripUndPdfSindGueltig()
|
||||
{
|
||||
var path = Path.Combine(_directory, "brief.lavorlage");
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = "brief", Name = "Brief",
|
||||
Placeholders = [new("Datum", PlaceholderType.Date, true), new("Text", PlaceholderType.Multiline, true)],
|
||||
};
|
||||
TemplatePackage.Create(path, manifest, """
|
||||
PAGE 210 297 mm
|
||||
TEXT 20 20 "Elternbrief" size=18 bold=true color=#1D4ED8
|
||||
TEXT 20 35 $Datum|dd.MM.yyyy size=10
|
||||
TEXTBOX 20 50 170 200 $Text size=11
|
||||
""", new Dictionary<string, byte[]>());
|
||||
var loaded = new TemplateLoader().LoadFromPackage(path);
|
||||
var pdf = new QuestTemplateRenderer().RenderToPdf(loaded, new DictionaryProvider(new Dictionary<string, PlaceholderValue>
|
||||
{
|
||||
["Datum"] = new DateValue(new DateOnly(2026, 8, 30)),
|
||||
["Text"] = new MultilineValue("Sehr geehrte Eltern,\n\ndies ist ein Testbrief."),
|
||||
}));
|
||||
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
|
||||
Assert.True(pdf.Length > 1_000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Paket_RoundtripErhaeltAuchNochNichtVerwendeteBildAssets()
|
||||
{
|
||||
var path = Path.Combine(_directory, "assets.lavorlage");
|
||||
var png = Convert.FromBase64String(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=");
|
||||
TemplatePackage.Create(path, new TemplateManifest { Id = "assets", Name = "Assets" },
|
||||
"PAGE 210 297 mm\nIMG logo.png 10 10 20 20",
|
||||
new Dictionary<string, byte[]> { ["logo.png"] = png, ["assets/siegel.png"] = png });
|
||||
|
||||
var loaded = new TemplateLoader().LoadFromPackage(path);
|
||||
|
||||
Assert.Equal(2, loaded.Assets.Count);
|
||||
Assert.Contains("assets/siegel.png", loaded.Assets.Keys);
|
||||
}
|
||||
|
||||
private static string ManifestJson() => """
|
||||
{ "schemaVersion": 1, "id": "test", "name": "Test",
|
||||
"pageSize": { "width": 210, "height": 297, "unit": "mm" },
|
||||
"layoutFile": "layout.tpl", "placeholders": [] }
|
||||
""";
|
||||
private static void Write(ZipArchive archive, string path, string content)
|
||||
{ using var writer = new StreamWriter(archive.CreateEntry(path).Open()); writer.Write(content); }
|
||||
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||
}
|
||||
|
||||
internal sealed class DictionaryProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
|
||||
{
|
||||
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
public sealed class LayoutParser
|
||||
{
|
||||
public TemplateLayout Parse(string source)
|
||||
{
|
||||
var issues = new List<ValidationIssue>();
|
||||
var elements = new List<TemplateElement>();
|
||||
float width = 0, height = 0;
|
||||
var unit = "mm";
|
||||
var pageSeen = false;
|
||||
var lines = source.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
|
||||
|
||||
for (var index = 0; index < lines.Length; index++)
|
||||
{
|
||||
var lineNumber = index + 1;
|
||||
var raw = lines[index].Trim();
|
||||
if (raw.Length == 0 || raw.StartsWith('#')) continue;
|
||||
try
|
||||
{
|
||||
var tokens = Tokenize(raw);
|
||||
if (tokens.Count == 0) continue;
|
||||
var keyword = tokens[0].ToUpperInvariant();
|
||||
if (!pageSeen && keyword != "PAGE")
|
||||
throw new FormatException("PAGE muss das erste Statement sein.");
|
||||
|
||||
switch (keyword)
|
||||
{
|
||||
case "PAGE":
|
||||
if (pageSeen) throw new FormatException("PAGE darf nur einmal vorkommen.");
|
||||
Require(tokens, 4);
|
||||
width = Number(tokens[1]); height = Number(tokens[2]); unit = tokens[3].ToLowerInvariant();
|
||||
if (width <= 0 || height <= 0) throw new FormatException("Seitengröße muss positiv sein.");
|
||||
if (unit is not ("mm" or "cm" or "pt" or "in"))
|
||||
throw new FormatException("Einheit muss mm, cm, pt oder in sein.");
|
||||
pageSeen = true;
|
||||
break;
|
||||
case "BG":
|
||||
Require(tokens, 2); elements.Add(new BackgroundElement(lineNumber, tokens[1])); break;
|
||||
case "IMG":
|
||||
Require(tokens, 6);
|
||||
var imageAttributes = Attributes(tokens, 6);
|
||||
if (imageAttributes.TryGetValue("scale", out var scale)) Percentage(scale);
|
||||
elements.Add(new ImageElement(lineNumber, tokens[1],
|
||||
Number(tokens[2]), Number(tokens[3]), Number(tokens[4]), Number(tokens[5]), imageAttributes));
|
||||
break;
|
||||
case "TEXT":
|
||||
Require(tokens, 4);
|
||||
var textRef = Reference(tokens[3]);
|
||||
elements.Add(new TextElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
|
||||
tokens[3], textRef.Name, textRef.Format, Attributes(tokens, 4))); break;
|
||||
case "TEXTBOX":
|
||||
Require(tokens, 6);
|
||||
var boxRef = Reference(tokens[5]);
|
||||
elements.Add(new TextBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
|
||||
Number(tokens[3]), Number(tokens[4]), tokens[5], boxRef.Name, boxRef.Format,
|
||||
Attributes(tokens, 6))); break;
|
||||
case "TABLE":
|
||||
Require(tokens, 6);
|
||||
elements.Add(new TableElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
|
||||
Number(tokens[3]), Number(tokens[4]), RequiredReference(tokens[5]), Attributes(tokens, 6)));
|
||||
break;
|
||||
case "CHART":
|
||||
Require(tokens, 6);
|
||||
var attributes = Attributes(tokens, 6);
|
||||
var chartType = attributes.GetValueOrDefault("type", "bar").ToLowerInvariant();
|
||||
if (chartType is not ("line" or "bar")) throw new FormatException("CHART type muss line oder bar sein.");
|
||||
elements.Add(new ChartElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
|
||||
Number(tokens[3]), Number(tokens[4]), RequiredReference(tokens[5]), chartType, attributes));
|
||||
break;
|
||||
default: throw new FormatException($"Unbekanntes Element „{tokens[0]}“.");
|
||||
}
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
issues.Add(new(ValidationSeverity.Error, $"Zeile {lineNumber}: {ex.Message}", lineNumber));
|
||||
}
|
||||
}
|
||||
|
||||
if (!pageSeen) issues.Add(new(ValidationSeverity.Error, "PAGE fehlt."));
|
||||
if (issues.Count > 0) throw new TemplateValidationException(new(issues));
|
||||
return new(width, height, unit, elements);
|
||||
}
|
||||
|
||||
private static (string? Name, string? Format) Reference(string value)
|
||||
{
|
||||
if (!value.StartsWith('$')) return (null, null);
|
||||
var parts = value[1..].Split('|', 2);
|
||||
if (string.IsNullOrWhiteSpace(parts[0])) throw new FormatException("Platzhaltername fehlt.");
|
||||
return (parts[0], parts.Length == 2 ? parts[1] : null);
|
||||
}
|
||||
|
||||
private static string RequiredReference(string value) => Reference(value).Name
|
||||
?? throw new FormatException("Das Element erwartet einen $Platzhalter.");
|
||||
|
||||
private static float Number(string value) => float.TryParse(value, NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture, out var result) ? result : throw new FormatException($"„{value}“ ist keine Zahl.");
|
||||
|
||||
internal static float Percentage(string value)
|
||||
{
|
||||
var normalized = value.EndsWith('%') ? value[..^1] : value;
|
||||
var result = Number(normalized);
|
||||
if (result <= 0 || result > 1000)
|
||||
throw new FormatException("scale muss zwischen 0 und 1000 Prozent liegen.");
|
||||
return result / 100f;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> Attributes(IReadOnlyList<string> tokens, int start)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = start; i < tokens.Count; i++)
|
||||
{
|
||||
var separator = tokens[i].IndexOf('=');
|
||||
if (separator <= 0 || separator == tokens[i].Length - 1)
|
||||
throw new FormatException($"Ungültiges Attribut „{tokens[i]}“.");
|
||||
result[tokens[i][..separator]] = tokens[i][(separator + 1)..];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void Require(IReadOnlyCollection<string> tokens, int minimum)
|
||||
{
|
||||
if (tokens.Count < minimum) throw new FormatException("Zu wenige Argumente.");
|
||||
}
|
||||
|
||||
internal static List<string> Tokenize(string line)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
var current = new StringBuilder();
|
||||
var quoted = false;
|
||||
var escaped = false;
|
||||
foreach (var character in line)
|
||||
{
|
||||
if (escaped) { current.Append(character); escaped = false; continue; }
|
||||
if (character == '\\' && quoted) { escaped = true; continue; }
|
||||
if (character == '"') { quoted = !quoted; continue; }
|
||||
if (char.IsWhiteSpace(character) && !quoted)
|
||||
{
|
||||
if (current.Length > 0) { tokens.Add(current.ToString()); current.Clear(); }
|
||||
}
|
||||
else current.Append(character);
|
||||
}
|
||||
if (quoted) throw new FormatException("Nicht abgeschlossenes Anführungszeichen.");
|
||||
if (current.Length > 0) tokens.Add(current.ToString());
|
||||
return tokens;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<PackageReference Include="QuestPDF" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,105 @@
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
public enum PlaceholderType { Text, Multiline, Date, Number, Image, Table, Chart }
|
||||
|
||||
public abstract record PlaceholderValue(PlaceholderType Type);
|
||||
public sealed record TextValue(string Value) : PlaceholderValue(PlaceholderType.Text);
|
||||
public sealed record MultilineValue(string Value) : PlaceholderValue(PlaceholderType.Multiline);
|
||||
public sealed record DateValue(DateOnly Value) : PlaceholderValue(PlaceholderType.Date);
|
||||
public sealed record NumberValue(decimal Value) : PlaceholderValue(PlaceholderType.Number);
|
||||
public sealed record ImageValue(byte[] Data, string MimeType) : PlaceholderValue(PlaceholderType.Image);
|
||||
public sealed record ImageMetadata(int Width, int Height);
|
||||
public sealed record TableValue(IReadOnlyList<string> Columns, IReadOnlyList<IReadOnlyList<string>> Rows)
|
||||
: PlaceholderValue(PlaceholderType.Table);
|
||||
public sealed record ChartPoint(string X, decimal Y);
|
||||
public sealed record ChartSeries(string Label, IReadOnlyList<ChartPoint> Points);
|
||||
public sealed record ChartValue(IReadOnlyList<ChartSeries> Series) : PlaceholderValue(PlaceholderType.Chart);
|
||||
|
||||
public interface ITemplateDataProvider
|
||||
{
|
||||
IReadOnlyDictionary<string, PlaceholderValue> GetValues();
|
||||
}
|
||||
|
||||
public sealed record PageSizeDefinition(float Width, float Height, string Unit = "mm");
|
||||
public sealed record PlaceholderDefinition(string Name, PlaceholderType Type, bool Required = false);
|
||||
|
||||
public sealed class TemplateManifest
|
||||
{
|
||||
public int SchemaVersion { get; set; } = 1;
|
||||
public string Id { get; set; } = "neue-vorlage";
|
||||
public string Name { get; set; } = "Neue Vorlage";
|
||||
public string Description { get; set; } = "";
|
||||
public PageSizeDefinition PageSize { get; set; } = new(210, 297);
|
||||
public string LayoutFile { get; set; } = "layout.tpl";
|
||||
public List<PlaceholderDefinition> Placeholders { get; set; } = [];
|
||||
}
|
||||
|
||||
public abstract record TemplateElement(int Line, float X, float Y, float Width, float Height,
|
||||
IReadOnlyDictionary<string, string> Attributes);
|
||||
public sealed record BackgroundElement(int Line, string Path)
|
||||
: TemplateElement(Line, 0, 0, 0, 0, EmptyAttributes.Value);
|
||||
public sealed record ImageElement(int Line, string Path, float X, float Y, float Width, float Height,
|
||||
IReadOnlyDictionary<string, string> Attributes)
|
||||
: TemplateElement(Line, X, Y, Width, Height, Attributes);
|
||||
public sealed record TextElement(int Line, float X, float Y, string Content, string? Placeholder,
|
||||
string? Format, IReadOnlyDictionary<string, string> Attributes)
|
||||
: TemplateElement(Line, X, Y, 0, 0, Attributes);
|
||||
public sealed record TextBoxElement(int Line, float X, float Y, float Width, float Height,
|
||||
string Content, string? Placeholder, string? Format, IReadOnlyDictionary<string, string> Attributes)
|
||||
: TemplateElement(Line, X, Y, Width, Height, Attributes);
|
||||
public sealed record TableElement(int Line, float X, float Y, float Width, float Height,
|
||||
string Placeholder, IReadOnlyDictionary<string, string> Attributes)
|
||||
: TemplateElement(Line, X, Y, Width, Height, Attributes);
|
||||
public sealed record ChartElement(int Line, float X, float Y, float Width, float Height,
|
||||
string Placeholder, string ChartType, IReadOnlyDictionary<string, string> Attributes)
|
||||
: TemplateElement(Line, X, Y, Width, Height, Attributes);
|
||||
|
||||
internal static class EmptyAttributes
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<string, string> Value =
|
||||
new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
public sealed record TemplateLayout(float Width, float Height, string Unit,
|
||||
IReadOnlyList<TemplateElement> Elements);
|
||||
|
||||
public sealed record LoadedTemplate(TemplateManifest Manifest, TemplateLayout Layout,
|
||||
IReadOnlyDictionary<string, byte[]> Assets, string SourceName = "");
|
||||
|
||||
public enum ValidationSeverity { Warning, Error }
|
||||
public sealed record ValidationIssue(ValidationSeverity Severity, string Message, int? Line = null);
|
||||
public sealed record ValidationResult(IReadOnlyList<ValidationIssue> Issues)
|
||||
{
|
||||
public bool IsValid => Issues.All(x => x.Severity != ValidationSeverity.Error);
|
||||
public static ValidationResult Success { get; } = new([]);
|
||||
}
|
||||
|
||||
public sealed class TemplateValidationException(ValidationResult result)
|
||||
: Exception(string.Join(Environment.NewLine, result.Issues.Select(x => x.Message)))
|
||||
{
|
||||
public ValidationResult Result { get; } = result;
|
||||
}
|
||||
|
||||
public interface ITemplateRenderer
|
||||
{
|
||||
byte[] RenderToPdf(LoadedTemplate template, ITemplateDataProvider data);
|
||||
}
|
||||
|
||||
public interface ITemplatePreviewRenderer
|
||||
{
|
||||
byte[] RenderFirstPageToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120);
|
||||
}
|
||||
|
||||
public interface ITemplateLoader
|
||||
{
|
||||
LoadedTemplate LoadFromPackage(string packagePath);
|
||||
LoadedTemplate LoadFromPackage(Stream package, string sourceName = "");
|
||||
ValidationResult Validate(LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderType> providedTypes);
|
||||
}
|
||||
|
||||
public interface ITemplateMigrator
|
||||
{
|
||||
int SourceVersion { get; }
|
||||
TemplateManifest Migrate(TemplateManifest manifest);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Globalization;
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewRenderer
|
||||
{
|
||||
static QuestTemplateRenderer() => QuestPDF.Settings.License = LicenseType.Community;
|
||||
|
||||
public byte[] RenderToPdf(LoadedTemplate template, ITemplateDataProvider data) =>
|
||||
BuildDocument(template, ValidateData(template, data)).GeneratePdf();
|
||||
|
||||
public byte[] RenderFirstPageToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
|
||||
{
|
||||
var settings = new ImageGenerationSettings { ImageFormat = ImageFormat.Png, RasterDpi = dpi };
|
||||
return BuildDocument(template, ValidateData(template, data)).GenerateImages(settings).First();
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, PlaceholderValue> ValidateData(LoadedTemplate template,
|
||||
ITemplateDataProvider provider)
|
||||
{
|
||||
var values = provider.GetValues();
|
||||
var validation = new TemplateLoader().Validate(template, values.ToDictionary(x => x.Key, x => x.Value.Type));
|
||||
if (!validation.IsValid) throw new TemplateValidationException(validation);
|
||||
return values;
|
||||
}
|
||||
|
||||
private static IDocument BuildDocument(LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values) => Document.Create(document =>
|
||||
{
|
||||
document.Page(page =>
|
||||
{
|
||||
page.Size(UnitConverter.Points(template.Layout.Width, template.Layout.Unit),
|
||||
UnitConverter.Points(template.Layout.Height, template.Layout.Unit));
|
||||
page.Margin(0);
|
||||
page.Content().Layers(layers =>
|
||||
{
|
||||
layers.PrimaryLayer().Width(UnitConverter.Points(template.Layout.Width, template.Layout.Unit))
|
||||
.Height(UnitConverter.Points(template.Layout.Height, template.Layout.Unit)).Background(Colors.White);
|
||||
foreach (var element in template.Layout.Elements)
|
||||
{
|
||||
var current = element;
|
||||
layers.Layer().Element(container => RenderElement(container, current, template, values));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
private static void RenderElement(IContainer root, TemplateElement element, LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||
{
|
||||
var unit = template.Layout.Unit;
|
||||
switch (element)
|
||||
{
|
||||
case BackgroundElement background:
|
||||
root.Width(UnitConverter.Points(template.Layout.Width, unit))
|
||||
.Height(UnitConverter.Points(template.Layout.Height, unit))
|
||||
.Image(GetAsset(template, background.Path)).FitArea();
|
||||
break;
|
||||
case ImageElement image:
|
||||
var imageScale = image.Attributes.TryGetValue("scale", out var scale)
|
||||
? LayoutParser.Percentage(scale) : 1f;
|
||||
Position(root, image, unit, imageScale).Image(GetAsset(template, image.Path)).FitArea();
|
||||
break;
|
||||
case TextElement text:
|
||||
var textContainer = root.TranslateX(UnitConverter.Points(text.X, unit))
|
||||
.TranslateY(UnitConverter.Points(text.Y, unit))
|
||||
.Width(UnitConverter.Points(Math.Max(0, template.Layout.Width - text.X), unit))
|
||||
.Height(QuestTemplateRenderer.ParseFloat(text.Attributes, "size", 11) * 1.8f);
|
||||
RenderText(textContainer, ResolveContent(text.Content, text.Placeholder, text.Format, values), text.Attributes);
|
||||
break;
|
||||
case TextBoxElement box:
|
||||
RenderText(Position(root, box, unit).Shrink(),
|
||||
ResolveContent(box.Content, box.Placeholder, box.Format, values), box.Attributes);
|
||||
break;
|
||||
case TableElement table:
|
||||
if (values.GetValueOrDefault(table.Placeholder) is TableValue tableValue)
|
||||
TableElementRenderer.Render(Position(root, table, unit), tableValue, table.Attributes);
|
||||
break;
|
||||
case ChartElement chart:
|
||||
if (values.GetValueOrDefault(chart.Placeholder) is ChartValue chartValue)
|
||||
ChartElementRenderer.Render(Position(root, chart, unit), chartValue, chart.ChartType, chart.Attributes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static IContainer Position(IContainer root, TemplateElement element, string unit, float scale = 1) => root
|
||||
.TranslateX(UnitConverter.Points(element.X, unit)).TranslateY(UnitConverter.Points(element.Y, unit))
|
||||
.Width(UnitConverter.Points(element.Width * scale, unit)).Height(UnitConverter.Points(element.Height * scale, unit));
|
||||
|
||||
private static byte[] GetAsset(LoadedTemplate template, string path) =>
|
||||
template.Assets.TryGetValue(TemplateLoader.Normalize(path), out var bytes) ? bytes
|
||||
: throw new InvalidDataException($"Asset „{path}“ fehlt.");
|
||||
|
||||
internal static void RenderText(IContainer container, string content, IReadOnlyDictionary<string, string> attributes)
|
||||
{
|
||||
var aligned = attributes.GetValueOrDefault("align", "left").ToLowerInvariant() switch
|
||||
{ "center" => container.AlignCenter(), "right" => container.AlignRight(), _ => container.AlignLeft() };
|
||||
var descriptor = aligned.Text(content);
|
||||
descriptor.FontSize(ParseFloat(attributes, "size", 11));
|
||||
if (ParseBool(attributes, "bold")) descriptor.SemiBold();
|
||||
if (ParseBool(attributes, "italic")) descriptor.Italic();
|
||||
if (attributes.TryGetValue("color", out var color)) descriptor.FontColor(color);
|
||||
}
|
||||
|
||||
private static string ResolveContent(string content, string? placeholder, string? format,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||
{
|
||||
if (placeholder is not null)
|
||||
return values.TryGetValue(placeholder, out var value) ? Format(value, format) : "";
|
||||
var result = content;
|
||||
foreach (var entry in values)
|
||||
result = result.Replace("$" + entry.Key, Format(entry.Value, null), StringComparison.Ordinal);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Format(PlaceholderValue value, string? format) => value switch
|
||||
{
|
||||
TextValue text => text.Value,
|
||||
MultilineValue text => text.Value,
|
||||
DateValue date => date.Value.ToString(format ?? "d", CultureInfo.GetCultureInfo("de-DE")),
|
||||
NumberValue number => number.Value.ToString(format, CultureInfo.GetCultureInfo("de-DE")),
|
||||
_ => "",
|
||||
};
|
||||
|
||||
internal static float ParseFloat(IReadOnlyDictionary<string, string> attributes, string key, float fallback) =>
|
||||
attributes.TryGetValue(key, out var raw) && float.TryParse(raw, NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture, out var value) ? value : fallback;
|
||||
internal static bool ParseBool(IReadOnlyDictionary<string, string> attributes, string key) =>
|
||||
attributes.TryGetValue(key, out var raw) && bool.TryParse(raw, out var value) && value;
|
||||
}
|
||||
|
||||
public static class UnitConverter
|
||||
{
|
||||
public static float Points(float value, string unit) => unit.ToLowerInvariant() switch
|
||||
{ "mm" => value * 72f / 25.4f, "cm" => value * 72f / 2.54f, "in" => value * 72f, "pt" => value,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(unit), unit, "Unbekannte Einheit.") };
|
||||
}
|
||||
|
||||
internal static class TableElementRenderer
|
||||
{
|
||||
public static void Render(IContainer container, TableValue value, IReadOnlyDictionary<string, string> attributes)
|
||||
{
|
||||
if (value.Columns.Count == 0) return;
|
||||
var fontSize = QuestTemplateRenderer.ParseFloat(attributes, "size", 9);
|
||||
container.Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns => { foreach (var _ in value.Columns) columns.RelativeColumn(); });
|
||||
table.Header(header =>
|
||||
{
|
||||
foreach (var column in value.Columns)
|
||||
header.Cell().Background(Colors.Grey.Lighten2).Border(0.5f).BorderColor(Colors.Grey.Medium)
|
||||
.Padding(3).Text(column).FontSize(fontSize).SemiBold();
|
||||
});
|
||||
foreach (var row in value.Rows)
|
||||
for (var i = 0; i < value.Columns.Count; i++)
|
||||
table.Cell().Border(0.5f).BorderColor(Colors.Grey.Lighten1).Padding(3)
|
||||
.Text(i < row.Count ? row[i] : "").FontSize(fontSize);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ChartElementRenderer
|
||||
{
|
||||
public static void Render(IContainer container, ChartValue value, string chartType,
|
||||
IReadOnlyDictionary<string, string> attributes)
|
||||
{
|
||||
var svg = BuildSvg(value, chartType);
|
||||
container.Svg(svg);
|
||||
}
|
||||
|
||||
private static string BuildSvg(ChartValue value, string chartType)
|
||||
{
|
||||
var points = value.Series.SelectMany(x => x.Points).ToList();
|
||||
if (points.Count == 0) return "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 600 200\"/>";
|
||||
var max = Math.Max(1m, points.Max(x => x.Y));
|
||||
var colors = new[] { "#2563EB", "#DC2626", "#059669", "#7C3AED" };
|
||||
var svg = new StringBuilder("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 600 200\">");
|
||||
svg.Append("<line x1=\"40\" y1=\"170\" x2=\"590\" y2=\"170\" stroke=\"#94A3B8\"/><line x1=\"40\" y1=\"10\" x2=\"40\" y2=\"170\" stroke=\"#94A3B8\"/>");
|
||||
for (var seriesIndex = 0; seriesIndex < value.Series.Count; seriesIndex++)
|
||||
{
|
||||
var series = value.Series[seriesIndex];
|
||||
var color = colors[seriesIndex % colors.Length];
|
||||
var step = 530d / Math.Max(1, series.Points.Count);
|
||||
var coordinates = new List<string>();
|
||||
for (var i = 0; i < series.Points.Count; i++)
|
||||
{
|
||||
var x = 50 + i * step + step / 2; var y = 165 - (double)(series.Points[i].Y / max) * 145;
|
||||
if (chartType == "bar") svg.Append(CultureInfo.InvariantCulture,
|
||||
$"<rect x=\"{x - step * .28:F1}\" y=\"{y:F1}\" width=\"{step * .56:F1}\" height=\"{165 - y:F1}\" fill=\"{color}\" opacity=\".8\"/>");
|
||||
else coordinates.Add(FormattableString.Invariant($"{x:F1},{y:F1}"));
|
||||
svg.Append($"<text x=\"{x:F1}\" y=\"188\" font-size=\"10\" text-anchor=\"middle\" fill=\"#475569\">{SecurityElement.Escape(series.Points[i].X)}</text>");
|
||||
}
|
||||
if (chartType == "line") svg.Append($"<polyline points=\"{string.Join(' ', coordinates)}\" fill=\"none\" stroke=\"{color}\" stroke-width=\"3\"/>");
|
||||
}
|
||||
return svg.Append("</svg>").ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# LehrerApp Templating
|
||||
|
||||
## Bildskalierung
|
||||
|
||||
`IMG` unterstützt neben dem festen Begrenzungsrahmen eine optionale prozentuale Skalierung:
|
||||
|
||||
```text
|
||||
IMG logo.png 15 15 30 12 scale=50%
|
||||
```
|
||||
|
||||
Der Rahmen von `30 × 12` Layout-Einheiten wird dabei auf `15 × 6` skaliert. `x` und `y`
|
||||
bleiben unverändert. Das Bild wird mit erhaltenem Seitenverhältnis in diesen Rahmen eingepasst.
|
||||
Ohne `scale` gilt wie bisher `100%`. Zulässig sind Werte größer als `0` bis einschließlich
|
||||
`1000%`.
|
||||
|
||||
## Wiederverwendbare Ausgangsvorlagen
|
||||
|
||||
Der TemplateDesigner verwaltet lokale Ausgangsvorlagen im Benutzerprofil. Eine Ausgangsvorlage
|
||||
ist weiterhin ein normales `.lavorlage`-Paket und kann deshalb importiert oder exportiert werden.
|
||||
|
||||
- **Bearbeiten** öffnet die Ausgangsvorlage mit ihrer stabilen ID. Erneutes Speichern aktualisiert sie.
|
||||
- **Als neues Projekt** kopiert Layout, Platzhalter und sämtliche Assets, vergibt aber eine neue ID.
|
||||
- **Duplizieren** erzeugt eine weitere unabhängige Ausgangsvorlage.
|
||||
- **Vorschau** rendert die Vorlage mit typgerechten Beispieldaten, ohne das aktuelle Projekt zu ändern.
|
||||
|
||||
Die Bibliothek liegt unter `LehrerApp/TemplateDesigner/starter-templates` im plattformspezifischen
|
||||
Anwendungsdatenverzeichnis und wird nicht in das LehrerApp-Repository oder Release eingebettet.
|
||||
@@ -0,0 +1,179 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
public sealed record TemplateLimits(long MaxPackageBytes = 50 * 1024 * 1024,
|
||||
long MaxAssetBytes = 20 * 1024 * 1024, int MaxImagePixels = 50_000_000);
|
||||
|
||||
public sealed class TemplateLoader(TemplateLimits? limits = null,
|
||||
IEnumerable<ITemplateMigrator>? migrators = null) : ITemplateLoader
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
private readonly TemplateLimits _limits = limits ?? new();
|
||||
private readonly IReadOnlyDictionary<int, ITemplateMigrator> _migrators =
|
||||
(migrators ?? []).ToDictionary(x => x.SourceVersion);
|
||||
internal static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
|
||||
|
||||
public LoadedTemplate LoadFromPackage(string packagePath)
|
||||
{
|
||||
if (!File.Exists(packagePath)) throw new FileNotFoundException("Vorlagenpaket nicht gefunden.", packagePath);
|
||||
var info = new FileInfo(packagePath);
|
||||
if (info.Length > _limits.MaxPackageBytes) throw new InvalidDataException("Vorlagenpaket überschreitet das Größenlimit.");
|
||||
using var stream = File.OpenRead(packagePath);
|
||||
return LoadFromPackage(stream, Path.GetFileName(packagePath));
|
||||
}
|
||||
|
||||
public LoadedTemplate LoadFromPackage(Stream package, string sourceName = "")
|
||||
{
|
||||
var issues = new List<ValidationIssue>();
|
||||
try
|
||||
{
|
||||
using var archive = new ZipArchive(package, ZipArchiveMode.Read, leaveOpen: true);
|
||||
var entries = new Dictionary<string, ZipArchiveEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
if (!IsSafeRelativePath(entry.FullName))
|
||||
issues.Add(new(ValidationSeverity.Error, $"Unsicherer Paketpfad: „{entry.FullName}“."));
|
||||
else if (!string.IsNullOrEmpty(entry.Name)) entries[Normalize(entry.FullName)] = entry;
|
||||
}
|
||||
if (issues.Count > 0) throw new TemplateValidationException(new(issues));
|
||||
|
||||
if (!entries.TryGetValue("manifest.json", out var manifestEntry))
|
||||
throw Error("manifest.json fehlt.");
|
||||
TemplateManifest manifest;
|
||||
try
|
||||
{
|
||||
using var manifestStream = manifestEntry.Open();
|
||||
manifest = JsonSerializer.Deserialize<TemplateManifest>(manifestStream, JsonOptions)
|
||||
?? throw new JsonException("Leeres Manifest.");
|
||||
}
|
||||
catch (JsonException ex) { throw Error($"manifest.json ist ungültig: {ex.Message}"); }
|
||||
|
||||
while (manifest.SchemaVersion != CurrentSchemaVersion &&
|
||||
_migrators.TryGetValue(manifest.SchemaVersion, out var migrator))
|
||||
manifest = migrator.Migrate(manifest);
|
||||
if (manifest.SchemaVersion != CurrentSchemaVersion)
|
||||
throw Error($"schemaVersion {manifest.SchemaVersion} wird nicht unterstützt.");
|
||||
if (!IsSafeRelativePath(manifest.LayoutFile)) throw Error("layoutFile enthält einen unsicheren Pfad.");
|
||||
if (!entries.TryGetValue(Normalize(manifest.LayoutFile), out var layoutEntry))
|
||||
throw Error($"Layoutdatei „{manifest.LayoutFile}“ fehlt.");
|
||||
|
||||
string layoutSource;
|
||||
using (var reader = new StreamReader(layoutEntry.Open())) layoutSource = reader.ReadToEnd();
|
||||
var layout = new LayoutParser().Parse(layoutSource);
|
||||
var referencedAssets = layout.Elements.Select(AssetPath).Where(x => x is not null).Cast<string>()
|
||||
.Select(Normalize).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
foreach (var path in referencedAssets)
|
||||
{
|
||||
if (!IsSafeRelativePath(path)) { issues.Add(new(ValidationSeverity.Error, $"Unsicherer Assetpfad „{path}“.")); continue; }
|
||||
if (!entries.TryGetValue(Normalize(path), out var asset))
|
||||
issues.Add(new(ValidationSeverity.Error, $"Referenziertes Bild „{path}“ fehlt."));
|
||||
}
|
||||
|
||||
var assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
|
||||
var layoutPath = Normalize(manifest.LayoutFile);
|
||||
foreach (var (path, asset) in entries.Where(x =>
|
||||
!x.Key.Equals("manifest.json", StringComparison.OrdinalIgnoreCase)
|
||||
&& !x.Key.Equals(layoutPath, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (asset.Length > _limits.MaxAssetBytes)
|
||||
{ issues.Add(new(ValidationSeverity.Error, $"Bild „{path}“ überschreitet das Größenlimit.")); continue; }
|
||||
using var stream = asset.Open();
|
||||
using var memory = new MemoryStream(); stream.CopyTo(memory);
|
||||
var bytes = memory.ToArray();
|
||||
if (!ImageDimensions.TryRead(bytes, out var imageWidth, out var imageHeight))
|
||||
{ issues.Add(new(ValidationSeverity.Error, $"„{path}“ ist kein unterstütztes PNG/JPEG-Bild.")); continue; }
|
||||
if ((long)imageWidth * imageHeight > _limits.MaxImagePixels)
|
||||
issues.Add(new(ValidationSeverity.Error, $"Bild „{path}“ überschreitet das Auflösungslimit."));
|
||||
else assets[path] = bytes;
|
||||
}
|
||||
|
||||
var declared = manifest.Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var used in UsedPlaceholders(layout).Where(x => !declared.Contains(x)))
|
||||
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{used}“ wird im Layout verwendet, aber nicht im Manifest deklariert."));
|
||||
foreach (var duplicate in manifest.Placeholders.GroupBy(x => x.Name, StringComparer.Ordinal).Where(x => x.Count() > 1))
|
||||
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{duplicate.Key}“ ist mehrfach deklariert."));
|
||||
if (issues.Count > 0) throw new TemplateValidationException(new(issues));
|
||||
return new(manifest, layout, assets, sourceName);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{ throw Error($"Paket ist kein lesbares ZIP-Archiv: {ex.Message}"); }
|
||||
}
|
||||
|
||||
public ValidationResult Validate(LoadedTemplate template, IReadOnlyDictionary<string, PlaceholderType> providedTypes)
|
||||
{
|
||||
var issues = new List<ValidationIssue>();
|
||||
foreach (var placeholder in template.Manifest.Placeholders)
|
||||
{
|
||||
if (!providedTypes.TryGetValue(placeholder.Name, out var actual))
|
||||
{
|
||||
if (placeholder.Required) issues.Add(new(ValidationSeverity.Error, $"Pflichtwert „{placeholder.Name}“ fehlt."));
|
||||
}
|
||||
else if (actual != placeholder.Type)
|
||||
issues.Add(new(ValidationSeverity.Error, $"„{placeholder.Name}“ erwartet {placeholder.Type}, geliefert wurde {actual}."));
|
||||
}
|
||||
return new(issues);
|
||||
}
|
||||
|
||||
public static IEnumerable<string> UsedPlaceholders(TemplateLayout layout) => layout.Elements.Select(x => x switch
|
||||
{
|
||||
TextElement { Placeholder: { } p } => p,
|
||||
TextBoxElement { Placeholder: { } p } => p,
|
||||
TableElement t => t.Placeholder,
|
||||
ChartElement c => c.Placeholder,
|
||||
_ => null,
|
||||
}).Where(x => x is not null).Cast<string>().Distinct(StringComparer.Ordinal);
|
||||
|
||||
internal static bool IsSafeRelativePath(string path) => !string.IsNullOrWhiteSpace(path)
|
||||
&& !Path.IsPathRooted(path) && !path.Split('/', '\\').Any(part => part == ".." || part.Length == 0);
|
||||
internal static string Normalize(string path) => path.Replace('\\', '/');
|
||||
private static string? AssetPath(TemplateElement element) => element switch
|
||||
{ BackgroundElement bg => bg.Path, ImageElement img => img.Path, _ => null };
|
||||
private static TemplateValidationException Error(string message) =>
|
||||
new(new([new(ValidationSeverity.Error, message)]));
|
||||
|
||||
private static JsonSerializerOptions CreateJsonOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true };
|
||||
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ImageDimensions
|
||||
{
|
||||
public static bool TryRead(byte[] data, out int width, out int height)
|
||||
{
|
||||
width = height = 0;
|
||||
if (data.Length >= 24 && data.AsSpan(0, 8).SequenceEqual(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }))
|
||||
{
|
||||
width = ReadBigEndian(data, 16); height = ReadBigEndian(data, 20); return width > 0 && height > 0;
|
||||
}
|
||||
if (data.Length >= 4 && data[0] == 0xFF && data[1] == 0xD8)
|
||||
{
|
||||
var offset = 2;
|
||||
while (offset + 9 < data.Length)
|
||||
{
|
||||
if (data[offset] != 0xFF) { offset++; continue; }
|
||||
var marker = data[offset + 1];
|
||||
if (marker is >= 0xC0 and <= 0xC3)
|
||||
{ height = (data[offset + 5] << 8) | data[offset + 6]; width = (data[offset + 7] << 8) | data[offset + 8]; return true; }
|
||||
if (offset + 3 >= data.Length) break;
|
||||
var length = (data[offset + 2] << 8) | data[offset + 3];
|
||||
if (length < 2) break; offset += length + 2;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
private static int ReadBigEndian(byte[] data, int offset) =>
|
||||
(data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3];
|
||||
}
|
||||
|
||||
public static class ImageInspector
|
||||
{
|
||||
public static ImageMetadata Inspect(byte[] data) => ImageDimensions.TryRead(data, out var width, out var height)
|
||||
? new(width, height)
|
||||
: throw new InvalidDataException("Das Asset ist kein unterstütztes PNG/JPEG-Bild.");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
public static class TemplatePackage
|
||||
{
|
||||
public const string Extension = ".lavorlage";
|
||||
|
||||
public static void Create(string outputPath, TemplateManifest manifest, string layoutSource,
|
||||
IReadOnlyDictionary<string, byte[]> assets)
|
||||
{
|
||||
if (!string.Equals(Path.GetExtension(outputPath), Extension, StringComparison.OrdinalIgnoreCase))
|
||||
outputPath += Extension;
|
||||
var directory = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
var temporary = outputPath + ".tmp";
|
||||
try
|
||||
{
|
||||
using (var archive = ZipFile.Open(temporary, ZipArchiveMode.Create))
|
||||
{
|
||||
WriteText(archive, "manifest.json", JsonSerializer.Serialize(manifest, TemplateLoader.JsonOptions));
|
||||
WriteText(archive, manifest.LayoutFile, layoutSource);
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
if (!TemplateLoader.IsSafeRelativePath(asset.Key))
|
||||
throw new InvalidDataException($"Unsicherer Assetpfad „{asset.Key}“.");
|
||||
var entry = archive.CreateEntry(TemplateLoader.Normalize(asset.Key), CompressionLevel.Optimal);
|
||||
using var stream = entry.Open(); stream.Write(asset.Value);
|
||||
}
|
||||
}
|
||||
using (var verify = File.OpenRead(temporary)) new TemplateLoader().LoadFromPackage(verify, Path.GetFileName(outputPath));
|
||||
File.Move(temporary, outputPath, overwrite: true);
|
||||
}
|
||||
finally { if (File.Exists(temporary)) File.Delete(temporary); }
|
||||
}
|
||||
|
||||
private static void WriteText(ZipArchive archive, string path, string content)
|
||||
{
|
||||
var entry = archive.CreateEntry(TemplateLoader.Normalize(path), CompressionLevel.Optimal);
|
||||
using var writer = new StreamWriter(entry.Open()); writer.Write(content);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record InstalledTemplate(string Id, string Name, string Description, string PackagePath,
|
||||
DateTime ImportedAtUtc);
|
||||
|
||||
public sealed class TemplateStore
|
||||
{
|
||||
private readonly string _directory;
|
||||
private readonly ITemplateLoader _loader;
|
||||
|
||||
public TemplateStore(string appDataPath, ITemplateLoader? loader = null)
|
||||
{
|
||||
_directory = Path.Combine(appDataPath, "letter-template-packages");
|
||||
Directory.CreateDirectory(_directory);
|
||||
_loader = loader ?? new TemplateLoader();
|
||||
}
|
||||
|
||||
public IReadOnlyList<InstalledTemplate> GetTemplates()
|
||||
{
|
||||
var result = new List<InstalledTemplate>();
|
||||
foreach (var path in Directory.EnumerateFiles(_directory, $"*{TemplatePackage.Extension}"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var loaded = _loader.LoadFromPackage(path);
|
||||
result.Add(new(loaded.Manifest.Id, loaded.Manifest.Name, loaded.Manifest.Description, path,
|
||||
File.GetLastWriteTimeUtc(path)));
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or TemplateValidationException) { }
|
||||
}
|
||||
return result.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
public InstalledTemplate Import(string sourcePath)
|
||||
{
|
||||
var loaded = _loader.LoadFromPackage(sourcePath);
|
||||
var safeId = string.Concat(loaded.Manifest.Id.Select(c => char.IsAsciiLetterOrDigit(c) || c == '-' ? c : '-')).Trim('-');
|
||||
if (safeId.Length == 0) throw new InvalidDataException("Die Vorlagen-ID ist ungültig.");
|
||||
var destination = Path.Combine(_directory, safeId + TemplatePackage.Extension);
|
||||
var temporary = destination + ".tmp";
|
||||
File.Copy(sourcePath, temporary, overwrite: true);
|
||||
File.Move(temporary, destination, overwrite: true);
|
||||
return new(loaded.Manifest.Id, loaded.Manifest.Name, loaded.Manifest.Description, destination, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
public LoadedTemplate Load(InstalledTemplate template) => _loader.LoadFromPackage(template.PackagePath);
|
||||
public void Delete(string id)
|
||||
{
|
||||
var match = GetTemplates().FirstOrDefault(x => x.Id == id);
|
||||
if (match is not null && File.Exists(match.PackagePath)) File.Delete(match.PackagePath);
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
using System.IO.Compression;
|
||||
using System.Xml.Linq;
|
||||
using LehrerApp.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Tests;
|
||||
|
||||
public sealed class LetterTemplateServiceTests : IDisposable
|
||||
{
|
||||
private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-tests-{Guid.NewGuid():N}");
|
||||
|
||||
public LetterTemplateServiceTests() => Directory.CreateDirectory(_directory);
|
||||
|
||||
[Fact]
|
||||
public void Validate_OhneInhaltssteuerelement_Warnt()
|
||||
{
|
||||
var path = CreateDocx("<w:p><w:r><w:t>Brief</w:t></w:r></w:p>");
|
||||
|
||||
var result = new LetterTemplateService(_directory).Validate(path);
|
||||
|
||||
Assert.Contains(result.Issues, i => i.Severity == TemplateIssueSeverity.Warning
|
||||
&& i.Message.Contains("keine Inhaltssteuerelemente"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_UnbekannterTag_Warnt()
|
||||
{
|
||||
var path = CreateDocx(Control("Something.EntirelyUnknown"));
|
||||
|
||||
var result = new LetterTemplateService(_directory).Validate(path);
|
||||
|
||||
Assert.Contains(result.Issues, i => i.Tag == "Something.EntirelyUnknown"
|
||||
&& i.Severity == TemplateIssueSeverity.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WahrscheinlicherTippfehler_WarntDeutlichMitVorschlag()
|
||||
{
|
||||
var path = CreateDocx(Control("Contact.Adress"));
|
||||
|
||||
var result = new LetterTemplateService(_directory).Validate(path);
|
||||
|
||||
var issue = Assert.Single(result.Issues);
|
||||
Assert.Equal(TemplateIssueSeverity.StrongWarning, issue.Severity);
|
||||
Assert.Equal("Contact.Address", issue.SuggestedTag);
|
||||
Assert.Contains("Meinten Sie", issue.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_FindetTagsInKopfzeile()
|
||||
{
|
||||
var path = CreateDocx(Control("Student.FirstName"), Control("CurrentDate"));
|
||||
|
||||
var result = new LetterTemplateService(_directory).Validate(path);
|
||||
|
||||
Assert.Contains("Student.FirstName", result.Tags);
|
||||
Assert.Contains("CurrentDate", result.Tags);
|
||||
Assert.Empty(result.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_BefuelltTextUndMehrzeiligeAdresse_OhneVorlageZuVeraendern()
|
||||
{
|
||||
var path = CreateDocx(Control("Student.FirstName") + Control("Contact.Address"));
|
||||
var original = File.ReadAllBytes(path);
|
||||
var output = Path.Combine(_directory, "filled.docx");
|
||||
var service = new LetterTemplateService(_directory);
|
||||
|
||||
service.Generate(path, output, new Dictionary<string, string?>
|
||||
{
|
||||
["Student.FirstName"] = "Mara",
|
||||
["Contact.Address"] = "Hauptstraße 1\n12345 Musterstadt",
|
||||
});
|
||||
|
||||
Assert.Equal(original, File.ReadAllBytes(path));
|
||||
using var archive = ZipFile.OpenRead(output);
|
||||
var entry = archive.GetEntry("word/document.xml")!;
|
||||
using var reader = new StreamReader(entry.Open());
|
||||
var xml = XDocument.Parse(reader.ReadToEnd());
|
||||
Assert.Contains("Mara", xml.Descendants(W + "t").Select(t => t.Value));
|
||||
Assert.Single(xml.Descendants(W + "br"));
|
||||
Assert.Contains("12345 Musterstadt", xml.Descendants(W + "t").Select(t => t.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_KopiertVorlageUndLoeschenEntferntSie()
|
||||
{
|
||||
var path = CreateDocx(Control("CurrentDate"));
|
||||
var service = new LetterTemplateService(Path.Combine(_directory, "appdata"));
|
||||
|
||||
var imported = service.Import(path, "Elternbrief");
|
||||
|
||||
Assert.Equal("Elternbrief", Assert.Single(service.GetTemplates()).Name);
|
||||
Assert.True(File.Exists(service.GetTemplatePath(imported)));
|
||||
service.Delete(imported.Id);
|
||||
Assert.Empty(service.GetTemplates());
|
||||
Assert.False(File.Exists(service.GetTemplatePath(imported)));
|
||||
}
|
||||
|
||||
private string CreateDocx(string body, string? header = null)
|
||||
{
|
||||
var path = Path.Combine(_directory, $"{Guid.NewGuid():N}.docx");
|
||||
using var archive = ZipFile.Open(path, ZipArchiveMode.Create);
|
||||
AddXml(archive, "word/document.xml", body);
|
||||
if (header is not null) AddXml(archive, "word/header1.xml", header);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void AddXml(ZipArchive archive, string name, string content)
|
||||
{
|
||||
var entry = archive.CreateEntry(name);
|
||||
using var writer = new StreamWriter(entry.Open());
|
||||
writer.Write($"<w:document xmlns:w=\"{W}\"><w:body>{content}</w:body></w:document>");
|
||||
}
|
||||
|
||||
private static string Control(string tag) =>
|
||||
$"<w:sdt><w:sdtPr><w:tag w:val=\"{tag}\"/></w:sdtPr><w:sdtContent>" +
|
||||
"<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>Platzhalter</w:t></w:r></w:p>" +
|
||||
"</w:sdtContent></w:sdt>";
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Sync.Tests", "Leh
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Api.Tests", "LehrerApp.Api.Tests\LehrerApp.Api.Tests.csproj", "{E8152216-11F1-427E-B189-D8CEC9A71C33}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Templating", "LehrerApp.Templating\LehrerApp.Templating.csproj", "{B2000001-0000-0000-0000-000000000001}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.TemplateDesigner", "LehrerApp.TemplateDesigner\LehrerApp.TemplateDesigner.csproj", "{B2000002-0000-0000-0000-000000000002}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Templating.Tests", "LehrerApp.Templating.Tests\LehrerApp.Templating.Tests.csproj", "{B2000003-0000-0000-0000-000000000003}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.TemplateDesigner.Tests", "LehrerApp.TemplateDesigner.Tests\LehrerApp.TemplateDesigner.Tests.csproj", "{B2000004-0000-0000-0000-000000000004}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -34,6 +42,22 @@ Global
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{B2000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2000002-0000-0000-0000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2000002-0000-0000-0000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2000002-0000-0000-0000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2000002-0000-0000-0000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2000003-0000-0000-0000-000000000003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2000003-0000-0000-0000-000000000003}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2000003-0000-0000-0000-000000000003}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2000003-0000-0000-0000-000000000003}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2000004-0000-0000-0000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2000004-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2000004-0000-0000-0000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2000004-0000-0000-0000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
|
||||
Reference in New Issue
Block a user