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

This commit is contained in:
2026-08-30 15:37:22 +02:00
parent a088823b30
commit 2f2761caf8
40 changed files with 2177 additions and 738 deletions
+15 -1
View File
@@ -5,7 +5,7 @@
"name": "Desktop App starten", "name": "Desktop App starten",
"type": "coreclr", "type": "coreclr",
"request": "launch", "request": "launch",
"preLaunchTask": "Desktop bauen", "preLaunchTask": "TemplateDesigner bauen",
"program": "${workspaceFolder}/LehrerApp.Desktop/bin/Debug/net10.0/LehrerApp.Desktop.dll", "program": "${workspaceFolder}/LehrerApp.Desktop/bin/Debug/net10.0/LehrerApp.Desktop.dll",
"args": [], "args": [],
"cwd": "${workspaceFolder}/LehrerApp.Desktop", "cwd": "${workspaceFolder}/LehrerApp.Desktop",
@@ -15,6 +15,20 @@
"DOTNET_ENVIRONMENT": "Development" "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", "name": "API Server starten",
"type": "coreclr", "type": "coreclr",
+13
View File
@@ -27,6 +27,19 @@
"problemMatcher": "$msCompile", "problemMatcher": "$msCompile",
"group": "build" "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", "label": "Solution bauen",
"command": "dotnet", "command": "dotnet",
+2 -2
View File
@@ -17,9 +17,9 @@
<!-- Datenbank --> <!-- Datenbank -->
<PackageVersion Include="LiteDB" Version="5.0.21" /> <PackageVersion Include="LiteDB" Version="5.0.21" />
<!-- PDF-Erzeugung (11.3) - nur LehrerApp.Desktop: zieht SkiaSharp-Native-Binaries mit, <!-- PDF-Erzeugung: Desktop-Exporte und die unabhängige Templating-Library. -->
die im Server-Image (LehrerApp.Api) nichts verloren haben. -->
<PackageVersion Include="QuestPDF" Version="2025.7.0" /> <PackageVersion Include="QuestPDF" Version="2025.7.0" />
<PackageVersion Include="PDFtoImage" Version="5.4.0" />
<!-- API --> <!-- API -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" /> <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.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Templating;
using Xunit; using Xunit;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -9,16 +8,12 @@ namespace LehrerApp.Desktop.Tests;
public sealed class CreateLetterDialogViewModelTests : IDisposable public sealed class CreateLetterDialogViewModelTests : IDisposable
{ {
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-vm-tests-{Guid.NewGuid():N}"); private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-vm-tests-{Guid.NewGuid():N}");
public CreateLetterDialogViewModelTests() => Directory.CreateDirectory(_directory); public CreateLetterDialogViewModelTests() => Directory.CreateDirectory(_directory);
[Fact] [Fact]
public void OhneVorlage_KannKeinenBriefErzeugen() public void OhneVorlage_KannKeinenBriefErzeugen()
{ {
var student = StudentWithContact("Sehr geehrte Frau Muster,"); var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), new TemplateStore(_directory));
var vm = Build(student, new LetterTemplateService(_directory));
Assert.False(vm.CanGenerate); Assert.False(vm.CanGenerate);
Assert.Contains(vm.Issues, i => i.Message.Contains("Vorlage", StringComparison.OrdinalIgnoreCase)); Assert.Contains(vm.Issues, i => i.Message.Contains("Vorlage", StringComparison.OrdinalIgnoreCase));
} }
@@ -26,83 +21,50 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
[Fact] [Fact]
public void VerwendeteBriefanredeFehlt_BlockiertErzeugung() public void VerwendeteBriefanredeFehlt_BlockiertErzeugung()
{ {
var service = ServiceWithTemplate("Letter.Salutation"); var vm = Build(StudentWithContact(null), StoreWithTemplate(
var vm = Build(StudentWithContact(null), service); new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
Assert.False(vm.CanGenerate); 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] [Fact]
public void VollstaendigeDaten_ErzeugenEditierbareDocxKopie() public void VollstaendigeDaten_ErzeugenFlachesPdf()
{ {
var service = ServiceWithTemplate("Letter.Salutation", "Student.FirstName", "CurrentDate"); var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), service); new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
var output = Path.Combine(_directory, "Brief.docx"); 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(vm.Generate(output));
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
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);
} }
[Fact] private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
public void KontaktBearbeiten_SpeichertExpliziteBriefanrede() new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
private TemplateStore StoreWithTemplate(params PlaceholderDefinition[] definitions)
{ {
var vm = new ContactEditDialogViewModel(new Contact { Name = "Frau Muster" }); var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
vm.LetterSalutation = "Sehr geehrte Frau Muster,"; var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief", Placeholders = [.. definitions] };
var lines = new List<string> { "PAGE 210 297 mm" };
vm.SaveCommand.Execute(null); var y = 20;
foreach (var definition in definitions)
Assert.Equal("Sehr geehrte Frau Muster,", vm.Result!.LetterSalutation);
}
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"); var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT";
using var writer = new StreamWriter(entry.Open()); lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}");
var controls = string.Join("", tags.Select(tag => y += 20;
$"<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}")); TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
service.Import(source, "Elternbrief"); var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")); store.Import(source); return store;
return service;
} }
private static Student StudentWithContact(string? salutation) => new() private static Student StudentWithContact(string? salutation) => new()
{ {
FirstName = "Lena", FirstName = "Lena", LastName = "Beispiel",
LastName = "Beispiel", Contacts = [new Contact { Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation,
Contacts = Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt" }],
[
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, true); }
public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
} }
@@ -7,6 +7,7 @@ using LehrerApp.Sync;
using LehrerApp.Sync.Models; using LehrerApp.Sync.Models;
using System.Linq; using System.Linq;
using Xunit; using Xunit;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -34,7 +35,7 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
@@ -320,7 +321,7 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), 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.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
@@ -348,7 +349,7 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, 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.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
@@ -380,7 +381,7 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, 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.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
+4 -1
View File
@@ -16,6 +16,7 @@ using LehrerApp.Sync;
using LehrerApp.Sync.Crypto; using LehrerApp.Sync.Crypto;
using LehrerApp.Sync.Models; using LehrerApp.Sync.Models;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using LehrerApp.Templating;
namespace LehrerApp.Desktop; namespace LehrerApp.Desktop;
@@ -117,6 +118,9 @@ public static class AppBootstrapper
services.AddSingleton<NotificationService>(); services.AddSingleton<NotificationService>();
services.AddSingleton<ExportService>(); services.AddSingleton<ExportService>();
services.AddSingleton<PdfExportService>(); services.AddSingleton<PdfExportService>();
services.AddSingleton<ITemplateLoader, TemplateLoader>();
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
services.AddSingleton(_ => new TemplateStore(appData));
// ── Datensicherheit (13.3) ─────────────────────────────────────────── // ── Datensicherheit (13.3) ───────────────────────────────────────────
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von // 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 DashboardSettingsService(appData));
services.AddSingleton(_ => new WindowSettingsService(appData)); services.AddSingleton(_ => new WindowSettingsService(appData));
services.AddSingleton(_ => new AppearanceSettingsService(appData)); services.AddSingleton(_ => new AppearanceSettingsService(appData));
services.AddSingleton(_ => new LetterTemplateService(appData));
services.AddSingleton<PlanningExchangeService>(); services.AddSingleton<PlanningExchangeService>();
// ── KI-Unterstützung (4.5.9, optional nur wenn in den Einstellungen aktiviert) ────── // ── 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.Data\LehrerApp.Data.csproj" />
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" /> <ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" /> <ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" /> <PackageReference Include="Avalonia" />
@@ -1,37 +1,19 @@
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces; using LehrerApp.Templating;
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 System.Collections.ObjectModel; 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; namespace LehrerApp.Desktop.ViewModels.Settings;
public partial class SettingsViewModel public partial class SettingsViewModel
{ {
// ── Word-Briefvorlagen (7.1.4 / 11.5) ───────────────────────────────────
[ObservableProperty] private string _letterTemplateStatus = ""; [ObservableProperty] private string _letterTemplateStatus = "";
public ObservableCollection<LetterTemplateListItem> LetterTemplateList { get; } = []; public ObservableCollection<LetterTemplateListItem> LetterTemplateList { get; } = [];
public IReadOnlyList<LetterPlaceholder> SupportedLetterPlaceholders =>
LetterTemplateService.SupportedPlaceholders;
// ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
private void LoadLetterTemplates() private void LoadLetterTemplates()
{ {
LetterTemplateList.Clear(); LetterTemplateList.Clear();
foreach (var template in _letterTemplates.GetTemplates()) foreach (var template in _letterTemplates.GetTemplates()) LetterTemplateList.Add(CreateItem(template));
LetterTemplateList.Add(new LetterTemplateListItem(template, _letterTemplates.Validate(template)));
} }
public void ImportLetterTemplate(string path) public void ImportLetterTemplate(string path)
@@ -40,75 +22,59 @@ public partial class SettingsViewModel
try try
{ {
var template = _letterTemplates.Import(path); var template = _letterTemplates.Import(path);
var validation = _letterTemplates.Validate(template);
LoadLetterTemplates(); LoadLetterTemplates();
LetterTemplateStatus = validation.Issues.Count == 0 LetterTemplateStatus = $"„{template.Name}“ wurde geprüft und lokal importiert.";
? "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}";
} }
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
{ LetterTemplateStatus = $"Import fehlgeschlagen: {ex.Message}"; }
} }
[RelayCommand] [RelayCommand]
private void ValidateLetterTemplate(LetterTemplateListItem? item) private void ValidateLetterTemplate(LetterTemplateListItem? item)
{ {
if (item is null) return; if (item is null) return;
var index = LetterTemplateList.IndexOf(item); try
var refreshed = new LetterTemplateListItem(item.Model, _letterTemplates.Validate(item.Model)); {
if (index >= 0) LetterTemplateList[index] = refreshed; var refreshed = CreateItem(item.Model);
LetterTemplateStatus = refreshed.Validation.Issues.Count == 0 var index = LetterTemplateList.IndexOf(item);
? $"„{item.Name}“ ist ohne Auffälligkeiten." if (index >= 0) LetterTemplateList[index] = refreshed;
: $"„{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] [RelayCommand]
private void DeleteLetterTemplate(LetterTemplateListItem? item) private void DeleteLetterTemplate(LetterTemplateListItem? item)
{ {
if (item is null) return; if (item is null) return;
_letterTemplates.Delete(item.Id); _letterTemplates.Delete(item.Id); LetterTemplateList.Remove(item); LetterTemplateStatus = "Vorlage gelöscht.";
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 InstalledTemplate Model { get; } = model;
public TemplateValidationResult Validation { get; } public string Id => Model.Id;
public Guid Id => Model.Id;
public string Name => Model.Name; public string Name => Model.Name;
public string OriginalFileName => Model.OriginalFileName; public string PackageFileName => Path.GetFileName(Model.PackagePath);
public bool HasIssues => Validation.Issues.Count > 0; public int SchemaVersion { get; } = schemaVersion;
public bool HasNoIssues => !HasIssues; public bool HasIssues => false;
public string ValidationSummary => HasNoIssues public bool HasNoIssues => true;
? $"{Validation.Tags.Count} Feld(er) · keine Auffälligkeiten" public string ValidationSummary => $"Schema {SchemaVersion} · {fieldCount} Felder ({requiredCount} Pflicht)";
: $"{Validation.Tags.Count} Feld(er) · {Validation.Issues.Count} Hinweis(e)"; public ObservableCollection<LetterTemplateIssueItem> Issues { get; } = [];
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 sealed class LetterTemplateIssueItem(TemplateValidationIssue issue) public sealed class LetterTemplateIssueItem(ValidationIssue issue)
{ {
public string Icon => issue.Severity switch public string Icon => issue.Severity == ValidationSeverity.Error ? "⛔" : "ⓘ";
{
TemplateIssueSeverity.Error => "⛔",
TemplateIssueSeverity.StrongWarning => "⚠",
_ => "ⓘ",
};
public string Message => issue.Message; public string Message => issue.Message;
public string Color => issue.Severity switch public string Color => issue.Severity == ValidationSeverity.Error ? "#DC2626" : "#6B7280";
{
TemplateIssueSeverity.Error => "#DC2626",
TemplateIssueSeverity.StrongWarning => "#D97706",
_ => "#6B7280",
};
} }
@@ -13,6 +13,7 @@ using System.Globalization;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.ViewModels.Settings; namespace LehrerApp.Desktop.ViewModels.Settings;
@@ -59,7 +60,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly IDocumentationRepository _documentation; private readonly IDocumentationRepository _documentation;
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IShorthandCodeRepository _shorthandCodes; private readonly IShorthandCodeRepository _shorthandCodes;
private readonly LetterTemplateService _letterTemplates; private readonly TemplateStore _letterTemplates;
[ObservableProperty] private int _activeTabIndex; [ObservableProperty] private int _activeTabIndex;
@@ -97,7 +98,7 @@ public partial class SettingsViewModel : ObservableObject
IDocumentationRepository documentation, IStudentRepository students, IDocumentationRepository documentation, IStudentRepository students,
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays, IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning, AiSettingsService aiSettings, AiPlanningService aiPlanning,
WebUntisSettingsService untisSettings, WebUntisSettingsService untisSettings,
AnnualPlanSettingsService annualPlanSettings, AnnualPlanSettingsService annualPlanSettings,
@@ -1,21 +1,23 @@
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Templating;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization;
namespace LehrerApp.Desktop.ViewModels.Students; namespace LehrerApp.Desktop.ViewModels.Students;
public partial class CreateLetterDialogViewModel : ObservableObject public partial class CreateLetterDialogViewModel : ObservableObject
{ {
private readonly Student _student; private readonly Student _student;
private readonly LetterTemplateService _templates; private readonly TemplateStore _templates;
private readonly ITemplateRenderer _renderer;
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate; [ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
[ObservableProperty] private LetterContactChoice? _selectedContact; [ObservableProperty] private LetterContactChoice? _selectedContact;
[ObservableProperty] private LetterGroupChoice? _selectedGroup; [ObservableProperty] private LetterGroupChoice? _selectedGroup;
[ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now; [ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now;
[ObservableProperty] private string _letterText = "";
[ObservableProperty] private string _teacherName = "";
[ObservableProperty] private string _generationError = ""; [ObservableProperty] private string _generationError = "";
[ObservableProperty] private bool _canGenerate; [ObservableProperty] private bool _canGenerate;
@@ -28,155 +30,92 @@ public partial class CreateLetterDialogViewModel : ObservableObject
public bool HasNoTemplates => Templates.Count == 0; public bool HasNoTemplates => Templates.Count == 0;
public bool HasNoContacts => Contacts.Count == 0; public bool HasNoContacts => Contacts.Count == 0;
public string SuggestedFileName => SanitizeFileName( 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) IGroupMembershipRepository memberships, IGroupRepository groups)
{ {
_student = student; _student = student; _templates = templates; _renderer = renderer;
_templates = templates; 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 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));
foreach (var membership in memberships.GetByStudent(student.Id)) foreach (var membership in memberships.GetByStudent(student.Id))
{ if (groups.GetById(membership.GroupId) is { } group) Groups.Add(new(group));
var group = groups.GetById(membership.GroupId); SelectedTemplate = Templates.FirstOrDefault(); SelectedContact = Contacts.FirstOrDefault(); SelectedGroup = Groups.FirstOrDefault();
if (group is not null) Groups.Add(new LetterGroupChoice(group));
}
SelectedTemplate = Templates.FirstOrDefault();
SelectedContact = Contacts.FirstOrDefault();
SelectedGroup = Groups.FirstOrDefault();
RefreshValidation(); RefreshValidation();
} }
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) { OnPropertyChanged(nameof(SuggestedFileName)); RefreshValidation(); }
{
OnPropertyChanged(nameof(SuggestedFileName));
RefreshValidation();
}
partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation(); partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation();
partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation(); partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation();
partial void OnLetterDateChanged(DateTimeOffset? 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) public bool Generate(string outputPath)
{ {
RefreshValidation(); RefreshValidation(); if (!CanGenerate || SelectedTemplate is null) return false;
if (!CanGenerate || SelectedTemplate is null) return false;
GenerationError = "";
try try
{ {
_templates.Generate(_templates.GetTemplatePath(SelectedTemplate.Model), outputPath, BuildValues()); var pdf = _renderer.RenderToPdf(_templates.Load(SelectedTemplate.Model), new LetterDataProvider(BuildValues()));
return true; 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)
{
GenerationError = $"Der Brief konnte nicht erzeugt werden: {ex.Message}";
return false;
} }
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() private void RefreshValidation()
{ {
Issues.Clear(); Issues.Clear(); GenerationError = "";
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 (SelectedTemplate is null) if (LetterDate is null) Issues.Add(new("Bitte ein Briefdatum auswählen.", true));
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));
if (SelectedTemplate is not null) if (SelectedTemplate is not null)
{ {
var validation = _templates.Validate(SelectedTemplate.Model); try
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)))
{ {
var message = tag switch var loaded = _templates.Load(SelectedTemplate.Model); var values = BuildValues();
{ var validation = new TemplateLoader().Validate(loaded, values.ToDictionary(x => x.Key, x => x.Value.Type));
"Letter.Salutation" => "Beim ausgewählten Kontakt fehlt die Briefanrede.", foreach (var issue in validation.Issues) Issues.Add(new(issue.Message, issue.Severity == ValidationSeverity.Error));
"Contact.Address" or "Contact.Street" or "Contact.PostalCode" or "Contact.City" => foreach (var required in loaded.Manifest.Placeholders.Where(x => x.Required && values.TryGetValue(x.Name, out var value) && IsEmpty(value)))
$"Beim ausgewählten Kontakt fehlt der Wert für „{tag}“.", Issues.Add(new($"Für das Pflichtfeld „{required.Name}“ ist kein Wert vorhanden.", true));
"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));
} }
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)); OnPropertyChanged(nameof(HasIssues));
} }
private IReadOnlyDictionary<string, string?> BuildValues() private IReadOnlyDictionary<string, PlaceholderValue> BuildValues()
{ {
var contact = SelectedContact?.Model; var contact = SelectedContact?.Model; var group = SelectedGroup?.Model;
var group = SelectedGroup?.Model; var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City } var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
.Where(v => !string.IsNullOrWhiteSpace(v))); var date = DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime);
var address = string.Join(Environment.NewLine, new[] { contact?.Street, cityLine } return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
.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?>
{ {
["Student.FirstName"] = _student.FirstName, ["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
["Student.LastName"] = _student.LastName, ["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
["Contact.Name"] = contact?.Name, ["Brieftext"] = new MultilineValue(LetterText), ["LehrerName"] = new TextValue(TeacherName),
["Contact.Address"] = address, ["Student.FirstName"] = new TextValue(_student.FirstName), ["Student.LastName"] = new TextValue(_student.LastName),
["Contact.Street"] = contact?.Street, ["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
["Contact.PostalCode"] = contact?.PostalCode, ["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
["Contact.City"] = contact?.City, ["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
["Letter.Salutation"] = contact?.LetterSalutation, ["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
["Group.Name"] = group?.Name,
["SchoolYear"] = group?.SchoolYear,
["CurrentDate"] = date,
}; };
} }
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) private static string SanitizeFileName(string value)
{ { foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return 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;
} }
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 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> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: Word-Briefvorlagen (7.1.4 / 11.5) --> <!-- Tab: portable PDF-Briefvorlagen -->
<ContentPage Header="Briefvorlagen"> <ContentPage Header="Briefvorlagen">
<ScrollViewer> <ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="16" MaxWidth="760"> <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"/> FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12"> <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" <TextBlock Grid.Column="1" Text="{Binding LetterTemplateStatus}" FontSize="12"
VerticalAlignment="Center" TextWrapping="Wrap" VerticalAlignment="Center" TextWrapping="Wrap"
IsVisible="{Binding LetterTemplateStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding LetterTemplateStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
@@ -355,7 +355,7 @@
<StackPanel Grid.Column="0"> <StackPanel Grid.Column="0">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="14"/> <TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="14"/>
<TextBlock FontSize="11" Opacity="0.55"> <TextBlock FontSize="11" Opacity="0.55">
<Run Text="{Binding OriginalFileName}"/><Run Text=" · "/> <Run Text="{Binding PackageFileName}"/><Run Text=" · "/>
<Run Text="{Binding ValidationSummary}"/> <Run Text="{Binding ValidationSummary}"/>
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
@@ -391,19 +391,8 @@
</ItemsControl> </ItemsControl>
<Separator/> <Separator/>
<TextBlock Text="Unterstützte Tags" FontSize="15" FontWeight="SemiBold"/> <TextBlock Text="Vorlagen werden mit dem separaten LehrerApp Vorlagen-Designer erstellt. Designer und Hauptapp verwenden exakt dieselbe QuestPDF-Renderingbibliothek."
<TextBlock Text="In Word: Entwicklertools → Rich-Text- oder Nur-Text-Inhaltssteuerelement → Eigenschaften → Tag. Der Titel ist frei wählbar; ausgewertet wird der Tag."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/> 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> </StackPanel>
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
@@ -263,9 +263,9 @@ public partial class SettingsView : UserControl
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{ {
Title = "Word-Briefvorlage importieren", Title = "LehrerApp-Briefvorlage importieren",
AllowMultiple = false, 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); 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) private void OnOpenLetterTemplateClick(object? sender, RoutedEventArgs e)
{ {
if (sender is not Button { Tag: LetterTemplateListItem item }) return; if (sender is not Button { Tag: LetterTemplateListItem item }) return;
var service = App.Services.GetRequiredService<LehrerApp.Core.Services.LetterTemplateService>(); var path = item.Model.PackagePath;
var path = service.GetTemplatePath(item.Model);
if (!File.Exists(path)) return; if (!File.Exists(path)) return;
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true }); System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
} }
@@ -32,7 +32,7 @@
<TextBlock Text="Briefanrede" FontSize="12" Opacity="0.7"/> <TextBlock Text="Briefanrede" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding LetterSalutation}" <TextBox Text="{Binding LetterSalutation}"
PlaceholderText="z.B. Sehr geehrte Frau Mustermann,"/> 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"/> FontSize="11" Opacity="0.55" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
@@ -3,15 +3,15 @@
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.CreateLetterDialog" x:Class="LehrerApp.Desktop.Views.Students.CreateLetterDialog"
x:DataType="vm:CreateLetterDialogViewModel" 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" MinWidth="500" MinHeight="540" CanResize="True"
WindowStartupLocation="CenterOwner"> WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24,20"> <Grid RowDefinitions="*,Auto" Margin="24,20">
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto"> <ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="15"> <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" <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"> <StackPanel Spacing="4">
<TextBlock Text="Schüler" FontSize="12" Opacity="0.7"/> <TextBlock Text="Schüler" FontSize="12" Opacity="0.7"/>
@@ -43,6 +43,15 @@
<CalendarDatePicker SelectedDate="{Binding LetterDate}" HorizontalAlignment="Stretch"/> <CalendarDatePicker SelectedDate="{Binding LetterDate}" HorizontalAlignment="Stretch"/>
</StackPanel> </StackPanel>
</Grid> </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" <Border BorderBrush="#D97706" BorderThickness="1" CornerRadius="6" Padding="10"
IsVisible="{Binding HasIssues}"> IsVisible="{Binding HasIssues}">
@@ -68,7 +77,7 @@
<Grid Grid.Row="1" ColumnDefinitions="*,10,*" Margin="0,18,0,0"> <Grid Grid.Row="1" ColumnDefinitions="*,10,*" Margin="0,18,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/> <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"/> IsEnabled="{Binding CanGenerate}" Click="OnGenerate"/>
</Grid> </Grid>
</Grid> </Grid>
@@ -14,10 +14,10 @@ public partial class CreateLetterDialog : Window
if (DataContext is not CreateLetterDialogViewModel vm) return; if (DataContext is not CreateLetterDialogViewModel vm) return;
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{ {
Title = "Word-Brief speichern", Title = "PDF-Brief speichern",
SuggestedFileName = vm.SuggestedFileName, SuggestedFileName = vm.SuggestedFileName,
DefaultExtension = "docx", DefaultExtension = "pdf",
FileTypeChoices = [new FilePickerFileType("Word-Dokumente") { Patterns = ["*.docx"] }], FileTypeChoices = [new FilePickerFileType("PDF-Dateien") { Patterns = ["*.pdf"] }],
}); });
if (file is null || !vm.Generate(file.Path.LocalPath)) return; if (file is null || !vm.Generate(file.Path.LocalPath)) return;
Close(file.Path.LocalPath); Close(file.Path.LocalPath);
@@ -28,7 +28,7 @@
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top"> <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top">
<TextBlock Text="{Binding StudentStatus}" VerticalAlignment="Center" Opacity="0.65"/> <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}"/> IsVisible="{Binding !IsEditing}"/>
<Button Content="Bearbeiten" Command="{Binding StartEditCommand}" <Button Content="Bearbeiten" Command="{Binding StartEditCommand}"
IsVisible="{Binding !IsEditing}"/> IsVisible="{Binding !IsEditing}"/>
@@ -8,6 +8,7 @@ using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Shared; using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Views.Students; 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; if (owner is null || DataContext is not StudentDetailViewModel { Student: { } student }) return;
var vm = new CreateLetterDialogViewModel(student, 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<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IGroupRepository>()); App.Services.GetRequiredService<IGroupRepository>());
var dialog = new CreateLetterDialog { DataContext = vm }; 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); }
}
+8
View File
@@ -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>
+16
View File
@@ -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>
+130
View File
@@ -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 &amp; 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 &amp; 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);
}
+10
View File
@@ -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;
}
+150
View File
@@ -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>
+105
View File
@@ -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();
}
}
+27
View File
@@ -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.
+179
View File
@@ -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.");
}
+94
View File
@@ -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
View File
@@ -24,6 +24,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Sync.Tests", "Leh
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Api.Tests", "LehrerApp.Api.Tests\LehrerApp.Api.Tests.csproj", "{E8152216-11F1-427E-B189-D8CEC9A71C33}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Api.Tests", "LehrerApp.Api.Tests\LehrerApp.Api.Tests.csproj", "{E8152216-11F1-427E-B189-D8CEC9A71C33}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -34,6 +42,22 @@ Global
Release|x86 = Release|x86 Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution 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.ActiveCfg = Debug|Any CPU
{A1000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = 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 {A1000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU