Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ccc3201d2 | ||
|
|
ab1feba0f0 | ||
|
|
b2d0ccd30d | ||
|
|
c6efdab9ef | ||
|
|
11ef73ab1f | ||
|
|
4b4a746e17 | ||
|
|
e2e2bfb854 | ||
|
|
3e5f197bdb | ||
|
|
95e3f677e4 | ||
|
|
cdbed69d8b | ||
|
|
527c186090 | ||
|
|
28469bf547 | ||
|
|
9dce6576e2 | ||
|
|
9f28081931 | ||
|
|
6468596bf3 | ||
|
|
23abe7af63 | ||
|
|
3f71f7f371 | ||
|
|
2f2761caf8 | ||
|
|
a088823b30 | ||
|
|
1215d4ac22 | ||
|
|
84c03962ef | ||
|
|
b7c7d46ee4 |
Vendored
+15
-1
@@ -5,7 +5,7 @@
|
||||
"name": "Desktop App starten",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "Desktop bauen",
|
||||
"preLaunchTask": "TemplateDesigner bauen",
|
||||
"program": "${workspaceFolder}/LehrerApp.Desktop/bin/Debug/net10.0/LehrerApp.Desktop.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/LehrerApp.Desktop",
|
||||
@@ -15,6 +15,20 @@
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "TemplateDesigner App starten",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "Desktop bauen",
|
||||
"program": "${workspaceFolder}/LehrerApp.TemplateDesigner/bin/Debug/net10.0/LehrerApp.TemplateDesigner.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/LehrerApp.TemplateDesigner",
|
||||
"stopAtEntry": false,
|
||||
"console": "internalConsole",
|
||||
"env": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "API Server starten",
|
||||
"type": "coreclr",
|
||||
|
||||
Vendored
+13
@@ -27,6 +27,19 @@
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "TemplateDesigner bauen",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/LehrerApp.TemplateDesigner/LehrerApp.TemplateDesigner.csproj",
|
||||
"--configuration", "Debug",
|
||||
"--verbosity", "minimal"
|
||||
],
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "Solution bauen",
|
||||
"command": "dotnet",
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
<!-- Datenbank -->
|
||||
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
||||
|
||||
<!-- PDF-Erzeugung (11.3) - nur LehrerApp.Desktop: zieht SkiaSharp-Native-Binaries mit,
|
||||
die im Server-Image (LehrerApp.Api) nichts verloren haben. -->
|
||||
<!-- PDF-Erzeugung: Desktop-Exporte und die unabhängige Templating-Library. -->
|
||||
<PackageVersion Include="QuestPDF" Version="2025.7.0" />
|
||||
<PackageVersion Include="PDFtoImage" Version="5.4.0" />
|
||||
<PackageVersion Include="PdfPig" Version="0.1.13" />
|
||||
|
||||
<!-- API -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||
|
||||
@@ -26,6 +26,13 @@ public class LearningGroup
|
||||
/// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie.
|
||||
/// </summary>
|
||||
public int? WebUntisLessonId { get; set; }
|
||||
/// <summary>
|
||||
/// Vorgängergruppe aus dem Hochstufen (<see cref="Services.GroupRolloverService"/>) —
|
||||
/// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg.
|
||||
/// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds
|
||||
/// hochgestufte Gruppen bleiben unverknüpft (kein rückwirkendes Verknüpfen vorgesehen).
|
||||
/// </summary>
|
||||
public Guid? PreviousGroupId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -32,9 +32,18 @@ public class ParticipationEntry
|
||||
// ParticipationCountSuggestion.
|
||||
public int RaisedHandCount { get; set; }
|
||||
public int CalledOnCount { get; set; }
|
||||
/// <summary>
|
||||
/// Tagesflagge (Nutzer-Feedback): markiert eine session-bezogene Bewertung als besonders
|
||||
/// herausragend oder besonders schwach, unabhängig von der Richtung ("egal in welche
|
||||
/// Richtung, herausragend festhalten"). Rein deskriptiv — fließt nirgends in eine Berechnung
|
||||
/// (Mitarbeitsnote 3.2, Aufrufgerechtigkeit) ein, nur zur Erinnerung/Dokumentation.
|
||||
/// </summary>
|
||||
public DayHighlightKind? DayHighlight { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public enum DayHighlightKind { Standout, Sleepy, Rough }
|
||||
|
||||
/// <summary>
|
||||
/// Hausaufgabenstatus einer Sitzung; null bedeutet, dass für diesen Termin keine
|
||||
/// Hausaufgabe erfasst wurde. MissingOpen kann später in MissingOverdue oder SubmittedLate
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class DashboardSettingsService
|
||||
[
|
||||
"today", "tasks", "calendar", "excuses", "upcoming",
|
||||
"corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload",
|
||||
"missingteachingtime",
|
||||
];
|
||||
|
||||
private readonly string _configPath;
|
||||
|
||||
@@ -77,13 +77,29 @@ public class GradingService
|
||||
public double WeightedAverage(List<(string Grade, double Weight)> grades)
|
||||
{
|
||||
var numeric = grades
|
||||
.Select(g => (Value: int.TryParse(g.Grade, out var n) ? (int?)n : null, g.Weight))
|
||||
.Select(g => (Value: ParseGradeValue(g.Grade), g.Weight))
|
||||
.Where(g => g.Value.HasValue).ToList();
|
||||
if (numeric.Count == 0) return 0;
|
||||
var total = numeric.Sum(g => g.Weight);
|
||||
return total == 0 ? 0 : numeric.Sum(g => g.Value!.Value * g.Weight) / total;
|
||||
}
|
||||
|
||||
/// Wandelt eine Notenangabe mit optionaler Tendenz ("3+", "2-") in einen rechnerischen
|
||||
/// Notenwert um. Tendenzen verschieben um ein Drittel in Richtung der besseren Note
|
||||
/// (z.B. "3+" → 2,67, "2-" → 2,33) — der in deutschen Notensystemen übliche Drittelabstand.
|
||||
private static double? ParseGradeValue(string grade)
|
||||
{
|
||||
var trimmed = grade.Trim();
|
||||
if (trimmed.Length == 0) return null;
|
||||
|
||||
var tendency = 0.0;
|
||||
var core = trimmed;
|
||||
if (core.EndsWith('+')) { tendency = -1.0 / 3.0; core = core[..^1].TrimEnd(); }
|
||||
else if (core.EndsWith('-')) { tendency = 1.0 / 3.0; core = core[..^1].TrimEnd(); }
|
||||
|
||||
return int.TryParse(core, out var n) ? n + tendency : null;
|
||||
}
|
||||
|
||||
/// Rundet einen rechnerischen Notenwert auf eine ganze Note/Punktzahl.
|
||||
/// "Kaufmännisch" rundet bei genau 0,5 immer vom Nullpunkt weg (Standard).
|
||||
/// "Pädagogisch" rundet bei genau 0,5 in Richtung der besseren Note
|
||||
|
||||
@@ -60,6 +60,7 @@ public sealed class GroupRolloverService(
|
||||
IsActive = true,
|
||||
IsOwnClass = source.IsOwnClass,
|
||||
IsDifferentiated = source.IsDifferentiated,
|
||||
PreviousGroupId = source.Id,
|
||||
// WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu,
|
||||
// eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern.
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// Welcher der drei Leistungsbereiche (siehe GradingScheme) bei der Zielnoten-Frage der
|
||||
/// gesuchte/unbekannte ist.
|
||||
public enum GradeBucketKind { Exams, Participation, Other }
|
||||
|
||||
public readonly record struct TargetGradeResult(double RequiredAverage, bool IsAchievable);
|
||||
|
||||
/// <summary>
|
||||
/// Beantwortet die Zielnoten-Frage "Was brauche ich noch, um Zeugnisnote X zu erreichen?"
|
||||
/// (Nutzer-Feedback) — kehrt <see cref="GradingService.CalculateReportGrade"/> um: löst nach
|
||||
/// dem Durchschnitt eines gewählten Bereichs auf, der zusammen mit den bekannten Durchschnitten
|
||||
/// der übrigen Bereiche (gewichtet nach <see cref="GradingScheme"/>) genau die Zielnote ergibt.
|
||||
/// Bereiche ohne bekannten Durchschnitt (z.B. noch keine Mitarbeitsnote im Halbjahr) fließen
|
||||
/// nicht in die bekannte Seite der Gleichung ein — exakt dieselbe Normierung wie
|
||||
/// <see cref="GradingService.CalculateReportGrade"/> bei fehlenden Bereichen.
|
||||
/// </summary>
|
||||
public static class ReportGradeTargetCalculator
|
||||
{
|
||||
public static TargetGradeResult? SolveRequiredAverage(double target, GradeBucketKind solveFor,
|
||||
double? examsAverage, double? participationAverage, double? otherAverage,
|
||||
GradingScheme scheme, GradingSystem system)
|
||||
{
|
||||
var solvePercent = solveFor switch
|
||||
{
|
||||
GradeBucketKind.Exams => scheme.ExamsPercent,
|
||||
GradeBucketKind.Participation => scheme.ParticipationPercent,
|
||||
_ => scheme.OtherPercent,
|
||||
};
|
||||
// Dieser Bereich fließt laut Gewichtungsschema gar nicht in die Zeugnisnote ein —
|
||||
// keine Zielnoten-Frage für ihn beantwortbar.
|
||||
if (solvePercent <= 0) return null;
|
||||
|
||||
var known = new List<(double Avg, double Percent)>();
|
||||
if (solveFor != GradeBucketKind.Exams && examsAverage is { } e) known.Add((e, scheme.ExamsPercent));
|
||||
if (solveFor != GradeBucketKind.Participation && participationAverage is { } p) known.Add((p, scheme.ParticipationPercent));
|
||||
if (solveFor != GradeBucketKind.Other && otherAverage is { } o) known.Add((o, scheme.OtherPercent));
|
||||
|
||||
var totalPercent = known.Sum(k => k.Percent) + solvePercent;
|
||||
var knownContribution = known.Sum(k => k.Avg * k.Percent);
|
||||
var required = (target * totalPercent - knownContribution) / solvePercent;
|
||||
|
||||
var (min, max) = system == GradingSystem.Grades1To6 ? (1.0, 6.0) : (0.0, 15.0);
|
||||
return new TargetGradeResult(required, required >= min && required <= max);
|
||||
}
|
||||
}
|
||||
@@ -184,7 +184,8 @@ public sealed class ClassTeacherViewModelsTests
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20));
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20,
|
||||
termStart: new DateOnly(2026, 8, 1)));
|
||||
|
||||
Assert.True(row.HasYearSummary);
|
||||
Assert.Equal(2, row.YearAbsenceDayCount);
|
||||
@@ -192,9 +193,29 @@ public sealed class ClassTeacherViewModelsTests
|
||||
Assert.Equal(10, row.YearAbsenceRatePercent);
|
||||
Assert.Contains("10 %", row.YearSummaryLabel);
|
||||
Assert.Contains("2 von 20", row.YearSummaryTooltip);
|
||||
Assert.Contains("seit 01.08.", row.YearSummaryTooltip);
|
||||
Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_ZeigtAbgezogeneFerientageImTooltip()
|
||||
{
|
||||
// Nutzer-Nachfrage: die Herkunft des Nenners soll sich ohne Blick in die WebUntis-Ferienliste
|
||||
// direkt in der App nachvollziehen lassen, nachdem er zeitweise Ferientage mitzählte.
|
||||
var students = new[] { Student(1001, "Ada Müller") };
|
||||
var yearAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 2, 90,
|
||||
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 14, holidayWeekdaysExcluded: 7,
|
||||
termStart: new DateOnly(2026, 8, 1)));
|
||||
|
||||
Assert.Contains("7 Ferientage abgezogen", row.YearSummaryTooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote()
|
||||
{
|
||||
@@ -264,6 +285,79 @@ public sealed class ClassTeacherViewModelsTests
|
||||
Assert.Equal(row.StatusText, row.StatusTextWithGlyph);
|
||||
}
|
||||
|
||||
// ── Nenner der Jahresfehlquote: echter WebUntis-Ferienkalender statt grober Werktagszählung ──
|
||||
|
||||
[Fact]
|
||||
public void CountSchoolWeekdays_SchliesstFerientageAus()
|
||||
{
|
||||
// 1.-31.08.2026 hat 21 Werktage; die (fiktiven) Sommerferien bis 11.08. nehmen davon 7
|
||||
// Werktage weg (Mo 03. - Fr 07., Mo 10. - Di 11.) - Nutzer-Feedback: Schüler, die seit
|
||||
// Unterrichtsbeginn jeden Tag fehlten, kamen wegen dieser mitgezählten Ferienzeit nur auf
|
||||
// ~57 % statt ~100 % Fehlquote.
|
||||
var holidays = new[] { new CachedUntisHoliday("Sommerferien", new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 11)) };
|
||||
|
||||
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
|
||||
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), holidays);
|
||||
|
||||
Assert.Equal(14, schoolDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountSchoolWeekdays_OhneFerienVerhaeltSichWieReineWerktagszaehlung()
|
||||
{
|
||||
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
|
||||
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), []);
|
||||
|
||||
Assert.Equal(21, schoolDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EstimateTermStart_NutztFruehestenFehlzeitEintragDerGanzenKlasse()
|
||||
{
|
||||
// Realer Befund (Nutzer-Feedback): WebUntis' getHolidays liefert für Bremen nie einen
|
||||
// Sommerferien-Eintrag (über 11 Jahre Kontohistorie geprüft, kein einziger Juli-/August-
|
||||
// Zeitraum dabei) - vermutlich weil die Sommerferien WebUntis-intern zwischen zwei
|
||||
// Schuljahres-Datensätzen liegen (1.8./31.7.-Grenze), nicht "in" einem davon. Für den
|
||||
// Schuljahresbeginn bleibt deshalb weiterhin der früheste Fehlzeiten-Eintrag der Klasse
|
||||
// nötig statt CountSchoolWeekdays allein.
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 13), "Fehlt Cem", 1001, 1, 45,
|
||||
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 19), "Andere Ada", 1002, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
|
||||
var termStart = ClassTeacherOverviewViewModel.EstimateTermStart(absences, new DateOnly(2026, 8, 1));
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 8, 13), termStart);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EstimateTermStart_FaelltOhneFehlzeitenAufSchuljahresbeginnZurueck()
|
||||
{
|
||||
var termStart = ClassTeacherOverviewViewModel.EstimateTermStart([], new DateOnly(2026, 8, 1));
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 8, 1), termStart);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountSchoolWeekdays_AbEchtemTerminstartOhneSommerferienEintragStimmtMitBeobachtungUeberein()
|
||||
{
|
||||
// Regressionstest für den konkret gemeldeten Fall: 13 tatsächliche Schultage seit
|
||||
// Unterrichtsbeginn (13.08.2026, ein Donnerstag) bis 31.08.2026, keine WebUntis-Ferien im
|
||||
// Bereich (die nächste, "Herbstferien", liegt erst im Oktober) - Kombination aus
|
||||
// EstimateTermStart (Sommerferien-Lücke) und CountSchoolWeekdays (übrige Ferien) muss auf
|
||||
// die vom Nutzer nachgezählte Zahl kommen, nicht auf die volle Werktagszahl ab 1.8. (21).
|
||||
var termStart = new DateOnly(2026, 8, 13);
|
||||
var noHolidaysInRange = Array.Empty<CachedUntisHoliday>();
|
||||
|
||||
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
|
||||
termStart, new DateOnly(2026, 8, 31), noHolidaysInRange);
|
||||
|
||||
Assert.Equal(13, schoolDays);
|
||||
}
|
||||
|
||||
// ── Offene Entschuldigungen (Feature-Idee 2) ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -290,6 +384,87 @@ public sealed class ClassTeacherViewModelsTests
|
||||
Assert.False(rows[1].IsOverdue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenExcuseBuild_IgnoriertUnentschuldigteVerspaetungen()
|
||||
{
|
||||
// Verspätungen sind i.d.R. nicht entschuldigungsfähig - "unentschuldigt" darf hier keine
|
||||
// Erinnerung an eine fehlende Entschuldigung auslösen (Nutzer-Feedback).
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 0, 15,
|
||||
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1003, 1, 45,
|
||||
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
|
||||
var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26));
|
||||
|
||||
Assert.Equal(["Cem Fehlt"], rows.Select(r => r.StudentName));
|
||||
}
|
||||
|
||||
// ── Verspätungsmuster: Elterngespräch-Eskalation (Nutzer-Feedback) ───────
|
||||
|
||||
[Fact]
|
||||
public void DetectLatePatterns_EskaliertBeiHoherJahressummeMitWiedervorlageOption()
|
||||
{
|
||||
var absences = Enumerable.Range(0, 5)
|
||||
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Spaet Timo", 1001, 0, 10,
|
||||
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false))
|
||||
.ToArray();
|
||||
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
|
||||
|
||||
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
|
||||
absences, names, sevenDayStart: new DateOnly(2026, 8, 31));
|
||||
|
||||
var notice = Assert.Single(notices);
|
||||
Assert.Equal("Timo Spät", notice.StudentName);
|
||||
Assert.Contains("5 Verspätungen seit Schuljahresbeginn", notice.Message);
|
||||
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
|
||||
Assert.True(notice.CanCreateReminder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectLatePatterns_KurzfristigesClusterBleibtHinweisOhneAktion()
|
||||
{
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Spaet Timo", 1001, 0, 10,
|
||||
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Spaet Timo", 1001, 0, 10,
|
||||
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||
};
|
||||
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
|
||||
|
||||
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
|
||||
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
|
||||
|
||||
var notice = Assert.Single(notices);
|
||||
Assert.Equal("2-mal verspätet in 7 Tagen", notice.Message);
|
||||
Assert.Equal(ClassTeacherStatusKind.Warning, notice.Kind);
|
||||
Assert.False(notice.CanCreateReminder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectLatePatterns_UnentschuldigteFehltageGehenVorVerspaetungseskalation()
|
||||
{
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1001, 1, 45,
|
||||
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Fehlt Cem", 1001, 1, 45,
|
||||
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Fehlt Cem")] = "Cem Fehlt" };
|
||||
|
||||
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
|
||||
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
|
||||
|
||||
var notice = Assert.Single(notices);
|
||||
Assert.Contains("unentschuldigte Fehltage", notice.Message);
|
||||
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
|
||||
Assert.False(notice.CanCreateReminder);
|
||||
}
|
||||
|
||||
// ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ──────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.IO.Compression;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
@@ -9,16 +8,12 @@ namespace LehrerApp.Desktop.Tests;
|
||||
public sealed class CreateLetterDialogViewModelTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-vm-tests-{Guid.NewGuid():N}");
|
||||
|
||||
public CreateLetterDialogViewModelTests() => Directory.CreateDirectory(_directory);
|
||||
|
||||
[Fact]
|
||||
public void OhneVorlage_KannKeinenBriefErzeugen()
|
||||
{
|
||||
var student = StudentWithContact("Sehr geehrte Frau Muster,");
|
||||
|
||||
var vm = Build(student, new LetterTemplateService(_directory));
|
||||
|
||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), new TemplateStore(_directory));
|
||||
Assert.False(vm.CanGenerate);
|
||||
Assert.Contains(vm.Issues, i => i.Message.Contains("Vorlage", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -26,83 +21,63 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
|
||||
[Fact]
|
||||
public void VerwendeteBriefanredeFehlt_BlockiertErzeugung()
|
||||
{
|
||||
var service = ServiceWithTemplate("Letter.Salutation");
|
||||
var vm = Build(StudentWithContact(null), service);
|
||||
|
||||
var vm = Build(StudentWithContact(null), StoreWithTemplate(
|
||||
new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
|
||||
Assert.False(vm.CanGenerate);
|
||||
Assert.Contains(vm.Issues, i => i.Message.Contains("Briefanrede"));
|
||||
Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VollstaendigeDaten_ErzeugenEditierbareDocxKopie()
|
||||
public void VollstaendigeDaten_ErzeugenFlachesPdf()
|
||||
{
|
||||
var service = ServiceWithTemplate("Letter.Salutation", "Student.FirstName", "CurrentDate");
|
||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), service);
|
||||
var output = Path.Combine(_directory, "Brief.docx");
|
||||
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
|
||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||
vm.LetterText = "Dies ist der Inhalt.";
|
||||
var output = Path.Combine(_directory, "Brief.pdf");
|
||||
|
||||
var generated = vm.Generate(output);
|
||||
|
||||
Assert.True(generated);
|
||||
Assert.True(File.Exists(output));
|
||||
using var archive = ZipFile.OpenRead(output);
|
||||
using var reader = new StreamReader(archive.GetEntry("word/document.xml")!.Open());
|
||||
var xml = reader.ReadToEnd();
|
||||
Assert.Contains("Sehr geehrte Frau Muster,", xml);
|
||||
Assert.Contains("Lena", xml);
|
||||
Assert.True(vm.Generate(output));
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KontaktBearbeiten_SpeichertExpliziteBriefanrede()
|
||||
public void KonstanterPflichttext_BenoetigtKeineExterneEingabe()
|
||||
{
|
||||
var vm = new ContactEditDialogViewModel(new Contact { Name = "Frau Muster" });
|
||||
vm.LetterSalutation = "Sehr geehrte Frau Muster,";
|
||||
var store = StoreWithTemplate(new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline,
|
||||
Required: true, IsConstant: true, ConstantValue: "Fest im Vorlagenpaket"));
|
||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||
var output = Path.Combine(_directory, "Konstant.pdf");
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Equal("Sehr geehrte Frau Muster,", vm.Result!.LetterSalutation);
|
||||
Assert.True(vm.CanGenerate);
|
||||
Assert.True(vm.Generate(output));
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||
}
|
||||
|
||||
private CreateLetterDialogViewModel Build(Student student, LetterTemplateService service) =>
|
||||
new(student, service, new FakeMemberships([]), new FakeGroups([]));
|
||||
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
|
||||
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
|
||||
|
||||
private LetterTemplateService ServiceWithTemplate(params string[] tags)
|
||||
private TemplateStore StoreWithTemplate(params PlaceholderDefinition[] definitions)
|
||||
{
|
||||
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.docx");
|
||||
using (var archive = ZipFile.Open(source, ZipArchiveMode.Create))
|
||||
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
|
||||
var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief", Placeholders = [.. definitions] };
|
||||
var lines = new List<string> { "PAGE 210 297 mm" };
|
||||
var y = 20;
|
||||
foreach (var definition in definitions)
|
||||
{
|
||||
var entry = archive.CreateEntry("word/document.xml");
|
||||
using var writer = new StreamWriter(entry.Open());
|
||||
var controls = string.Join("", tags.Select(tag =>
|
||||
$"<w:sdt><w:sdtPr><w:tag w:val=\"{tag}\"/></w:sdtPr><w:sdtContent>" +
|
||||
"<w:p><w:r><w:t>Platzhalter</w:t></w:r></w:p></w:sdtContent></w:sdt>"));
|
||||
writer.Write("<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">" +
|
||||
$"<w:body>{controls}</w:body></w:document>");
|
||||
var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT";
|
||||
lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}");
|
||||
y += 20;
|
||||
}
|
||||
var service = new LetterTemplateService(Path.Combine(_directory, $"service-{Guid.NewGuid():N}"));
|
||||
service.Import(source, "Elternbrief");
|
||||
return service;
|
||||
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
|
||||
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")); store.Import(source); return store;
|
||||
}
|
||||
|
||||
private static Student StudentWithContact(string? salutation) => new()
|
||||
{
|
||||
FirstName = "Lena",
|
||||
LastName = "Beispiel",
|
||||
Contacts =
|
||||
[
|
||||
new Contact
|
||||
{
|
||||
Name = "Frau Muster",
|
||||
Relation = "Mutter",
|
||||
LetterSalutation = salutation,
|
||||
Street = "Hauptstraße 1",
|
||||
PostalCode = "12345",
|
||||
City = "Musterstadt",
|
||||
},
|
||||
],
|
||||
FirstName = "Lena", LastName = "Beispiel",
|
||||
Contacts = [new Contact { Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation,
|
||||
Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt" }],
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||
}
|
||||
|
||||
@@ -60,7 +60,8 @@ public sealed class DashboardViewModelTests
|
||||
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
|
||||
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
|
||||
FakeSessions? sessions = null, FakeEntries? entries = null,
|
||||
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null)
|
||||
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null,
|
||||
FakeTimeEntries? timeEntries = null)
|
||||
{
|
||||
lessons ??= new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
@@ -73,7 +74,7 @@ public sealed class DashboardViewModelTests
|
||||
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
||||
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
|
||||
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
|
||||
substitutions ?? new FakeSubstitutionEntries(), annualPlanEvents);
|
||||
substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(), annualPlanEvents);
|
||||
}
|
||||
|
||||
/// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der
|
||||
@@ -137,6 +138,120 @@ public sealed class DashboardViewModelTests
|
||||
Assert.Empty(vm.ExamWeekLoads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_VergangenerUnterrichtstagOhneErfassung_WirdGemeldet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var pastDay = today.AddDays(-1);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
|
||||
|
||||
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||
Assert.True(vm.MissingTeachingTimeCard.EffectiveIsVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_BereitsErfassterTag_WirdNichtGemeldet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var pastDay = today.AddDays(-1);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||
var timeEntries = new FakeTimeEntries();
|
||||
timeEntries.Add(new TimeEntry { Date = pastDay, Category = "Unterricht", DurationMinutes = 45 });
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||
slots: slots, periodSchedule: periodSchedule, timeEntries: timeEntries);
|
||||
|
||||
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_KomplettAusgefallenerTag_WirdNichtGemeldet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var pastDay = today.AddDays(-1);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||
var substitutions = new FakeSubstitutionEntries();
|
||||
substitutions.Add(new SubstitutionEntry { Date = pastDay, Kind = SubstitutionKind.Cancelled, PeriodNumber = 1 });
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||
slots: slots, periodSchedule: periodSchedule, substitutions: substitutions);
|
||||
|
||||
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_SchulferienTagWirdNichtGemeldet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var pastDay = today.AddDays(-1);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||
var schoolHolidays = new FakeSchoolHolidays();
|
||||
schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = pastDay, EndDate = pastDay });
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||
slots: slots, periodSchedule: periodSchedule, schoolHolidays: schoolHolidays);
|
||||
|
||||
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
// Unterrichtsende liegt garantiert noch keine 30 Minuten zurück.
|
||||
var futureEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(2);
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = futureEnd.AddHours(-1), End = futureEnd }]);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
|
||||
|
||||
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == today);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
// Unterrichtsende liegt garantiert mehr als 30 Minuten zurück.
|
||||
var pastEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(-2);
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = pastEnd.AddHours(-1), End = pastEnd }]);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
|
||||
|
||||
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == today);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TodaysLessons_LoestRaumUeberPassendenStundenplanSlotAuf()
|
||||
{
|
||||
|
||||
@@ -18,8 +18,8 @@ public sealed class GroupDetailViewModelTests
|
||||
var grades = new FakeGrades();
|
||||
|
||||
var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks,
|
||||
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeSessions([]), new FakeEntries(), students,
|
||||
new FakeDocumentation(), tasks, new AttendanceBalanceService(), new SchoolYearService()),
|
||||
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeResults(), new FakeSessions([]), new FakeEntries(), students,
|
||||
new FakeDocumentation(), tasks, groups, new AttendanceBalanceService(), new GradingService(), new SchoolYearService()),
|
||||
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
|
||||
students, memberships, groups, new FakeCompetencyDomains()),
|
||||
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
|
||||
|
||||
@@ -12,10 +12,13 @@ public sealed class GroupOverviewViewModelTests
|
||||
{
|
||||
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
|
||||
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
|
||||
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null) =>
|
||||
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), sessions ?? new FakeSessions([]),
|
||||
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null,
|
||||
FakeResults? results = null, FakeGroups? groups = null) =>
|
||||
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), results ?? new FakeResults(),
|
||||
sessions ?? new FakeSessions([]),
|
||||
entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
|
||||
tasks ?? new FakeWorkTasks(), new AttendanceBalanceService(), new SchoolYearService());
|
||||
tasks ?? new FakeWorkTasks(), groups ?? new FakeGroups([]), new AttendanceBalanceService(),
|
||||
new GradingService(), new SchoolYearService());
|
||||
|
||||
private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams,
|
||||
FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario(
|
||||
@@ -76,6 +79,86 @@ public sealed class GroupOverviewViewModelTests
|
||||
Assert.Contains("Klassenarbeit 2", vm.NextExamLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorjahresvergleich_OhneVerknuepfteVorgaengergruppe_BleibtVerborgen()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var groups = new FakeGroups([new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6 }]);
|
||||
var vm = NewVm(groups: groups);
|
||||
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.False(vm.HasYearComparison);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorjahresvergleich_VorgaengerOhneKlausurergebnisse_BleibtVerborgen()
|
||||
{
|
||||
var previousId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var groups = new FakeGroups([
|
||||
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
|
||||
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
|
||||
]);
|
||||
var vm = NewVm(groups: groups);
|
||||
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.False(vm.HasYearComparison);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorjahresvergleich_ZeigtDurchschnitteBeiderJahreAn()
|
||||
{
|
||||
var previousId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var groups = new FakeGroups([
|
||||
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
|
||||
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
|
||||
]);
|
||||
|
||||
var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
|
||||
var currentExam = new Exam { Id = Guid.NewGuid(), GroupId = groupId };
|
||||
var exams = new FakeExams([previousExam, currentExam]);
|
||||
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "2" });
|
||||
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "4" });
|
||||
results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Grade = "1" });
|
||||
// Abwesende und leere Ergebnisse fließen bewusst nicht in den Schnitt ein.
|
||||
results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Absent = true, Grade = "6" });
|
||||
|
||||
var vm = NewVm(exams: exams, results: results, groups: groups);
|
||||
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.True(vm.HasYearComparison);
|
||||
Assert.Contains("Dieses Jahr: Ø 1,0 (1 Klausuren)", vm.YearComparisonLabel);
|
||||
Assert.Contains("Vorjahr: Ø 3,0 (2 Klausuren)", vm.YearComparisonLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorjahresvergleich_OhneEigeneKlausurenZeigtNurVorjahr()
|
||||
{
|
||||
var previousId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var groups = new FakeGroups([
|
||||
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
|
||||
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
|
||||
]);
|
||||
var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
|
||||
var exams = new FakeExams([previousExam]);
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "3" });
|
||||
|
||||
var vm = NewVm(exams: exams, results: results, groups: groups);
|
||||
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.True(vm.HasYearComparison);
|
||||
Assert.Contains("noch keine Klausur", vm.YearComparisonLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mitarbeit_OhneSitzungenZeigtHinweisOhneWarnung()
|
||||
{
|
||||
|
||||
@@ -103,6 +103,47 @@ public sealed class QuickInputViewModelTests
|
||||
Assert.Null(rows[0].GetRating("quality"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetDayHighlight_SpeichertAufDerZeileUndAktualisiertDieAnzeige()
|
||||
{
|
||||
var aspects = new List<AspectColumnDef>
|
||||
{
|
||||
new(new ParticipationAspect { Key = "quality", Label = "Qualität" }),
|
||||
};
|
||||
var rows = new List<ParticipationStudentRow>
|
||||
{
|
||||
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
|
||||
};
|
||||
var vm = new QuickInputViewModel(rows, aspects);
|
||||
|
||||
vm.SetDayHighlight(DayHighlightKind.Standout);
|
||||
|
||||
Assert.Equal(DayHighlightKind.Standout, rows[0].DayHighlight);
|
||||
Assert.Equal("👑", vm.CurrentStudentDayHighlightSymbol);
|
||||
Assert.Equal("Spitzentag", vm.CurrentStudentDayHighlightLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZurueckZuVorherigemSchueler_ZeigtDessenTagesflaggeWiederAn()
|
||||
{
|
||||
var aspects = new List<AspectColumnDef>
|
||||
{
|
||||
new(new ParticipationAspect { Key = "quality", Label = "Qualität" }),
|
||||
};
|
||||
var rows = new List<ParticipationStudentRow>
|
||||
{
|
||||
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
|
||||
new(Guid.NewGuid(), "Ben", new ParticipationEntry(), aspects, []),
|
||||
};
|
||||
var vm = new QuickInputViewModel(rows, aspects);
|
||||
|
||||
vm.SetDayHighlight(DayHighlightKind.Sleepy); // Anna
|
||||
vm.NextStudent(); // zu Ben, keine Flagge
|
||||
vm.PreviousStudent(); // zurück zu Anna
|
||||
|
||||
Assert.Equal(DayHighlightKind.Sleepy, vm.CurrentStudentDayHighlight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf()
|
||||
{
|
||||
|
||||
@@ -472,6 +472,7 @@ public sealed class SeatingPlanViewModelTests
|
||||
vm.SetRatingByNumber(5);
|
||||
vm.ApplyAttendanceShortcut(1, clear: false);
|
||||
vm.ApplyHomeworkShortcut(7, clear: false);
|
||||
vm.ApplyDayHighlightShortcut(1, clear: false);
|
||||
|
||||
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||
Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date);
|
||||
@@ -481,6 +482,27 @@ public sealed class SeatingPlanViewModelTests
|
||||
Assert.Equal(AttendanceStatus.Present, entry.Attendance);
|
||||
Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework);
|
||||
Assert.True(entry.HomeworkMissing);
|
||||
Assert.Equal(DayHighlightKind.Standout, entry.DayHighlight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SitzplatzBewertung_TagesflaggeKannWiederGeloeschtWerden()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var studentId = Guid.NewGuid();
|
||||
var sessions = new FakeSessions([]);
|
||||
var entries = new FakeEntries();
|
||||
var vm = new SeatAssessmentViewModel(sessions, entries, new FakeAspects(),
|
||||
groupId, studentId, "Beispiel, Anna", canEdit: true);
|
||||
|
||||
vm.ApplyDayHighlightShortcut(3, clear: false); // aus Versehen "Schlechter Tag" gesetzt
|
||||
vm.ApplyDayHighlightShortcut(null, clear: true);
|
||||
|
||||
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||
var entry = entries.GetBySessionAndStudent(session.Id, studentId)!;
|
||||
Assert.Null(entry.DayHighlight);
|
||||
Assert.Equal("Keine Markierung", vm.DayHighlightLabel);
|
||||
Assert.All(vm.DayHighlightChoices, c => Assert.Equal(c.Kind is null, c.IsSelected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -7,6 +7,7 @@ using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Models;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
@@ -34,7 +35,7 @@ public sealed class SettingsViewModelTests
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
||||
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
||||
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
|
||||
@@ -320,7 +321,7 @@ public sealed class SettingsViewModelTests
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
@@ -348,7 +349,7 @@ public sealed class SettingsViewModelTests
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
@@ -380,7 +381,7 @@ public sealed class SettingsViewModelTests
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Tests für den Schüler-Leistungsüberblick (Nutzer-Feedback): Klausuren/Mitarbeit/Sonstige
|
||||
/// im Überblick, berechnete Zeugnisnote, Zielnoten- und Was-wäre-wenn-Rechner sowie der
|
||||
/// Bewerter-/Schülermodus-Unterschied (Kursdurchschnitt, Bearbeitbarkeit).
|
||||
public sealed class StudentPerformanceOverviewViewModelTests
|
||||
{
|
||||
private static readonly Guid GroupId = Guid.NewGuid();
|
||||
private static readonly Guid StudentId = Guid.NewGuid();
|
||||
private static readonly Guid OtherStudentId = Guid.NewGuid();
|
||||
|
||||
private static StudentPerformanceOverviewViewModel BuildViewModel(
|
||||
FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null,
|
||||
FakeSchemes? schemes = null, GradingSystem system = GradingSystem.Grades1To6)
|
||||
{
|
||||
var scheme = new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
|
||||
var schemesRepo = schemes ?? new FakeSchemes();
|
||||
if (schemes is null) schemesRepo.SetForGroup(GroupId, scheme);
|
||||
|
||||
return new StudentPerformanceOverviewViewModel(
|
||||
grades ?? new FakeGrades(), exams ?? new FakeExams([]), results ?? new FakeResults(),
|
||||
new FakeMemberships([]), schemesRepo, new GradingService(),
|
||||
GroupId, StudentId, GroupType.Class, system, "Beispiel, Anna", "Testkurs", "2025/26");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExamRows_ZeigtEigeneNoteUndKursdurchschnitt()
|
||||
{
|
||||
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||
var exams = new FakeExams([exam]);
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" });
|
||||
results.Add(new ExamResult { ExamId = exam.Id, StudentId = OtherStudentId, Grade = "4" });
|
||||
|
||||
var vm = BuildViewModel(exams, results);
|
||||
|
||||
var row = Assert.Single(vm.ExamRows);
|
||||
Assert.Equal("2", row.OwnGradeDisplay);
|
||||
Assert.Equal("3.0", row.ClassAverageDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExamHistory_ZeigtEigenenVerlaufUndMarkiertNotenabfall()
|
||||
{
|
||||
var exam1 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||
var exam2 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K2", Date = new DateOnly(2025, 9, 24) };
|
||||
var exams = new FakeExams([exam1, exam2]);
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = exam1.Id, StudentId = StudentId, Grade = "2" });
|
||||
results.Add(new ExamResult { ExamId = exam2.Id, StudentId = StudentId, Grade = "5" }); // Abfall + mangelhaft
|
||||
|
||||
var vm = BuildViewModel(exams, results);
|
||||
|
||||
Assert.Equal(2, vm.ExamHistory.Points.Count);
|
||||
Assert.False(vm.ExamHistory.Points[0].IsWarning);
|
||||
Assert.True(vm.ExamHistory.Points[1].IsWarning);
|
||||
Assert.Contains("Abfall", vm.ExamHistory.Points[1].WarningText);
|
||||
Assert.Contains("Versetzungsgefährdung", vm.ExamHistory.Points[1].WarningText);
|
||||
Assert.True(vm.ExamHistory.Points[1].BarHeight < vm.ExamHistory.Points[0].BarHeight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AktuelleZeugnisnote_KombiniertAlleDreiBereiche()
|
||||
{
|
||||
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||
var exams = new FakeExams([exam]);
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" });
|
||||
var grades = new FakeGrades();
|
||||
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||
Date = new DateOnly(2025, 9, 5), Value = "3" });
|
||||
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Other,
|
||||
Date = new DateOnly(2025, 9, 5), Value = "1" });
|
||||
|
||||
var vm = BuildViewModel(exams, results, grades);
|
||||
|
||||
// (2*50 + 3*40 + 1*10) / 100 = 2.3 -> kaufmännisch 2
|
||||
Assert.Equal("Note 2", vm.CurrentReportGradeDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zielnote_BerechnetNoetigenKlausurschnitt()
|
||||
{
|
||||
var grades = new FakeGrades();
|
||||
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||
Date = new DateOnly(2025, 9, 5), Value = "3" });
|
||||
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Other,
|
||||
Date = new DateOnly(2025, 9, 5), Value = "2" });
|
||||
var vm = BuildViewModel(grades: grades);
|
||||
|
||||
vm.TargetGradeText = "2";
|
||||
vm.TargetBucketName = "Klausuren";
|
||||
|
||||
// Ziel 2,0 = (x*50 + 3*40 + 2*10)/100 => 200 = 50x+120+20 => x = 1,2
|
||||
Assert.Contains("1.2", vm.TargetResultDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WasWaereWenn_ZeigtZeugnisnoteMitZusaetzlicherKlausurAn()
|
||||
{
|
||||
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||
var exams = new FakeExams([exam]);
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" });
|
||||
var grades = new FakeGrades();
|
||||
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||
Date = new DateOnly(2025, 9, 5), Value = "2" });
|
||||
|
||||
var vm = BuildViewModel(exams, results, grades);
|
||||
|
||||
vm.WhatIfExamGradeText = "6";
|
||||
|
||||
// Klausuren neu: (2+6)/2=4 (50%), Mitarbeit 2 (40%, Sonstige fehlt) -> (4*50+2*40)/90 = 3,11 -> kaufm. 3
|
||||
Assert.Contains("3", vm.WhatIfResultDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveCommand_AufMitarbeitszeile_SpeichertUndAktualisiertDurchschnitt()
|
||||
{
|
||||
var grades = new FakeGrades();
|
||||
var grade = new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||
Date = new DateOnly(2025, 9, 5), Value = "3" };
|
||||
grades.Add(grade);
|
||||
var vm = BuildViewModel(grades: grades);
|
||||
|
||||
var row = Assert.Single(vm.ParticipationRows);
|
||||
row.Value = "1";
|
||||
row.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Equal("1.0", vm.ParticipationAverageDisplay);
|
||||
Assert.Equal("1", grades.GetByStudentAndGroup(StudentId, GroupId).Single().Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OhneWerteImZeitraum_ZeigtHinweisStattZeugnisnote()
|
||||
{
|
||||
var vm = BuildViewModel();
|
||||
|
||||
Assert.Contains("Noch nicht berechenbar", vm.CurrentReportGradeDisplay);
|
||||
}
|
||||
}
|
||||
@@ -712,6 +712,31 @@ public sealed class TimetableViewModelTests
|
||||
Assert.Equal("1", period2.BadgeText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Doppelstunde_ZeigtLessonThemaInBeidenKacheln_AuchOhnePhasenUndStundenraster()
|
||||
{
|
||||
var date = DateInCurrentWeek(DayOfWeek.Monday);
|
||||
var group = new LearningGroup { Name = "10c" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = date.DayOfWeek, PeriodNumber = 3 });
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = date.DayOfWeek, PeriodNumber = 4 });
|
||||
var lesson = new Lesson
|
||||
{
|
||||
GroupId = group.Id, Date = date, LessonNumber = 3, Topic = "Werkstoffprüfung",
|
||||
Phases = [],
|
||||
};
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
|
||||
|
||||
var first = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == date.DayOfWeek && c.PeriodNumber == 3);
|
||||
var second = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == date.DayOfWeek && c.PeriodNumber == 4);
|
||||
Assert.Equal("Werkstoffprüfung", first.Topic);
|
||||
Assert.Equal("Werkstoffprüfung", second.Topic);
|
||||
Assert.Equal(lesson.Id, second.Lesson?.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_KeineAnstehendenFerien_KeinBadge()
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Styling;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
@@ -153,6 +154,7 @@ public class App : Application
|
||||
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
|
||||
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
|
||||
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
|
||||
dash.OnAddMissingTeachingTime = item => ShowMissingTeachingTimeDialog(item, dash);
|
||||
|
||||
var examsOverview = Services.GetRequiredService<ViewModels.Exams.ExamsOverviewViewModel>();
|
||||
examsOverview.OnNavigateToGroups = () => main.NavigateToCommand.Execute(NavItem.Groups);
|
||||
@@ -208,6 +210,29 @@ public class App : Application
|
||||
Services.GetRequiredService<WorkTaskListViewModel>().Load();
|
||||
}
|
||||
|
||||
private static async Task ShowMissingTeachingTimeDialog(MissingTeachingTimeItem item, DashboardViewModel dashboard)
|
||||
{
|
||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
||||
{ MainWindow: { } owner }) return;
|
||||
|
||||
var tasks = Services.GetRequiredService<IWorkTaskRepository>()
|
||||
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
|
||||
var vm = new AddTimeEntryDialogViewModel(tasks)
|
||||
{
|
||||
DateText = item.Date.ToString("dd.MM.yyyy"),
|
||||
SelectedCategory = "Unterricht",
|
||||
StartTimeText = item.WindowStart.ToString("HH:mm"),
|
||||
EndTimeText = item.WindowEnd.ToString("HH:mm"),
|
||||
};
|
||||
var dialog = new AddTimeEntryDialog { DataContext = vm };
|
||||
await dialog.ShowDialog<bool>(owner);
|
||||
if (vm.Result is null) return;
|
||||
|
||||
Services.GetRequiredService<ITimeEntryRepository>().Save(vm.Result);
|
||||
dashboard.RefreshCommand.Execute(null);
|
||||
Services.GetRequiredService<TimeTrackingViewModel>().Load();
|
||||
}
|
||||
|
||||
private static async Task ShowQuickGroupDocumentationDialog(DashboardViewModel dashboard)
|
||||
{
|
||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
||||
|
||||
@@ -16,6 +16,7 @@ using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Sync.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop;
|
||||
|
||||
@@ -117,6 +118,9 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<NotificationService>();
|
||||
services.AddSingleton<ExportService>();
|
||||
services.AddSingleton<PdfExportService>();
|
||||
services.AddSingleton<ITemplateLoader, TemplateLoader>();
|
||||
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
|
||||
services.AddSingleton(_ => new TemplateStore(appData));
|
||||
|
||||
// ── Datensicherheit (13.3) ───────────────────────────────────────────
|
||||
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
|
||||
@@ -193,7 +197,6 @@ public static class AppBootstrapper
|
||||
services.AddSingleton(_ => new DashboardSettingsService(appData));
|
||||
services.AddSingleton(_ => new WindowSettingsService(appData));
|
||||
services.AddSingleton(_ => new AppearanceSettingsService(appData));
|
||||
services.AddSingleton(_ => new LetterTemplateService(appData));
|
||||
services.AddSingleton<PlanningExchangeService>();
|
||||
|
||||
// ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ──────
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace LehrerApp.Desktop.Services;
|
||||
public sealed class WebUntisIntegrationException(string message) : Exception(message);
|
||||
|
||||
public sealed record UntisSchoolYearDto(int UntisId, string Name, int StartDate, int EndDate);
|
||||
public sealed record UntisHolidayDto(int UntisId, string Name, string? LongName, int StartDate, int EndDate);
|
||||
public sealed record UntisClassDto(int UntisId, string Name, string? LongName);
|
||||
public sealed record UntisTeacherDto(int UntisId, string Name, string? ForeName, string? LongName, string? Title,
|
||||
bool Active, IReadOnlyList<int> DepartmentUntisIds)
|
||||
@@ -81,6 +82,10 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
|
||||
async client => (IReadOnlyList<UntisSchoolYearDto>)(await client.GetSchoolYearsAsync(token))
|
||||
.Select(x => new UntisSchoolYearDto(x.UntisId, x.Name, x.StartDate, x.EndDate)).ToList(), token);
|
||||
|
||||
public Task<IReadOnlyList<UntisHolidayDto>> GetHolidaysAsync(CancellationToken token = default) => ExecuteAsync(
|
||||
async client => (IReadOnlyList<UntisHolidayDto>)(await client.GetHolidaysAsync(token))
|
||||
.Select(x => new UntisHolidayDto(x.UntisId, x.Name, x.LongName, x.StartDate, x.EndDate)).ToList(), token);
|
||||
|
||||
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) => ExecuteAsync(
|
||||
async client => (IReadOnlyList<UntisClassDto>)(await client.GetClassesAsync(schoolYearId, token))
|
||||
.Select(x => new UntisClassDto(x.UntisId, x.Name, x.LongName)).ToList(), token);
|
||||
|
||||
@@ -13,10 +13,17 @@ internal class WebUntisSettingsConfig
|
||||
public int? TeacherUntisId { get; set; }
|
||||
public int? HomeroomClassUntisId { get; set; }
|
||||
public string? HomeroomClassName { get; set; }
|
||||
public List<CachedUntisHoliday>? CachedHolidays { get; set; }
|
||||
public DateTime? HolidaysFetchedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed record WebUntisCredentials(string School, string Host, string Username, string Password);
|
||||
|
||||
/// <summary>Ferienzeitraum aus WebUntis' <c>getHolidays</c>-Bericht, hier auf die für die
|
||||
/// Fehlquoten-Berechnung ("Klassenlehrer"-Feature) relevanten Felder reduziert. Kein Geheimnis
|
||||
/// (anders als iCal-URL/API-Zugangsdaten in dieser Datei), deshalb unverschlüsselt gecacht.</summary>
|
||||
public sealed record CachedUntisHoliday(string Name, DateOnly Start, DateOnly End);
|
||||
|
||||
/// <summary>
|
||||
/// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie
|
||||
/// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die
|
||||
@@ -115,6 +122,16 @@ public class WebUntisSettingsService
|
||||
Save();
|
||||
}
|
||||
|
||||
public DateTime? HolidaysFetchedAt => _config.HolidaysFetchedAt;
|
||||
public IReadOnlyList<CachedUntisHoliday>? GetCachedHolidays() => _config.CachedHolidays;
|
||||
|
||||
public void SetCachedHolidays(IReadOnlyList<CachedUntisHoliday> holidays, DateTime at)
|
||||
{
|
||||
_config.CachedHolidays = holidays.ToList();
|
||||
_config.HolidaysFetchedAt = at;
|
||||
Save();
|
||||
}
|
||||
|
||||
private byte[] GenerateAndSaveKey()
|
||||
{
|
||||
var key = SyncCrypto.GenerateKey();
|
||||
|
||||
@@ -71,19 +71,29 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
};
|
||||
|
||||
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
|
||||
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt nur
|
||||
/// Werktage, ohne Ferienkalender — eine bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag.
|
||||
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt Werktage
|
||||
/// abzüglich der über WebUntis geladenen Ferien (<see cref="ClassTeacherOverviewViewModel.CountSchoolWeekdays"/>).
|
||||
public int YearAbsenceDayCount { get; init; }
|
||||
public int YearUnexcusedDayCount { get; init; }
|
||||
public int SchoolDaysElapsed { get; init; }
|
||||
/// Nutzer-Feedback: nachdem der Nenner zeitweise Ferientage mitzählte (siehe TODO.md), soll die
|
||||
/// Herkunft der Zahl nachvollziehbar bleiben, ohne dafür die WebUntis-Ferienliste separat
|
||||
/// nachschlagen zu müssen — deshalb hier sichtbar im Tooltip statt nur intern verrechnet.
|
||||
public int HolidayWeekdaysExcluded { get; init; }
|
||||
/// Angenommener erster Unterrichtstag (<see cref="ClassTeacherOverviewViewModel.EstimateTermStart"/>),
|
||||
/// aus demselben Nachvollziehbarkeits-Grund wie <see cref="HolidayWeekdaysExcluded"/> im Tooltip
|
||||
/// sichtbar — WebUntis' Ferienkalender deckt die Sommerferien selbst nicht ab (siehe TODO.md),
|
||||
/// die Korrektur passiert also am Startpunkt, nicht an abgezogenen Tagen mittendrin.
|
||||
public DateOnly TermStart { get; init; }
|
||||
public bool HasYearSummary => SchoolDaysElapsed > 0 && YearAbsenceDayCount > 0;
|
||||
public int YearAbsenceRatePercent =>
|
||||
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
|
||||
public string YearSummaryLabel => HasYearSummary
|
||||
? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : "";
|
||||
public string? YearSummaryTooltip => !HasYearSummary ? null :
|
||||
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen mit Fehlzeit" +
|
||||
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "");
|
||||
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} mit Fehlzeit" +
|
||||
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "") +
|
||||
(HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : "");
|
||||
|
||||
private static bool IsLateReason(string reason) =>
|
||||
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
|
||||
@@ -94,7 +104,9 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries,
|
||||
DateOnly? today = null,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
|
||||
int schoolDaysElapsed = 0)
|
||||
int schoolDaysElapsed = 0,
|
||||
int holidayWeekdaysExcluded = 0,
|
||||
DateOnly termStart = default)
|
||||
{
|
||||
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
|
||||
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
|
||||
@@ -126,6 +138,8 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
YearAbsenceDayCount = yearEntries.Count,
|
||||
YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused),
|
||||
SchoolDaysElapsed = schoolDaysElapsed,
|
||||
HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
|
||||
TermStart = termStart,
|
||||
};
|
||||
})
|
||||
.OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList();
|
||||
@@ -157,7 +171,7 @@ public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int U
|
||||
$"{UnexcusedCount} unentschuldigt · {LateExcusedCount} verspätet · {ExcusedCount} entschuldigt";
|
||||
}
|
||||
public sealed record ClassTeacherPatternNotice(string StudentName, string Message,
|
||||
ClassTeacherStatusKind Kind)
|
||||
ClassTeacherStatusKind Kind, bool CanCreateReminder = false)
|
||||
{
|
||||
public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger;
|
||||
public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning;
|
||||
@@ -182,7 +196,10 @@ public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date
|
||||
|
||||
public static IReadOnlyList<ClassTeacherOpenExcuseRow> Build(
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today) =>
|
||||
absenceDays.Where(a => a.IsUnexcused)
|
||||
// Verspätungen ausgeschlossen: dafür gibt es i.d.R. keine Entschuldigungspflicht, eine
|
||||
// "Entschuldigung fehlt"-Erinnerung wäre hier gegenstandslos. Wiederholte Verspätungen
|
||||
// laufen stattdessen über die Mustererkennung (siehe BuildPatternNotices).
|
||||
absenceDays.Where(a => a.IsUnexcused && !a.IsLate)
|
||||
.Select(a => new ClassTeacherOpenExcuseRow(a.StudentDisplayName, a.Date,
|
||||
today.DayNumber - a.Date.DayNumber))
|
||||
.OrderByDescending(r => r.DaysOpen).ThenBy(r => r.StudentName)
|
||||
@@ -192,12 +209,14 @@ public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date
|
||||
public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
{
|
||||
private readonly WebUntisSettingsService _settings;
|
||||
private readonly WebUntisIntegrationService _untis;
|
||||
private readonly UntisReportCacheService _cache;
|
||||
private readonly SchoolYearService _schoolYear;
|
||||
private readonly IWorkTaskRepository _workTasks;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IParticipationRepository _participation;
|
||||
private readonly IParticipationSessionRepository _participationSessions;
|
||||
private readonly AppLogger? _logger;
|
||||
|
||||
public ClassTeacherDetailsViewModel DetailsTab { get; }
|
||||
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
|
||||
@@ -268,18 +287,21 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
public Func<Task>? OnNavigateToSettings { get; set; }
|
||||
public Func<Task>? OnNavigateToWorkload { get; set; }
|
||||
|
||||
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings,
|
||||
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
|
||||
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
|
||||
IStudentRepository students, IParticipationRepository participation,
|
||||
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab)
|
||||
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
|
||||
AppLogger? logger = null)
|
||||
{
|
||||
_settings = settings;
|
||||
_untis = untis;
|
||||
_cache = cache;
|
||||
_schoolYear = schoolYear;
|
||||
_workTasks = workTasks;
|
||||
_students = students;
|
||||
_participation = participation;
|
||||
_participationSessions = participationSessions;
|
||||
_logger = logger;
|
||||
DetailsTab = detailsTab;
|
||||
}
|
||||
|
||||
@@ -325,14 +347,18 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
var studentsTask = _cache.GetStudentRosterAsync(className);
|
||||
var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
|
||||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, sevenDayStart, today);
|
||||
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask);
|
||||
var holidaysTask = GetHolidaysAsync();
|
||||
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask);
|
||||
|
||||
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
|
||||
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
|
||||
var weekAbsenceDays = absenceDaysYear.Where(a => a.Date >= sevenDayStart).ToList();
|
||||
var schoolDaysElapsed = CountWeekdays(yearStart, today);
|
||||
var termStart = EstimateTermStart(absenceDaysYear, yearStart);
|
||||
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
|
||||
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result);
|
||||
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
|
||||
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
|
||||
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed)) Roster.Add(row);
|
||||
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed,
|
||||
holidayWeekdaysExcluded, termStart)) Roster.Add(row);
|
||||
|
||||
StudentCount = Roster.Count;
|
||||
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
||||
@@ -343,7 +369,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
|
||||
RecentClassRegisterCount = classRegisterTask.Result.Count;
|
||||
BuildTrend(absenceDaysYear, trendDays);
|
||||
BuildPatternNotices(weekAbsenceDays);
|
||||
BuildPatternNotices(absenceDaysYear, sevenDayStart);
|
||||
BuildWeekdayPatternNotices(absenceDaysYear);
|
||||
BuildAttendanceParticipationNotices();
|
||||
BuildOpenExcuses(absenceDaysYear, today);
|
||||
@@ -451,34 +477,123 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
return days;
|
||||
}
|
||||
|
||||
/// Nenner für <see cref="ClassTeacherRosterRow.YearAbsenceRatePercent"/> — Werktage zwischen
|
||||
/// Schuljahresbeginn und heute, ebenfalls ohne Ferienkalender.
|
||||
private static int CountWeekdays(DateOnly start, DateOnly end)
|
||||
/// Nutzer-Feedback: Schüler*innen, die seit Unterrichtsbeginn nachweislich an jedem Tag
|
||||
/// fehlten, zeigten trotzdem nur ~57 % Fehlquote statt der erwarteten ~100 %. Ursache: der
|
||||
/// Nenner zählte bislang jeden Werktag ab dem fest verdrahteten 1. August
|
||||
/// (<see cref="SchoolYearService.SchoolYearStart"/>) als "Schultag" mit — die Sommerferien
|
||||
/// enden je nach Bundesland/Jahr aber erst Wochen später, und genau zu Schuljahresbeginn macht
|
||||
/// diese Ferienzeit einen großen Teil des bis dahin "verstrichenen" Zeitraums aus. WebUntis
|
||||
/// kennt einen Teil des echten Ferienkalenders (<c>getHolidays</c>-Bericht, hier zwischen den
|
||||
/// Werktagen ausgeschlossen) und behebt damit die kleineren Verzerrungen durch Herbst-/
|
||||
/// Weihnachts-/Osterferien im weiteren Jahresverlauf. Die Sommerferien selbst liefert
|
||||
/// <c>getHolidays</c> nach Prüfung der echten Antwort für dieses Konto aber NIE (über 11 Jahre
|
||||
/// zurück kein einziger Sommerferien-Eintrag, siehe <see cref="EstimateTermStart"/>) — sie
|
||||
/// liegen WebUntis-intern vermutlich außerhalb jedes Schuljahres-Datensatzes (der bei 1.8./31.7.
|
||||
/// endet), nicht "in" einem davon. Für die Sommerferien bleibt deshalb weiterhin
|
||||
/// <see cref="EstimateTermStart"/> nötig, das den Startpunkt selbst korrigiert statt Tage
|
||||
/// innerhalb des Zeitraums abzuziehen.
|
||||
public static int CountSchoolWeekdays(DateOnly start, DateOnly end, IReadOnlyList<CachedUntisHoliday> holidays)
|
||||
{
|
||||
if (end < start) return 0;
|
||||
var count = 0;
|
||||
for (var d = start; d <= end; d = d.AddDays(1))
|
||||
if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)) count++;
|
||||
if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday) &&
|
||||
!holidays.Any(h => d >= h.Start && d <= h.End))
|
||||
count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays)
|
||||
/// Ergänzt <see cref="CountSchoolWeekdays"/> um genau die Lücke, die WebUntis' echter
|
||||
/// Ferienkalender nicht schließt: die Sommerferien. Nimmt den frühesten Tag mit irgendeinem
|
||||
/// Fehlzeiten-Eintrag der ganzen Klasse als Näherung für den tatsächlichen ersten
|
||||
/// Unterrichtstag (ein solcher Eintrag kann nur an einem Tag mit tatsächlich stattfindendem
|
||||
/// Unterricht entstehen) — Rückfall auf <paramref name="fallback"/>, wenn noch keine
|
||||
/// Fehlzeiten vorliegen (dann bleibt <see cref="ClassTeacherRosterRow.HasYearSummary"/>
|
||||
/// ohnehin ausgeblendet).
|
||||
public static DateOnly EstimateTermStart(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear, DateOnly fallback) =>
|
||||
absenceDaysYear.Count > 0 ? absenceDaysYear.Min(a => a.Date) : fallback;
|
||||
|
||||
/// Ferien ändern sich innerhalb eines Schuljahrs praktisch nie (anders als die
|
||||
/// Fehlzeiten-/Klassenbuchberichte, deshalb hier keine der stündlichen "heißes Fenster"-Logik
|
||||
/// aus <see cref="UntisReportCacheService"/>, sondern ein einfacher tagesgenauer Cache über
|
||||
/// <see cref="WebUntisSettingsService"/> — kein zusätzliches LiteDB-Repository nötig). Schlägt
|
||||
/// der Live-Abruf fehl (z. B. kurzzeitig kein Netz), wird der zuletzt bekannte Stand
|
||||
/// weiterverwendet statt die ganze Übersicht mit einem Fehler zu blockieren; ist noch nie
|
||||
/// erfolgreich abgerufen worden, bleibt die Liste leer und <see cref="CountSchoolWeekdays"/>
|
||||
/// verhält sich wie vorher (reine Werktagszählung ohne Ferienabzug).
|
||||
private static readonly TimeSpan HolidaysRefreshInterval = TimeSpan.FromDays(1);
|
||||
|
||||
private async Task<IReadOnlyList<CachedUntisHoliday>> GetHolidaysAsync()
|
||||
{
|
||||
foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
|
||||
var cached = _settings.GetCachedHolidays();
|
||||
if (cached is not null && _settings.HolidaysFetchedAt is { } fetchedAt &&
|
||||
DateTime.UtcNow - fetchedAt < HolidaysRefreshInterval)
|
||||
return cached;
|
||||
try
|
||||
{
|
||||
var fresh = (await _untis.GetHolidaysAsync())
|
||||
.Select(h => new CachedUntisHoliday(h.Name, ParseDate(h.StartDate), ParseDate(h.EndDate)))
|
||||
.ToList();
|
||||
_settings.SetCachedHolidays(fresh, DateTime.UtcNow);
|
||||
_logger?.Info("Klassenlehrer: WebUntis-Ferien geladen — " +
|
||||
string.Join("; ", fresh.Select(h => $"{h.Name} {h.Start:yyyy-MM-dd}..{h.End:yyyy-MM-dd}")));
|
||||
return fresh;
|
||||
}
|
||||
catch (WebUntisIntegrationException ex)
|
||||
{
|
||||
_logger?.Error("Klassenlehrer: WebUntis-Ferienabruf fehlgeschlagen", ex);
|
||||
return cached ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
private static DateOnly ParseDate(int value) => DateOnly.ParseExact(value.ToString(), "yyyyMMdd");
|
||||
|
||||
private const int LateYearEscalationThreshold = 5;
|
||||
|
||||
private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear, DateOnly sevenDayStart)
|
||||
{
|
||||
var displayNames = Roster.GroupBy(r => UntisNameMatching.NameKey(r.StudentName))
|
||||
.ToDictionary(g => g.Key, g => g.First().StudentName);
|
||||
foreach (var notice in DetectLatePatterns(absenceDaysYear, displayNames, sevenDayStart))
|
||||
PatternNotices.Add(notice);
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
}
|
||||
|
||||
/// Verspätungen sind i.d.R. nicht entschuldigungsfähig (siehe <see cref="ClassTeacherOpenExcuseRow.Build"/>)
|
||||
/// — eine Häufung ist trotzdem ein Signal, nur eines für ein Elterngespräch/einen Brief statt
|
||||
/// für eine fehlende Entschuldigung. Zwei Stufen, Nutzer-Feedback: ein kurzfristiges Cluster
|
||||
/// (<paramref name="sevenDayStart"/>..heute) bleibt ein sanfter Hinweis ohne Aktion, eine hohe
|
||||
/// Jahressumme eskaliert zu Danger mit Wiedervorlage-Option
|
||||
/// (<see cref="CreateReminderForPatternNoticeCommand"/>). Reine, ohne ViewModel-Zustand
|
||||
/// testbare Kernlogik (gleiches Muster wie <see cref="DetectWeekdayPatterns"/>).
|
||||
public static IReadOnlyList<ClassTeacherPatternNotice> DetectLatePatterns(
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear,
|
||||
IReadOnlyDictionary<string, string> displayNamesByKey,
|
||||
DateOnly sevenDayStart,
|
||||
int lateYearEscalationThreshold = LateYearEscalationThreshold)
|
||||
{
|
||||
var notices = new List<ClassTeacherPatternNotice>();
|
||||
foreach (var group in absenceDaysYear.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
|
||||
{
|
||||
var rows = group.ToList();
|
||||
var displayName = Roster.FirstOrDefault(row =>
|
||||
UntisNameMatching.NameKey(row.StudentName) == group.Key)?.StudentName ?? rows[0].StudentDisplayName;
|
||||
var lateDays = rows.Count(row => row.IsLate);
|
||||
var unexcusedDays = rows.Count(row => row.IsUnexcused && !row.IsLate);
|
||||
if (unexcusedDays >= 2)
|
||||
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{unexcusedDays} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger));
|
||||
else if (lateDays >= 2)
|
||||
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{lateDays}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning));
|
||||
var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName;
|
||||
var weekRows = rows.Where(row => row.Date >= sevenDayStart).ToList();
|
||||
var lateDaysWeek = weekRows.Count(row => row.IsLate);
|
||||
var unexcusedDaysWeek = weekRows.Count(row => row.IsUnexcused && !row.IsLate);
|
||||
var lateDaysYear = rows.Count(row => row.IsLate);
|
||||
|
||||
if (unexcusedDaysWeek >= 2)
|
||||
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{unexcusedDaysWeek} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger));
|
||||
else if (lateDaysYear >= lateYearEscalationThreshold)
|
||||
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{lateDaysYear} Verspätungen seit Schuljahresbeginn – Elterngespräch oder Brief erwägen",
|
||||
ClassTeacherStatusKind.Danger, CanCreateReminder: true));
|
||||
else if (lateDaysWeek >= 2)
|
||||
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{lateDaysWeek}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning));
|
||||
}
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
return notices;
|
||||
}
|
||||
|
||||
/// Erweiterte Mustererkennung (Nutzer-Feedback): die obigen Regeln schauen nur auf die letzten
|
||||
@@ -635,6 +750,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
urgent: row.IsOverdue);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CreateReminderForPatternNotice(ClassTeacherPatternNotice? notice)
|
||||
{
|
||||
if (notice is null || !notice.CanCreateReminder) return;
|
||||
SaveReminder(notice.StudentName, notice.Message, urgent: true);
|
||||
}
|
||||
|
||||
private void SaveReminder(string studentName, string notes, bool urgent)
|
||||
{
|
||||
var task = new WorkTask
|
||||
|
||||
@@ -35,6 +35,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
private readonly PublicHolidayService _publicHolidays;
|
||||
private readonly SchoolCalendarSettingsService _calendarSettings;
|
||||
private readonly ISubstitutionEntryRepository _substitutions;
|
||||
private readonly ITimeEntryRepository _timeEntries;
|
||||
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
|
||||
private readonly SchoolWeatherService? _schoolWeather;
|
||||
|
||||
@@ -50,6 +51,18 @@ public partial class DashboardViewModel : ObservableObject
|
||||
/// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
|
||||
/// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize.
|
||||
private const int AttendanceMinSampleSize = 8;
|
||||
/// Nutzer-Feedback: "ich vergesse es, und fasse die App auch außerhalb der Schule manchmal
|
||||
/// nicht mehr an" — die Erinnerung an fehlende Unterrichtszeit-Erfassung schaut deshalb nicht
|
||||
/// nur auf heute zurück, sondern auf ein paar Tage, damit ein vergessener Tag nicht verloren
|
||||
/// geht, sobald der nächste Schultag anbricht.
|
||||
private const int MissingTeachingTimeLookbackDays = 14;
|
||||
/// Für den heutigen Tag soll die Erinnerung nicht schon mitten im Unterricht auftauchen —
|
||||
/// erst diese Zeitspanne nach dem laut Stundenplan letzten Unterrichtsende.
|
||||
private const int MissingTeachingTimeTodayDelayMinutes = 30;
|
||||
/// Gleiche Puffer-Idee wie TimeTrackingViewModel.ComputeTodaysTeachingWindow (bewusst hier
|
||||
/// noch einmal definiert statt geteilt — der Vorschlag ist nur zwei Zeilen Rechnung).
|
||||
private const int TeachingWindowBufferBeforeMinutes = 15;
|
||||
private const int TeachingWindowBufferAfterMinutes = 10;
|
||||
/// Vorausschau für die Klausurwochen-Karte (Nutzer-Feedback): weit genug, um eine sich
|
||||
/// anbahnende Häufung noch rechtzeitig vor dem Anlegen weiterer Klausuren zu zeigen, aber
|
||||
/// keine Vorschau auf das ganze Schuljahr.
|
||||
@@ -77,6 +90,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
|
||||
public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = [];
|
||||
public ObservableCollection<ExamWeekLoadItem> ExamWeekLoads { get; } = [];
|
||||
public ObservableCollection<MissingTeachingTimeItem> MissingTeachingTimeEntries { get; } = [];
|
||||
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
|
||||
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
|
||||
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
|
||||
@@ -101,6 +115,10 @@ public partial class DashboardViewModel : ObservableObject
|
||||
// Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe
|
||||
// (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann.
|
||||
public Action<Guid>? OnNavigateToUnplannedLesson { get; set; }
|
||||
// Öffnet den Nacherfassen-Dialog für einen Tag mit fehlender Unterrichtszeit-Erfassung
|
||||
// (Nutzer-Feedback), vorbelegt mit Datum, Kategorie "Unterricht" und dem laut Stundenplan
|
||||
// berechneten Zeitfenster — echtes Speichern bleibt eine bewusste Bestätigung im Dialog.
|
||||
public Func<MissingTeachingTimeItem, Task>? OnAddMissingTeachingTime { get; set; }
|
||||
|
||||
public DashboardCardOption TodayCard => Card("today");
|
||||
public DashboardCardOption TasksCard => Card("tasks");
|
||||
@@ -112,6 +130,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
public DashboardCardOption AlertsCard => Card("alerts");
|
||||
public DashboardCardOption AttendanceCard => Card("attendance");
|
||||
public DashboardCardOption ExamLoadCard => Card("examload");
|
||||
public DashboardCardOption MissingTeachingTimeCard => Card("missingteachingtime");
|
||||
public DashboardCardOption SupportCard => Card("support");
|
||||
public DashboardCardOption GroupsCard => Card("groups");
|
||||
public int TodayLessonCount => TodaysLessons.Count;
|
||||
@@ -133,7 +152,8 @@ public partial class DashboardViewModel : ObservableObject
|
||||
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
|
||||
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
|
||||
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
|
||||
ISubstitutionEntryRepository substitutions, IAnnualPlanEventRepository? annualPlanEvents = null,
|
||||
ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
|
||||
IAnnualPlanEventRepository? annualPlanEvents = null,
|
||||
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
|
||||
{
|
||||
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
|
||||
@@ -143,6 +163,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
||||
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
|
||||
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
|
||||
_timeEntries = timeEntries;
|
||||
_substitutions = substitutions;
|
||||
_annualPlanEvents = annualPlanEvents;
|
||||
_schoolWeather = schoolWeather;
|
||||
@@ -216,6 +237,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
LoadOpenExcuses(groups.Values.ToList(), today);
|
||||
LoadAttendanceWarnings(today);
|
||||
LoadExamWeekLoads(groups, today);
|
||||
LoadMissingTeachingTime(today);
|
||||
LoadSupportPlanReviews(today);
|
||||
LoadUpcomingDates(groups, today);
|
||||
LoadOpenCorrections(groups, today);
|
||||
@@ -236,6 +258,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
AlertsCard.IsEmpty = Alerts.Count == 0;
|
||||
AttendanceCard.IsEmpty = AttendanceWarnings.Count == 0;
|
||||
ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0;
|
||||
MissingTeachingTimeCard.IsEmpty = MissingTeachingTimeEntries.Count == 0;
|
||||
SupportCard.IsEmpty = SupportPlanReviews.Count == 0;
|
||||
GroupsCard.IsEmpty = CurrentGroups.Count == 0;
|
||||
|
||||
@@ -351,6 +374,54 @@ public partial class DashboardViewModel : ObservableObject
|
||||
ExamWeekLoads.Add(new ExamWeekLoadItem(week.IsoWeek, week.WeekStart, week.WeekEnd, week.ExamCount));
|
||||
}
|
||||
|
||||
// ── Unterrichtszeit-Erfassung nachholen (Nutzer-Feedback) ─────────────────
|
||||
//
|
||||
// "Ich vergesse es, und fasse die App außerhalb der Schule manchmal nicht mehr an" — schaut
|
||||
// deshalb bewusst ein paar Tage zurück statt nur auf heute. Ein Tag zählt als erfasst, sobald
|
||||
// irgendein TimeEntry der Kategorie "Unterricht" an diesem Datum existiert; die genaue
|
||||
// Zeitüberschneidung mit dem Stundenplan wird nicht geprüft (dieselbe grobe Betrachtung wie
|
||||
// beim bestehenden "Unterrichtszeit übernehmen"-Vorschlag für heute).
|
||||
|
||||
private void LoadMissingTeachingTime(DateOnly today)
|
||||
{
|
||||
MissingTeachingTimeEntries.Clear();
|
||||
var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays);
|
||||
var publicHolidayDates = Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1)
|
||||
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
|
||||
.Select(h => h.Date).ToHashSet();
|
||||
var schoolHolidays = _schoolHolidays.GetAll();
|
||||
var nowTime = TimeOnly.FromDateTime(DateTime.Now);
|
||||
|
||||
var items = new List<MissingTeachingTimeItem>();
|
||||
for (var date = firstDay; date <= today; date = date.AddDays(1))
|
||||
{
|
||||
if (IsFreeDay(date, schoolHolidays, publicHolidayDates)) continue;
|
||||
|
||||
var daySlots = _timetableSlots.GetAll().Where(s => s.Weekday == date.DayOfWeek).ToList();
|
||||
if (daySlots.Count == 0) continue;
|
||||
|
||||
var cancelledPeriods = _substitutions.GetByDate(date)
|
||||
.Where(s => s.Kind == SubstitutionKind.Cancelled)
|
||||
.Select(s => s.PeriodNumber).ToHashSet();
|
||||
var periodTimes = daySlots.Where(s => !cancelledPeriods.Contains(s.PeriodNumber))
|
||||
.Select(s => _periodSchedule.GetTimes(s.PeriodNumber))
|
||||
.Where(t => t is not null).Select(t => t!.Value).ToList();
|
||||
if (periodTimes.Count == 0) continue; // alle Stunden entfallen oder kein Zeitraster hinterlegt
|
||||
|
||||
var lastPeriodEnd = periodTimes.Max(t => t.End);
|
||||
if (date == today && nowTime < lastPeriodEnd.AddMinutes(MissingTeachingTimeTodayDelayMinutes))
|
||||
continue; // heute: erst 30 Minuten nach Unterrichtsschluss erinnern
|
||||
|
||||
if (_timeEntries.GetByDate(date).Any(e => e.Category == "Unterricht")) continue;
|
||||
|
||||
var windowStart = periodTimes.Min(t => t.Start).AddMinutes(-TeachingWindowBufferBeforeMinutes);
|
||||
var windowEnd = lastPeriodEnd.AddMinutes(TeachingWindowBufferAfterMinutes);
|
||||
items.Add(new MissingTeachingTimeItem(date, windowStart, windowEnd));
|
||||
}
|
||||
foreach (var item in items.OrderBy(i => i.Date))
|
||||
MissingTeachingTimeEntries.Add(item);
|
||||
}
|
||||
|
||||
// ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────
|
||||
|
||||
private void LoadSupportPlanReviews(DateOnly today)
|
||||
@@ -576,7 +647,8 @@ public partial class DashboardViewModel : ObservableObject
|
||||
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine",
|
||||
"corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten",
|
||||
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
|
||||
"groups" => "Meine Lerngruppen", "examload" => "Klausurwochen", _ => key,
|
||||
"groups" => "Meine Lerngruppen", "examload" => "Klausurwochen",
|
||||
"missingteachingtime" => "Unterrichtszeit nacherfassen", _ => key,
|
||||
};
|
||||
|
||||
private void ApplyCardLayout()
|
||||
@@ -626,6 +698,15 @@ public partial class DashboardViewModel : ObservableObject
|
||||
[RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item)
|
||||
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddMissingTeachingTime(MissingTeachingTimeItem? item)
|
||||
{
|
||||
if (item is null || OnAddMissingTeachingTime is null) return;
|
||||
await OnAddMissingTeachingTime(item);
|
||||
LoadMissingTeachingTime(DateOnly.FromDateTime(DateTime.Today));
|
||||
UpdateDashboardSummary();
|
||||
}
|
||||
|
||||
private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today)
|
||||
{
|
||||
OpenExcuses.Clear();
|
||||
@@ -1002,6 +1083,16 @@ public class ExamWeekLoadItem(int isoWeek, DateOnly weekStart, DateOnly weekEnd,
|
||||
public string CountDisplay => ExamCount == 1 ? "1 Klausur" : $"{ExamCount} Klausuren";
|
||||
}
|
||||
|
||||
public class MissingTeachingTimeItem(DateOnly date, TimeOnly windowStart, TimeOnly windowEnd)
|
||||
{
|
||||
private static readonly CultureInfo De = new("de-DE");
|
||||
|
||||
public DateOnly Date { get; } = date;
|
||||
public TimeOnly WindowStart { get; } = windowStart;
|
||||
public TimeOnly WindowEnd { get; } = windowEnd;
|
||||
public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De);
|
||||
}
|
||||
|
||||
// ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────────
|
||||
|
||||
public class SupportPlanDueItem
|
||||
|
||||
@@ -54,6 +54,7 @@ public partial class GradeOverviewTabViewModel : ObservableObject
|
||||
public Func<GradeOverviewRow, Task>? OnManageStudentGrades { get; set; }
|
||||
public Func<Task>? OnCollectiveGrade { get; set; }
|
||||
public Func<Task>? OnReportGrades { get; set; }
|
||||
public Func<GradeOverviewRow, Task>? OnShowPerformanceOverview { get; set; }
|
||||
|
||||
public GradeOverviewTabViewModel(IGradeRepository grades, IExamRepository exams,
|
||||
IExamResultRepository results, IStudentRepository students,
|
||||
@@ -107,6 +108,14 @@ public partial class GradeOverviewTabViewModel : ObservableObject
|
||||
Recompute();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ShowPerformanceOverview()
|
||||
{
|
||||
if (SelectedRow is null || OnShowPerformanceOverview is null) return;
|
||||
await OnShowPerformanceOverview(SelectedRow);
|
||||
Recompute();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CollectiveGrade()
|
||||
{
|
||||
|
||||
@@ -33,12 +33,15 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
|
||||
private readonly ILessonRepository _lessons;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _examResults;
|
||||
private readonly IParticipationSessionRepository _sessions;
|
||||
private readonly IParticipationRepository _entries;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IDocumentationRepository _documentation;
|
||||
private readonly IWorkTaskRepository _tasks;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly AttendanceBalanceService _attendanceBalance;
|
||||
private readonly GradingService _grading;
|
||||
private readonly SchoolYearService _schoolYear;
|
||||
|
||||
private Guid _groupId;
|
||||
@@ -48,6 +51,8 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
[ObservableProperty] private string _nextLessonLabel = "";
|
||||
[ObservableProperty] private bool _hasNextExam;
|
||||
[ObservableProperty] private string _nextExamLabel = "";
|
||||
[ObservableProperty] private bool _hasYearComparison;
|
||||
[ObservableProperty] private string _yearComparisonLabel = "";
|
||||
[ObservableProperty] private string _participationHintLabel = "";
|
||||
[ObservableProperty] private bool _participationHintIsStale;
|
||||
[ObservableProperty] private bool _hasOpenHomeworkCheck;
|
||||
@@ -105,13 +110,14 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
}
|
||||
|
||||
public GroupOverviewViewModel(ILessonRepository lessons, IExamRepository exams,
|
||||
IParticipationSessionRepository sessions, IParticipationRepository entries,
|
||||
IStudentRepository students, IDocumentationRepository documentation, IWorkTaskRepository tasks,
|
||||
AttendanceBalanceService attendanceBalance, SchoolYearService schoolYear)
|
||||
IExamResultRepository examResults, IParticipationSessionRepository sessions,
|
||||
IParticipationRepository entries, IStudentRepository students,
|
||||
IDocumentationRepository documentation, IWorkTaskRepository tasks, IGroupRepository groups,
|
||||
AttendanceBalanceService attendanceBalance, GradingService grading, SchoolYearService schoolYear)
|
||||
{
|
||||
_lessons = lessons; _exams = exams; _sessions = sessions;
|
||||
_lessons = lessons; _exams = exams; _examResults = examResults; _sessions = sessions;
|
||||
_entries = entries; _students = students; _documentation = documentation; _tasks = tasks;
|
||||
_attendanceBalance = attendanceBalance; _schoolYear = schoolYear;
|
||||
_groups = groups; _attendanceBalance = attendanceBalance; _grading = grading; _schoolYear = schoolYear;
|
||||
}
|
||||
|
||||
public void Initialize(Guid groupId, string groupName)
|
||||
@@ -126,6 +132,7 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
LoadNextLesson(today);
|
||||
LoadNextExam(today);
|
||||
LoadYearComparison();
|
||||
LoadParticipationHint(today);
|
||||
LoadOpenHomeworkCheck(today);
|
||||
LoadOpenExcuses(today);
|
||||
@@ -156,6 +163,38 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
NextExamLabel = next is null ? "" : $"{next.Date:dd.MM.yyyy} — {next.Title}";
|
||||
}
|
||||
|
||||
// ── Klausurschnitt-Vorjahresvergleich (Nutzer-Feedback) ──────────────────
|
||||
//
|
||||
// Setzt voraus, dass diese Gruppe über GroupRolloverService aus einer Vorgängergruppe
|
||||
// hochgestuft wurde (LearningGroup.PreviousGroupId) — vor Einführung dieses Felds
|
||||
// hochgestufte Gruppen bleiben unverknüpft und zeigen keinen Vergleich. Nur sichtbar, wenn
|
||||
// im Vorjahr tatsächlich Klausurergebnisse vorliegen; ohne die ist kein Vergleich möglich,
|
||||
// auch wenn eine Verknüpfung besteht.
|
||||
|
||||
private void LoadYearComparison()
|
||||
{
|
||||
var group = _groups.GetById(_groupId);
|
||||
var previous = group?.PreviousGroupId is Guid previousId ? ComputeExamAverage(previousId) : null;
|
||||
if (previous is null) { HasYearComparison = false; return; }
|
||||
|
||||
var current = ComputeExamAverage(_groupId);
|
||||
YearComparisonLabel = current is null
|
||||
? $"Vorjahr: Ø {previous.Value.Average:0.0} ({previous.Value.Count} Klausuren) — dieses Jahr noch keine Klausur."
|
||||
: $"Dieses Jahr: Ø {current.Value.Average:0.0} ({current.Value.Count} Klausuren) · " +
|
||||
$"Vorjahr: Ø {previous.Value.Average:0.0} ({previous.Value.Count} Klausuren)";
|
||||
HasYearComparison = true;
|
||||
}
|
||||
|
||||
private (double Average, int Count)? ComputeExamAverage(Guid groupId)
|
||||
{
|
||||
var grades = _exams.GetByGroup(groupId)
|
||||
.SelectMany(e => _examResults.GetByExam(e.Id))
|
||||
.Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade))
|
||||
.Select(r => (Grade: r.Grade!, Weight: 1.0))
|
||||
.ToList();
|
||||
return grades.Count == 0 ? null : (_grading.WeightedAverage(grades), grades.Count);
|
||||
}
|
||||
|
||||
/// Erinnert nicht an eine Note, sondern schlicht daran, überhaupt wieder eine Sitzung
|
||||
/// anzulegen — genau das vergisst man in Kursen, die man seltener unterrichtet, zuerst.
|
||||
private void LoadParticipationHint(DateOnly today)
|
||||
|
||||
@@ -158,6 +158,7 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
row.OnCompetencyRatingChanged = (sid, code, val) => SaveCompetencyRating(sessionId, sid, code, val);
|
||||
row.HomeworkChangedCallback = (sid, val) => SaveHomework(sessionId, sid, val);
|
||||
row.AttendanceChangedCallback = (sid, val) => SaveAttendance(sessionId, sid, val);
|
||||
row.DayHighlightChangedCallback = (sid, val) => SaveDayHighlight(sessionId, sid, val);
|
||||
StudentRows.Add(row);
|
||||
}
|
||||
QuickInputCommand.NotifyCanExecuteChanged();
|
||||
@@ -212,6 +213,15 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
_entries.Save(entry);
|
||||
}
|
||||
|
||||
private void SaveDayHighlight(Guid sessionId, Guid studentId, DayHighlightKind? value)
|
||||
{
|
||||
if (IsReadOnly) return;
|
||||
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
||||
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
|
||||
entry.DayHighlight = value;
|
||||
_entries.Save(entry);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleCompetencyTags() => CompetencyTagsVisible = !CompetencyTagsVisible;
|
||||
|
||||
@@ -389,6 +399,7 @@ public partial class ParticipationStudentRow : ObservableObject
|
||||
|
||||
[ObservableProperty] private HomeworkStatus? _homework;
|
||||
[ObservableProperty] private AttendanceStatus? _attendance;
|
||||
[ObservableProperty] private DayHighlightKind? _dayHighlight;
|
||||
|
||||
public string AttendanceLabel => AttendanceDisplay.ShortLabel(Attendance);
|
||||
public string AttendanceTooltip => AttendanceDisplay.Label(Attendance);
|
||||
@@ -405,6 +416,7 @@ public partial class ParticipationStudentRow : ObservableObject
|
||||
public Action<Guid, string, int?>? OnCompetencyRatingChanged { get; set; }
|
||||
public Action<Guid, HomeworkStatus?>? HomeworkChangedCallback { get; set; }
|
||||
public Action<Guid, AttendanceStatus?>? AttendanceChangedCallback { get; set; }
|
||||
public Action<Guid, DayHighlightKind?>? DayHighlightChangedCallback { get; set; }
|
||||
|
||||
public ParticipationStudentRow(Guid id, string name, ParticipationEntry entry,
|
||||
List<AspectColumnDef> aspects, List<string> competencyCodes)
|
||||
@@ -414,6 +426,7 @@ public partial class ParticipationStudentRow : ObservableObject
|
||||
_aspectDefs = aspects;
|
||||
_homework = HomeworkDisplay.Effective(entry);
|
||||
_attendance = entry.Attendance;
|
||||
_dayHighlight = entry.DayHighlight;
|
||||
|
||||
foreach (var a in aspects)
|
||||
{
|
||||
@@ -463,6 +476,12 @@ public partial class ParticipationStudentRow : ObservableObject
|
||||
HomeworkChangedCallback?.Invoke(StudentId, value);
|
||||
}
|
||||
|
||||
public void SetDayHighlight(DayHighlightKind? value)
|
||||
{
|
||||
DayHighlight = value;
|
||||
DayHighlightChangedCallback?.Invoke(StudentId, value);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CycleAttendance()
|
||||
{
|
||||
@@ -614,6 +633,27 @@ public static class AttendanceDisplay
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tagesflagge (Nutzer-Feedback) ──────────────────────────────────────────────
|
||||
|
||||
public static class DayHighlightDisplay
|
||||
{
|
||||
public static string Symbol(DayHighlightKind? kind) => kind switch
|
||||
{
|
||||
DayHighlightKind.Standout => "👑",
|
||||
DayHighlightKind.Sleepy => "😴",
|
||||
DayHighlightKind.Rough => "⚡",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
public static string Label(DayHighlightKind? kind) => kind switch
|
||||
{
|
||||
DayHighlightKind.Standout => "Spitzentag",
|
||||
DayHighlightKind.Sleepy => "Schlaftag",
|
||||
DayHighlightKind.Rough => "Schlechter Tag",
|
||||
_ => "Keine Markierung",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Eine Bewertungszelle ──────────────────────────────────────────────────────
|
||||
|
||||
public partial class RatingCell : ObservableObject
|
||||
@@ -800,12 +840,31 @@ public partial class QuickInputViewModel : ObservableObject
|
||||
[ObservableProperty] private string _progressText = "";
|
||||
[ObservableProperty] private bool _currentStudentIsAbsent;
|
||||
[ObservableProperty] private string _currentStudentAttendanceLabel = "";
|
||||
[ObservableProperty] private DayHighlightKind? _currentStudentDayHighlight;
|
||||
|
||||
/// Dimmt Name/Aspektliste, wenn der aktuelle Schüler abwesend ist — kein Blockieren der
|
||||
/// Eingabe (manche Bewertungssysteme wollen trotzdem einen Eintrag, z.B. "0 Punkte"), nur ein
|
||||
/// visueller Hinweis, dass eine Bewertung hier normalerweise keinen Sinn ergibt.
|
||||
public double CurrentStudentContentOpacity => CurrentStudentIsAbsent ? 0.4 : 1.0;
|
||||
|
||||
/// Tagesflagge (Nutzer-Feedback): Fallback für Kurse ohne Sitzplan — im Sitzplatz-Dialog gibt
|
||||
/// es dieselbe Auswahl bereits über SeatAssessmentViewModel.DayHighlightChoices.
|
||||
public string CurrentStudentDayHighlightSymbol => DayHighlightDisplay.Symbol(CurrentStudentDayHighlight);
|
||||
public string CurrentStudentDayHighlightLabel => DayHighlightDisplay.Label(CurrentStudentDayHighlight);
|
||||
|
||||
public void SetDayHighlight(DayHighlightKind? value)
|
||||
{
|
||||
if (_rows.Count == 0) return;
|
||||
_rows[StudentIndex].SetDayHighlight(value);
|
||||
CurrentStudentDayHighlight = value;
|
||||
}
|
||||
|
||||
partial void OnCurrentStudentDayHighlightChanged(DayHighlightKind? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CurrentStudentDayHighlightSymbol));
|
||||
OnPropertyChanged(nameof(CurrentStudentDayHighlightLabel));
|
||||
}
|
||||
|
||||
public ObservableCollection<QuickAspectRow> AspectRows { get; } = [];
|
||||
|
||||
/// Wechselt je nach Typ des aktuell gewählten Aspekts (3.1.3) — Scale3/Binary haben andere
|
||||
@@ -824,7 +883,8 @@ public partial class QuickInputViewModel : ObservableObject
|
||||
_ => "1–5 bewerten",
|
||||
};
|
||||
return $"{ratingHint} · Q/W/E/R/T Aspekt wählen · Leertaste/↓ nächster Aspekt · ↑ vorheriger Aspekt · " +
|
||||
"Enter/→ nächster Schüler · Backspace/← vorheriger Schüler · +/− anpassen · Entf nicht bewertet · Esc schließen";
|
||||
"Enter/→ nächster Schüler · Backspace/← vorheriger Schüler · +/− anpassen · Entf nicht bewertet · " +
|
||||
"⇧1/2/3 Tagesflagge, ⇧0 löschen · Esc schließen";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,6 +912,7 @@ public partial class QuickInputViewModel : ObservableObject
|
||||
ProgressText = $"{index + 1} / {_rows.Count}";
|
||||
CurrentStudentIsAbsent = row.IsAbsent;
|
||||
CurrentStudentAttendanceLabel = row.AttendanceTooltip;
|
||||
CurrentStudentDayHighlight = row.DayHighlight;
|
||||
|
||||
AspectRows.Clear();
|
||||
foreach (var (a, i) in _aspects.Select((a, i) => (a, i)))
|
||||
|
||||
@@ -20,6 +20,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _results;
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly IUnitRepository _units;
|
||||
private readonly Dictionary<Guid, GroupMembership> _membershipsByStudent;
|
||||
private readonly GradingService _grading;
|
||||
private readonly Guid _groupId;
|
||||
@@ -75,11 +76,11 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
||||
IParticipationAspectRepository aspects, IParticipationSectionRepository sectionRepo,
|
||||
IStudentRepository students, IGroupMembershipRepository memberships,
|
||||
IExamRepository exams, IExamResultRepository results,
|
||||
IGradeRepository grades, GradingService grading,
|
||||
IGradeRepository grades, IUnitRepository units, GradingService grading,
|
||||
Guid groupId, string schoolYear, GradingSystem gradingSystem, string groupLabel)
|
||||
{
|
||||
_sessions = sessions; _entries = entries; _sectionRepo = sectionRepo;
|
||||
_exams = exams; _results = results; _grades = grades; _grading = grading;
|
||||
_exams = exams; _results = results; _grades = grades; _units = units; _grading = grading;
|
||||
_groupId = groupId; _schoolYear = schoolYear; _gradingSystem = gradingSystem;
|
||||
GroupLabel = groupLabel;
|
||||
_membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId);
|
||||
@@ -98,6 +99,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
||||
|
||||
_rollupPeriod = RollupPeriodOptions[0];
|
||||
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
|
||||
NewSectionEndDateText = SuggestSectionEndDate().ToString("dd.MM.yyyy");
|
||||
|
||||
if (_students.Count > 0) ShowStudent(0);
|
||||
}
|
||||
@@ -277,6 +279,23 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
||||
_sectionList.Count > 0 ? _sectionList.Max(s => s.EndDate).AddDays(1)
|
||||
: (_allSessions.Count > 0 ? _allSessions.Min(s => s.Date) : DateOnly.FromDateTime(DateTime.Today));
|
||||
|
||||
/// Schlägt als Abschnittsende bewusst nicht immer "heute" vor: Wenn seit Abschnittsbeginn
|
||||
/// eine Unterrichtseinheit (4.2) abgeschlossen wurde, ist deren Ende meist die fachlich
|
||||
/// sinnvollere Zäsur für eine Mitarbeit-Teilnote als das Bearbeitungsdatum im Wizard.
|
||||
private DateOnly SuggestSectionEndDate()
|
||||
{
|
||||
var openStart = ComputeOpenStart();
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var completedUnitEnd = _units.GetByGroup(_groupId)
|
||||
.Where(u => u.Status == UnitStatus.Completed && u.EndDate.HasValue)
|
||||
.Select(u => u.EndDate!.Value)
|
||||
.Where(d => d >= openStart && d <= today)
|
||||
.OrderByDescending(d => d)
|
||||
.Cast<DateOnly?>()
|
||||
.FirstOrDefault();
|
||||
return completedUnitEnd ?? today;
|
||||
}
|
||||
|
||||
private void BuildSections(Guid studentId)
|
||||
{
|
||||
Sections.Clear();
|
||||
@@ -378,6 +397,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
||||
}
|
||||
|
||||
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
|
||||
NewSectionEndDateText = SuggestSectionEndDate().ToString("dd.MM.yyyy");
|
||||
BuildTimeline(CurrentStudentId);
|
||||
BuildSections(CurrentStudentId);
|
||||
}
|
||||
|
||||
@@ -547,11 +547,13 @@ public partial class SeatCellViewModel : ObservableObject
|
||||
[ObservableProperty] private double _lessonOpacity = 1.0;
|
||||
[ObservableProperty] private string _attendanceBadge = "";
|
||||
[ObservableProperty] private string _homeworkBadge = "";
|
||||
[ObservableProperty] private string _dayHighlightBadge = "";
|
||||
[ObservableProperty] private int _raisedHandCount;
|
||||
[ObservableProperty] private int _calledOnCount;
|
||||
public ObservableCollection<SituationTagChoice> SituationTags { get; } = [];
|
||||
public bool HasAttendanceBadge => AttendanceBadge.Length > 0;
|
||||
public bool HasHomeworkBadge => HomeworkBadge.Length > 0;
|
||||
public bool HasDayHighlightBadge => DayHighlightBadge.Length > 0;
|
||||
public bool ShowLessonOverview => IsOccupied && !CanEdit;
|
||||
[ObservableProperty] private bool _canRecordLesson;
|
||||
public bool IsOccupied => SelectedOption.StudentId.HasValue;
|
||||
@@ -644,6 +646,7 @@ public partial class SeatCellViewModel : ObservableObject
|
||||
var attendance = entry?.Attendance;
|
||||
AttendanceBadge = attendance is null ? "" : AttendanceDisplay.ShortLabel(attendance);
|
||||
HomeworkBadge = entry is null ? "" : HomeworkDisplay.Symbol(HomeworkDisplay.Effective(entry));
|
||||
DayHighlightBadge = DayHighlightDisplay.Symbol(entry?.DayHighlight);
|
||||
RaisedHandCount = entry?.RaisedHandCount ?? 0;
|
||||
CalledOnCount = entry?.CalledOnCount ?? 0;
|
||||
LessonOpacity = attendance is not null and not AttendanceStatus.Present
|
||||
@@ -652,6 +655,7 @@ public partial class SeatCellViewModel : ObservableObject
|
||||
foreach (var choice in SituationTags) choice.IsSelected = selected.Contains(choice.Text);
|
||||
OnPropertyChanged(nameof(HasAttendanceBadge));
|
||||
OnPropertyChanged(nameof(HasHomeworkBadge));
|
||||
OnPropertyChanged(nameof(HasDayHighlightBadge));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,6 +681,7 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
||||
[ObservableProperty] private int _selectedAspectIndex;
|
||||
[ObservableProperty] private string _attendanceLabel = "Noch nicht kontrolliert";
|
||||
[ObservableProperty] private string _homeworkLabel = "Keine Hausaufgabe aufgegeben";
|
||||
[ObservableProperty] private string _dayHighlightLabel = "Keine Markierung";
|
||||
|
||||
public string StudentName { get; }
|
||||
public string SessionDisplay { get; }
|
||||
@@ -687,6 +692,7 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
||||
public ObservableCollection<SeatAssessmentAspectRow> AspectRows { get; } = [];
|
||||
public ObservableCollection<SeatAttendanceChoice> AttendanceChoices { get; } = [];
|
||||
public ObservableCollection<SeatHomeworkChoice> HomeworkChoices { get; } = [];
|
||||
public ObservableCollection<SeatDayHighlightChoice> DayHighlightChoices { get; } = [];
|
||||
|
||||
public SeatAssessmentViewModel(IParticipationSessionRepository sessions,
|
||||
IParticipationRepository entries, IParticipationAspectRepository aspects,
|
||||
@@ -729,6 +735,7 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
||||
|
||||
BuildAttendanceChoices();
|
||||
BuildHomeworkChoices();
|
||||
BuildDayHighlightChoices();
|
||||
RefreshStatusChoices();
|
||||
SelectAspect(0);
|
||||
}
|
||||
@@ -761,6 +768,17 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
||||
HomeworkChoices.Add(new("·", "Keine aufgegeben", "⌥X", null, SetHomework));
|
||||
}
|
||||
|
||||
private void BuildDayHighlightChoices()
|
||||
{
|
||||
DayHighlightChoices.Add(new(DayHighlightDisplay.Symbol(DayHighlightKind.Standout),
|
||||
DayHighlightDisplay.Label(DayHighlightKind.Standout), "⇧1", DayHighlightKind.Standout, SetDayHighlight));
|
||||
DayHighlightChoices.Add(new(DayHighlightDisplay.Symbol(DayHighlightKind.Sleepy),
|
||||
DayHighlightDisplay.Label(DayHighlightKind.Sleepy), "⇧2", DayHighlightKind.Sleepy, SetDayHighlight));
|
||||
DayHighlightChoices.Add(new(DayHighlightDisplay.Symbol(DayHighlightKind.Rough),
|
||||
DayHighlightDisplay.Label(DayHighlightKind.Rough), "⇧3", DayHighlightKind.Rough, SetDayHighlight));
|
||||
DayHighlightChoices.Add(new("·", "Keine Markierung", "⇧X", null, SetDayHighlight));
|
||||
}
|
||||
|
||||
public void SelectAspect(int index)
|
||||
{
|
||||
if (index < 0 || index >= AspectRows.Count) return;
|
||||
@@ -827,6 +845,17 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
||||
if (clear || digit is 0 or 1 or 3 or 4 or 5 or 7 or 8) SetHomework(status);
|
||||
}
|
||||
|
||||
public void ApplyDayHighlightShortcut(int? digit, bool clear)
|
||||
{
|
||||
if (!CanEdit) return;
|
||||
var kind = clear ? null : digit switch
|
||||
{
|
||||
1 => DayHighlightKind.Standout, 2 => DayHighlightKind.Sleepy, 3 => DayHighlightKind.Rough,
|
||||
_ => (DayHighlightKind?)null,
|
||||
};
|
||||
if (clear || digit is 1 or 2 or 3) SetDayHighlight(kind);
|
||||
}
|
||||
|
||||
private void ApplyRating(string key, int? value)
|
||||
{
|
||||
if (!CanEdit || _entry is null) return;
|
||||
@@ -857,13 +886,23 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
||||
RefreshStatusChoices();
|
||||
}
|
||||
|
||||
private void SetDayHighlight(DayHighlightKind? kind)
|
||||
{
|
||||
if (!CanEdit || _entry is null) return;
|
||||
_entry.DayHighlight = kind;
|
||||
_entries.Save(_entry);
|
||||
RefreshStatusChoices();
|
||||
}
|
||||
|
||||
private void RefreshStatusChoices()
|
||||
{
|
||||
AttendanceLabel = AttendanceDisplay.Label(_entry?.Attendance);
|
||||
HomeworkLabel = HomeworkDisplay.Label(_entry is null ? null : HomeworkDisplay.Effective(_entry));
|
||||
DayHighlightLabel = DayHighlightDisplay.Label(_entry?.DayHighlight);
|
||||
foreach (var choice in AttendanceChoices) choice.IsSelected = choice.Status == _entry?.Attendance;
|
||||
var homework = _entry is null ? null : HomeworkDisplay.Effective(_entry);
|
||||
foreach (var choice in HomeworkChoices) choice.IsSelected = choice.Status == homework;
|
||||
foreach (var choice in DayHighlightChoices) choice.IsSelected = choice.Kind == _entry?.DayHighlight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -966,6 +1005,17 @@ public partial class SeatHomeworkChoice(string symbol, string label, string shor
|
||||
[RelayCommand] private void Apply() => apply(Status);
|
||||
}
|
||||
|
||||
public partial class SeatDayHighlightChoice(string symbol, string label, string shortcut,
|
||||
DayHighlightKind? kind, Action<DayHighlightKind?> apply) : ObservableObject
|
||||
{
|
||||
public string Symbol { get; } = symbol;
|
||||
public string Label { get; } = label;
|
||||
public string Shortcut { get; } = shortcut;
|
||||
public DayHighlightKind? Kind { get; } = kind;
|
||||
[ObservableProperty] private bool _isSelected;
|
||||
[RelayCommand] private void Apply() => apply(Kind);
|
||||
}
|
||||
|
||||
public partial class SeatingPlanDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly ISeatingPlanRepository _plans;
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
// ── Schüler-Leistungsüberblick + Zielnoten-Rechner (Nutzer-Feedback) ─────────────
|
||||
//
|
||||
// Zeigt die Leistungen eines einzelnen Schülers in einem Kurs im Überblick — Klausuren,
|
||||
// Mitarbeit- und sonstige Noten, die daraus berechnete Zeugnisnote sowie zwei Rechner
|
||||
// ("Zielnote": was brauche ich noch? / "Was-wäre-wenn": wie wirkt sich eine zusätzliche
|
||||
// Klausurnote aus?). Bewusst pro Kurs statt fächerübergreifend, da Gewichtungsschema und
|
||||
// Notensystem am Kurs hängen. Zwei Modi über IsTeacherMode: im Bewertermodus zusätzlich
|
||||
// Kursdurchschnitt je Klausur und Bearbeitbarkeit der Mitarbeit-/Sonstige-Noten; im
|
||||
// Schülermodus ausschließlich die eigenen Werte, rein lesend — gedacht, um den Bildschirm im
|
||||
// Gespräch umzudrehen. Exam-Noten bleiben bewusst nur lesbar: eine Korrektur läuft weiterhin
|
||||
// über den bestehenden ExamGradingDialog, der die Punkte-Struktur konsistent hält — direktes
|
||||
// Überschreiben von Exam.Grade hier könnte mit ExamResult.Points/TotalPoints auseinanderlaufen.
|
||||
public partial class StudentPerformanceOverviewViewModel : ObservableObject
|
||||
{
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _results;
|
||||
private readonly IGroupMembershipRepository _memberships;
|
||||
private readonly IGradingSchemeRepository _schemes;
|
||||
private readonly GradingService _grading;
|
||||
|
||||
private readonly Guid _groupId;
|
||||
private readonly Guid _studentId;
|
||||
private readonly GroupType _groupType;
|
||||
private readonly GradingSystem _gradingSystem;
|
||||
private readonly string _schoolYear;
|
||||
|
||||
private GradingScheme _scheme = new();
|
||||
private double? _examsAverage;
|
||||
private double? _participationAverage;
|
||||
private double? _otherAverage;
|
||||
private List<(string Grade, double Weight)> _examGradesRaw = [];
|
||||
private List<(string Grade, double Weight)> _participationGradesRaw = [];
|
||||
private List<(string Grade, double Weight)> _otherGradesRaw = [];
|
||||
|
||||
public string StudentName { get; }
|
||||
public string GroupLabel { get; }
|
||||
|
||||
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
|
||||
[ObservableProperty] private bool _isTeacherMode = true;
|
||||
[ObservableProperty] private string _schemeSummary = "";
|
||||
[ObservableProperty] private string? _examsAverageDisplay;
|
||||
[ObservableProperty] private string? _participationAverageDisplay;
|
||||
[ObservableProperty] private string? _otherAverageDisplay;
|
||||
[ObservableProperty] private string _currentReportGradeDisplay = "";
|
||||
|
||||
[ObservableProperty] private string _targetGradeText = "";
|
||||
[ObservableProperty] private GradeBucketKind _targetBucket = GradeBucketKind.Exams;
|
||||
[ObservableProperty] private string _targetResultDisplay = "";
|
||||
|
||||
[ObservableProperty] private string _whatIfExamGradeText = "";
|
||||
[ObservableProperty] private string _whatIfResultDisplay = "";
|
||||
|
||||
public List<ParticipationPeriodOption> PeriodOptions { get; } =
|
||||
[
|
||||
new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"),
|
||||
new(ParticipationPeriod.H1, "1. Halbjahr"),
|
||||
new(ParticipationPeriod.H2, "2. Halbjahr"),
|
||||
];
|
||||
|
||||
public string[] TargetBucketOptions { get; } = ["Klausuren", "Mitarbeit", "Sonstige"];
|
||||
|
||||
public string TargetBucketName
|
||||
{
|
||||
get => TargetBucket switch
|
||||
{
|
||||
GradeBucketKind.Participation => "Mitarbeit",
|
||||
GradeBucketKind.Other => "Sonstige",
|
||||
_ => "Klausuren",
|
||||
};
|
||||
set => TargetBucket = value switch
|
||||
{
|
||||
"Mitarbeit" => GradeBucketKind.Participation,
|
||||
"Sonstige" => GradeBucketKind.Other,
|
||||
_ => GradeBucketKind.Exams,
|
||||
};
|
||||
}
|
||||
|
||||
public ObservableCollection<StudentExamRow> ExamRows { get; } = [];
|
||||
/// Eigener Verlauf (Nutzer-Feedback): dieselbe Balken-Sparkline wie die bestehende
|
||||
/// Notenentwicklung im Schülerdetail (2.5), hier nur auf die Klausuren dieses Kurses
|
||||
/// beschränkt statt aller Lerngruppen — passt zum Kursdurchschnitt-Vergleich je Klausur.
|
||||
public StudentGradeHistoryGroup ExamHistory { get; } = new("Klausurverlauf");
|
||||
public ObservableCollection<StudentSimpleGradeRow> ParticipationRows { get; } = [];
|
||||
public ObservableCollection<StudentSimpleGradeRow> OtherRows { get; } = [];
|
||||
|
||||
public StudentPerformanceOverviewViewModel(IGradeRepository grades, IExamRepository exams,
|
||||
IExamResultRepository results, IGroupMembershipRepository memberships,
|
||||
IGradingSchemeRepository schemes, GradingService grading,
|
||||
Guid groupId, Guid studentId, GroupType groupType, GradingSystem gradingSystem,
|
||||
string studentName, string groupLabel, string schoolYear)
|
||||
{
|
||||
_grades = grades; _exams = exams; _results = results; _memberships = memberships;
|
||||
_schemes = schemes; _grading = grading;
|
||||
_groupId = groupId; _studentId = studentId; _groupType = groupType;
|
||||
_gradingSystem = gradingSystem; _schoolYear = schoolYear;
|
||||
StudentName = studentName; GroupLabel = groupLabel;
|
||||
|
||||
_selectedPeriod = PeriodOptions[0];
|
||||
Recompute();
|
||||
}
|
||||
|
||||
partial void OnSelectedPeriodChanged(ParticipationPeriodOption value) => Recompute();
|
||||
|
||||
public string ModeButtonLabel => IsTeacherMode ? "Modus: Bewerter" : "Modus: Schüler";
|
||||
|
||||
partial void OnIsTeacherModeChanged(bool value) => OnPropertyChanged(nameof(ModeButtonLabel));
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleMode() => IsTeacherMode = !IsTeacherMode;
|
||||
|
||||
private GradingScheme ResolveScheme() =>
|
||||
_schemes.GetByGroup(_groupId)
|
||||
?? _schemes.GetDefaultForType(_groupType)
|
||||
?? new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
|
||||
|
||||
private void Recompute()
|
||||
{
|
||||
_scheme = ResolveScheme();
|
||||
SchemeSummary = $"Klausuren {_scheme.ExamsPercent:0.#} % · Mitarbeit {_scheme.ParticipationPercent:0.#} % · " +
|
||||
$"Sonstige {_scheme.OtherPercent:0.#} %";
|
||||
|
||||
var (periodFrom, periodTo) = GroupMembershipService.SchoolYearPeriod(_schoolYear, SelectedPeriod.Period switch
|
||||
{
|
||||
ParticipationPeriod.H1 => SchoolYearPeriodKind.H1,
|
||||
ParticipationPeriod.H2 => SchoolYearPeriodKind.H2,
|
||||
_ => SchoolYearPeriodKind.FullYear,
|
||||
});
|
||||
var membership = _memberships.GetByGroup(_groupId).FirstOrDefault(m => m.StudentId == _studentId);
|
||||
|
||||
// ── Klausuren ─────────────────────────────────────────────────────
|
||||
ExamRows.Clear();
|
||||
ExamHistory.Points.Clear();
|
||||
_examGradesRaw = [];
|
||||
int? previousNoteEquivalent = null;
|
||||
foreach (var exam in _exams.GetByGroup(_groupId)
|
||||
.Where(e => e.Date >= periodFrom && e.Date <= periodTo)
|
||||
.Where(e => membership is null || GroupMembershipService.IsActiveOn(membership, e.Date))
|
||||
.Where(e => !e.Niveau.HasValue || membership?.Niveau == e.Niveau)
|
||||
.OrderBy(e => e.Date))
|
||||
{
|
||||
var allResults = _results.GetByExam(exam.Id);
|
||||
var own = allResults.FirstOrDefault(r => r.StudentId == _studentId);
|
||||
string? ownGrade = own is { Absent: false, Grade: not null } ? own.Grade : null;
|
||||
if (ownGrade is not null) _examGradesRaw.Add((ownGrade, 1.0));
|
||||
|
||||
var classGrades = allResults.Where(r => !r.Absent && r.Grade is not null)
|
||||
.Select(r => (r.Grade!, 1.0)).ToList();
|
||||
var classAverage = classGrades.Count > 0 ? _grading.WeightedAverage(classGrades) : (double?)null;
|
||||
|
||||
ExamRows.Add(new StudentExamRow(exam.Title, exam.Date, ownGrade,
|
||||
classAverage.HasValue ? FormatGrade(classAverage.Value) : "–"));
|
||||
|
||||
// Gleiche Balken-Sparkline wie die bestehende Notenentwicklung (2.5): Rohwert auf die
|
||||
// 1..6-Notenäquivalent-Achse abbilden, damit Grades1To6 und Points0To15 dieselbe
|
||||
// Balkenhöhen-Formel nutzen können.
|
||||
int? noteEquivalent = ownGrade is not null && int.TryParse(ownGrade, out var raw)
|
||||
? (_gradingSystem == GradingSystem.Grades1To6 ? raw : int.Parse(PointsNoteMapping.PointsToNote(raw)))
|
||||
: null;
|
||||
var isFailing = noteEquivalent is >= 5;
|
||||
var isDrop = noteEquivalent.HasValue && previousNoteEquivalent.HasValue
|
||||
&& noteEquivalent.Value - previousNoteEquivalent.Value >= 1;
|
||||
var warnings = new List<string>();
|
||||
if (isDrop) warnings.Add("Abfall um ≥ 1 Note");
|
||||
if (isFailing) warnings.Add("Versetzungsgefährdung");
|
||||
ExamHistory.Points.Add(new GradeHistoryPoint(exam.Date, exam.Title, ownGrade ?? "–",
|
||||
noteEquivalent, warnings.Count > 0, string.Join(" · ", warnings)));
|
||||
if (noteEquivalent.HasValue) previousNoteEquivalent = noteEquivalent;
|
||||
}
|
||||
_examsAverage = _examGradesRaw.Count > 0 ? _grading.WeightedAverage(_examGradesRaw) : null;
|
||||
|
||||
// ── Mitarbeit & Sonstige ──────────────────────────────────────────
|
||||
var allGrades = _grades.GetByStudentAndGroup(_studentId, _groupId)
|
||||
.Where(g => g.Date >= periodFrom && g.Date <= periodTo)
|
||||
.Where(g => membership is null || GroupMembershipService.IsActiveOn(membership, g.Date))
|
||||
.OrderBy(g => g.Date).ToList();
|
||||
|
||||
ParticipationRows.Clear();
|
||||
_participationGradesRaw = [];
|
||||
foreach (var g in allGrades.Where(g => g.Category == GradeCategory.Participation))
|
||||
{
|
||||
_participationGradesRaw.Add((g.Value, g.Weight));
|
||||
ParticipationRows.Add(new StudentSimpleGradeRow(g, SaveGrade));
|
||||
}
|
||||
_participationAverage = _participationGradesRaw.Count > 0 ? _grading.WeightedAverage(_participationGradesRaw) : null;
|
||||
|
||||
OtherRows.Clear();
|
||||
_otherGradesRaw = [];
|
||||
foreach (var g in allGrades.Where(g => g.Category != GradeCategory.Participation))
|
||||
{
|
||||
_otherGradesRaw.Add((g.Value, g.Weight));
|
||||
OtherRows.Add(new StudentSimpleGradeRow(g, SaveGrade));
|
||||
}
|
||||
_otherAverage = _otherGradesRaw.Count > 0 ? _grading.WeightedAverage(_otherGradesRaw) : null;
|
||||
|
||||
ExamsAverageDisplay = _examsAverage.HasValue ? FormatGrade(_examsAverage.Value) : null;
|
||||
ParticipationAverageDisplay = _participationAverage.HasValue ? FormatGrade(_participationAverage.Value) : null;
|
||||
OtherAverageDisplay = _otherAverage.HasValue ? FormatGrade(_otherAverage.Value) : null;
|
||||
|
||||
var reportGrade = _grading.CalculateReportGrade(_examGradesRaw, _participationGradesRaw, _otherGradesRaw,
|
||||
_scheme, _gradingSystem, RoundingRule.Commercial);
|
||||
CurrentReportGradeDisplay = reportGrade is null
|
||||
? "Noch nicht berechenbar — keine Werte im gewählten Zeitraum."
|
||||
: $"Note {reportGrade}";
|
||||
|
||||
RecomputeTarget();
|
||||
RecomputeWhatIf();
|
||||
}
|
||||
|
||||
private void SaveGrade(Grade grade)
|
||||
{
|
||||
_grades.Save(grade);
|
||||
Recompute();
|
||||
}
|
||||
|
||||
partial void OnTargetGradeTextChanged(string value) => RecomputeTarget();
|
||||
partial void OnTargetBucketChanged(GradeBucketKind value)
|
||||
{
|
||||
OnPropertyChanged(nameof(TargetBucketName));
|
||||
RecomputeTarget();
|
||||
}
|
||||
|
||||
private void RecomputeTarget()
|
||||
{
|
||||
if (!TryParseGrade(TargetGradeText, out var target))
|
||||
{
|
||||
TargetResultDisplay = "";
|
||||
return;
|
||||
}
|
||||
|
||||
var result = ReportGradeTargetCalculator.SolveRequiredAverage(target, TargetBucket,
|
||||
_examsAverage, _participationAverage, _otherAverage, _scheme, _gradingSystem);
|
||||
|
||||
TargetResultDisplay = result switch
|
||||
{
|
||||
null => $"{TargetBucketName} fließt laut Gewichtungsschema nicht in die Zeugnisnote ein.",
|
||||
{ IsAchievable: true } r => $"Nötiger Schnitt in {TargetBucketName}: {FormatGrade(r.RequiredAverage)}",
|
||||
{ IsAchievable: false } r =>
|
||||
$"Mit den übrigen Bereichen rechnerisch nicht mehr erreichbar (bräuchte {FormatGrade(r.RequiredAverage)}).",
|
||||
};
|
||||
}
|
||||
|
||||
partial void OnWhatIfExamGradeTextChanged(string value) => RecomputeWhatIf();
|
||||
|
||||
private void RecomputeWhatIf()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(WhatIfExamGradeText))
|
||||
{
|
||||
WhatIfResultDisplay = "";
|
||||
return;
|
||||
}
|
||||
|
||||
var hypothetical = new List<(string Grade, double Weight)>(_examGradesRaw) { (WhatIfExamGradeText.Trim(), 1.0) };
|
||||
var result = _grading.CalculateReportGrade(hypothetical, _participationGradesRaw, _otherGradesRaw,
|
||||
_scheme, _gradingSystem, RoundingRule.Commercial);
|
||||
WhatIfResultDisplay = result is null
|
||||
? "Ungültige Note."
|
||||
: $"Zeugnisnote mit dieser zusätzlichen Klausurnote: {result}";
|
||||
}
|
||||
|
||||
private static bool TryParseGrade(string text, out double value) =>
|
||||
double.TryParse(text.Trim().Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
|
||||
|
||||
private static string FormatGrade(double value) => value.ToString("0.0", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public class StudentExamRow(string title, DateOnly date, string? ownGrade, string classAverageDisplay)
|
||||
{
|
||||
public string Title { get; } = title;
|
||||
public string DateDisplay { get; } = date.ToString("dd.MM.yyyy");
|
||||
public string OwnGradeDisplay { get; } = ownGrade ?? "–";
|
||||
public string ClassAverageDisplay { get; } = classAverageDisplay;
|
||||
}
|
||||
|
||||
public partial class StudentSimpleGradeRow : ObservableObject
|
||||
{
|
||||
private readonly Grade _grade;
|
||||
private readonly Action<Grade> _save;
|
||||
|
||||
public string CategoryLabel { get; }
|
||||
public string DateDisplay { get; }
|
||||
[ObservableProperty] private string _value;
|
||||
|
||||
public StudentSimpleGradeRow(Grade grade, Action<Grade> save)
|
||||
{
|
||||
_grade = grade;
|
||||
_save = save;
|
||||
CategoryLabel = GradeCategoryDisplay.Label(grade.Category);
|
||||
DateDisplay = grade.Date.ToString("dd.MM.yyyy");
|
||||
_value = grade.Value;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
_grade.Value = Value.Trim();
|
||||
_save(_grade);
|
||||
}
|
||||
}
|
||||
@@ -570,9 +570,10 @@ public partial class TimetableViewModel : ObservableObject
|
||||
p.Material?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true)) == true;
|
||||
|
||||
/// Eine Doppelstunde wird als eine Lesson an der ersten Periode gespeichert. Für die zweite
|
||||
/// Rasterzelle liefern wir dieselbe Lesson nur dann, wenn der Stundenplan dort unmittelbar
|
||||
/// fortgesetzt wird und der geplante Verlauf länger als die erste Periode ist. Eine exakt an
|
||||
/// der Zielperiode verankerte Lesson hat immer Vorrang.
|
||||
/// Rasterzelle liefern wir dieselbe Lesson, wenn der Stundenplan dort unmittelbar mit derselben
|
||||
/// Lerngruppe fortgesetzt wird. Das darf nicht von bereits eingetragenen Phasendauern abhängen:
|
||||
/// Gerade ein früher Entwurf hat oft erst Datum und Thema, gehört aber trotzdem zum kompletten
|
||||
/// Doppelstundenblock. Eine exakt an der Zielperiode verankerte Lesson hat immer Vorrang.
|
||||
private Lesson? FindLessonForSlot(Guid groupId, DateOnly date, int period)
|
||||
{
|
||||
var lessons = _lessons.GetByGroupAndDate(groupId, date);
|
||||
@@ -584,14 +585,22 @@ public partial class TimetableViewModel : ObservableObject
|
||||
var withoutPeriod = lessons.Where(l => l.LessonNumber is null).ToList();
|
||||
if (withoutPeriod.Count == 1) return withoutPeriod[0];
|
||||
|
||||
var previous = lessons.Where(l => l.LessonNumber is int p && p == period - 1)
|
||||
var periodsInBlock = _slots.GetByGroup(groupId)
|
||||
.Where(s => s.Weekday == date.DayOfWeek)
|
||||
.Select(s => s.PeriodNumber)
|
||||
.ToHashSet();
|
||||
|
||||
// Vom angefragten Slot innerhalb desselben lückenlosen Blocks rückwärts zur verankerten
|
||||
// Lesson laufen. Damit funktionieren neben Doppelstunden auch seltene Dreifachblöcke.
|
||||
for (var candidatePeriod = period - 1;
|
||||
periodsInBlock.Contains(candidatePeriod) && periodsInBlock.Contains(candidatePeriod + 1);
|
||||
candidatePeriod--)
|
||||
{
|
||||
var blockLesson = lessons.Where(l => l.LessonNumber == candidatePeriod)
|
||||
.OrderByDescending(l => l.UpdatedAt).FirstOrDefault();
|
||||
if (previous?.LessonNumber is not int anchor) return null;
|
||||
var isConsecutiveSlot = _slots.GetByGroup(groupId)
|
||||
.Any(s => s.Weekday == date.DayOfWeek && s.PeriodNumber == period);
|
||||
var firstPeriodMinutes = _periodSchedule.GetDurationMinutes(anchor);
|
||||
return isConsecutiveSlot && firstPeriodMinutes > 0 &&
|
||||
previous.Phases.Sum(p => p.DurationMinutes) > firstPeriodMinutes ? previous : null;
|
||||
if (blockLesson is not null) return blockLesson;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>4.5.4: Hat die letzte vor <paramref name="date"/> liegende Lesson dieser Gruppe eine
|
||||
|
||||
@@ -1,37 +1,19 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Templating;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Word-Briefvorlagen (7.1.4 / 11.5) ───────────────────────────────────
|
||||
|
||||
[ObservableProperty] private string _letterTemplateStatus = "";
|
||||
public ObservableCollection<LetterTemplateListItem> LetterTemplateList { get; } = [];
|
||||
public IReadOnlyList<LetterPlaceholder> SupportedLetterPlaceholders =>
|
||||
LetterTemplateService.SupportedPlaceholders;
|
||||
|
||||
// ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
|
||||
|
||||
private void LoadLetterTemplates()
|
||||
{
|
||||
LetterTemplateList.Clear();
|
||||
foreach (var template in _letterTemplates.GetTemplates())
|
||||
LetterTemplateList.Add(new LetterTemplateListItem(template, _letterTemplates.Validate(template)));
|
||||
foreach (var template in _letterTemplates.GetTemplates()) LetterTemplateList.Add(CreateItem(template));
|
||||
}
|
||||
|
||||
public void ImportLetterTemplate(string path)
|
||||
@@ -40,75 +22,59 @@ public partial class SettingsViewModel
|
||||
try
|
||||
{
|
||||
var template = _letterTemplates.Import(path);
|
||||
var validation = _letterTemplates.Validate(template);
|
||||
LoadLetterTemplates();
|
||||
LetterTemplateStatus = validation.Issues.Count == 0
|
||||
? "Vorlage importiert und ohne Auffälligkeiten geprüft."
|
||||
: $"Vorlage importiert. Die Prüfung meldet {validation.Issues.Count} Hinweis(e).";
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
|
||||
{
|
||||
LetterTemplateStatus = $"Import fehlgeschlagen: {ex.Message}";
|
||||
LetterTemplateStatus = $"„{template.Name}“ wurde geprüft und lokal importiert.";
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
|
||||
{ LetterTemplateStatus = $"Import fehlgeschlagen: {ex.Message}"; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ValidateLetterTemplate(LetterTemplateListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
try
|
||||
{
|
||||
var refreshed = CreateItem(item.Model);
|
||||
var index = LetterTemplateList.IndexOf(item);
|
||||
var refreshed = new LetterTemplateListItem(item.Model, _letterTemplates.Validate(item.Model));
|
||||
if (index >= 0) LetterTemplateList[index] = refreshed;
|
||||
LetterTemplateStatus = refreshed.Validation.Issues.Count == 0
|
||||
? $"„{item.Name}“ ist ohne Auffälligkeiten."
|
||||
: $"„{item.Name}“: {refreshed.Validation.Issues.Count} Hinweis(e).";
|
||||
LetterTemplateStatus = $"„{item.Name}“ ist gültig (Schema {refreshed.SchemaVersion}).";
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
|
||||
{ LetterTemplateStatus = $"„{item.Name}“ ist ungültig: {ex.Message}"; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DeleteLetterTemplate(LetterTemplateListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_letterTemplates.Delete(item.Id);
|
||||
LetterTemplateList.Remove(item);
|
||||
LetterTemplateStatus = "Vorlage gelöscht.";
|
||||
_letterTemplates.Delete(item.Id); LetterTemplateList.Remove(item); LetterTemplateStatus = "Vorlage gelöscht.";
|
||||
}
|
||||
|
||||
private LetterTemplateListItem CreateItem(InstalledTemplate template)
|
||||
{
|
||||
var loaded = _letterTemplates.Load(template);
|
||||
return new(template, loaded.Manifest.SchemaVersion, loaded.Manifest.Placeholders.Count(x => !x.IsConstant),
|
||||
loaded.Manifest.Placeholders.Count(x => !x.IsConstant && x.Required));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LetterTemplateListItem
|
||||
public sealed class LetterTemplateListItem(InstalledTemplate model, int schemaVersion, int fieldCount, int requiredCount)
|
||||
{
|
||||
public LetterTemplateInfo Model { get; }
|
||||
public TemplateValidationResult Validation { get; }
|
||||
public Guid Id => Model.Id;
|
||||
public InstalledTemplate Model { get; } = model;
|
||||
public string Id => Model.Id;
|
||||
public string Name => Model.Name;
|
||||
public string OriginalFileName => Model.OriginalFileName;
|
||||
public bool HasIssues => Validation.Issues.Count > 0;
|
||||
public bool HasNoIssues => !HasIssues;
|
||||
public string ValidationSummary => HasNoIssues
|
||||
? $"{Validation.Tags.Count} Feld(er) · keine Auffälligkeiten"
|
||||
: $"{Validation.Tags.Count} Feld(er) · {Validation.Issues.Count} Hinweis(e)";
|
||||
public ObservableCollection<LetterTemplateIssueItem> Issues { get; }
|
||||
|
||||
public LetterTemplateListItem(LetterTemplateInfo model, TemplateValidationResult validation)
|
||||
{
|
||||
Model = model;
|
||||
Validation = validation;
|
||||
Issues = new(validation.Issues.Select(i => new LetterTemplateIssueItem(i)));
|
||||
}
|
||||
public string PackageFileName => Path.GetFileName(Model.PackagePath);
|
||||
public int SchemaVersion { get; } = schemaVersion;
|
||||
public bool HasIssues => false;
|
||||
public bool HasNoIssues => true;
|
||||
public string ValidationSummary => $"Schema {SchemaVersion} · {fieldCount} Felder ({requiredCount} Pflicht)";
|
||||
public ObservableCollection<LetterTemplateIssueItem> Issues { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class LetterTemplateIssueItem(TemplateValidationIssue issue)
|
||||
public sealed class LetterTemplateIssueItem(ValidationIssue issue)
|
||||
{
|
||||
public string Icon => issue.Severity switch
|
||||
{
|
||||
TemplateIssueSeverity.Error => "⛔",
|
||||
TemplateIssueSeverity.StrongWarning => "⚠",
|
||||
_ => "ⓘ",
|
||||
};
|
||||
public string Icon => issue.Severity == ValidationSeverity.Error ? "⛔" : "ⓘ";
|
||||
public string Message => issue.Message;
|
||||
public string Color => issue.Severity switch
|
||||
{
|
||||
TemplateIssueSeverity.Error => "#DC2626",
|
||||
TemplateIssueSeverity.StrongWarning => "#D97706",
|
||||
_ => "#6B7280",
|
||||
};
|
||||
public string Color => issue.Severity == ValidationSeverity.Error ? "#DC2626" : "#6B7280";
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
@@ -59,7 +60,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
private readonly IDocumentationRepository _documentation;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IShorthandCodeRepository _shorthandCodes;
|
||||
private readonly LetterTemplateService _letterTemplates;
|
||||
private readonly TemplateStore _letterTemplates;
|
||||
|
||||
[ObservableProperty] private int _activeTabIndex;
|
||||
|
||||
@@ -97,7 +98,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
IDocumentationRepository documentation, IStudentRepository students,
|
||||
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
||||
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
||||
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
|
||||
ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates,
|
||||
AiSettingsService aiSettings, AiPlanningService aiPlanning,
|
||||
WebUntisSettingsService untisSettings,
|
||||
AnnualPlanSettingsService annualPlanSettings,
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Templating;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
public partial class CreateLetterDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly Student _student;
|
||||
private readonly LetterTemplateService _templates;
|
||||
private readonly TemplateStore _templates;
|
||||
private readonly ITemplateRenderer _renderer;
|
||||
|
||||
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
|
||||
[ObservableProperty] private LetterContactChoice? _selectedContact;
|
||||
[ObservableProperty] private LetterGroupChoice? _selectedGroup;
|
||||
[ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _letterText = "";
|
||||
[ObservableProperty] private string _teacherName = "";
|
||||
[ObservableProperty] private string _generationError = "";
|
||||
[ObservableProperty] private bool _canGenerate;
|
||||
|
||||
@@ -28,155 +30,93 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
||||
public bool HasNoTemplates => Templates.Count == 0;
|
||||
public bool HasNoContacts => Contacts.Count == 0;
|
||||
public string SuggestedFileName => SanitizeFileName(
|
||||
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.docx");
|
||||
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf");
|
||||
|
||||
public CreateLetterDialogViewModel(Student student, LetterTemplateService templates,
|
||||
public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer,
|
||||
IGroupMembershipRepository memberships, IGroupRepository groups)
|
||||
{
|
||||
_student = student;
|
||||
_templates = templates;
|
||||
|
||||
foreach (var template in templates.GetTemplates())
|
||||
Templates.Add(new LetterTemplateChoice(template));
|
||||
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name))
|
||||
Contacts.Add(new LetterContactChoice(contact));
|
||||
_student = student; _templates = templates; _renderer = renderer;
|
||||
foreach (var template in templates.GetTemplates()) Templates.Add(new(template));
|
||||
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact));
|
||||
foreach (var membership in memberships.GetByStudent(student.Id))
|
||||
{
|
||||
var group = groups.GetById(membership.GroupId);
|
||||
if (group is not null) Groups.Add(new LetterGroupChoice(group));
|
||||
}
|
||||
|
||||
SelectedTemplate = Templates.FirstOrDefault();
|
||||
SelectedContact = Contacts.FirstOrDefault();
|
||||
SelectedGroup = Groups.FirstOrDefault();
|
||||
if (groups.GetById(membership.GroupId) is { } group) Groups.Add(new(group));
|
||||
SelectedTemplate = Templates.FirstOrDefault(); SelectedContact = Contacts.FirstOrDefault(); SelectedGroup = Groups.FirstOrDefault();
|
||||
RefreshValidation();
|
||||
}
|
||||
|
||||
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(SuggestedFileName));
|
||||
RefreshValidation();
|
||||
}
|
||||
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) { OnPropertyChanged(nameof(SuggestedFileName)); RefreshValidation(); }
|
||||
partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation();
|
||||
partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation();
|
||||
partial void OnLetterDateChanged(DateTimeOffset? value) => RefreshValidation();
|
||||
partial void OnLetterTextChanged(string value) => RefreshValidation();
|
||||
partial void OnTeacherNameChanged(string value) => RefreshValidation();
|
||||
|
||||
public bool Generate(string outputPath)
|
||||
{
|
||||
RefreshValidation();
|
||||
if (!CanGenerate || SelectedTemplate is null) return false;
|
||||
GenerationError = "";
|
||||
RefreshValidation(); if (!CanGenerate || SelectedTemplate is null) return false;
|
||||
try
|
||||
{
|
||||
_templates.Generate(_templates.GetTemplatePath(SelectedTemplate.Model), outputPath, BuildValues());
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
|
||||
{
|
||||
GenerationError = $"Der Brief konnte nicht erzeugt werden: {ex.Message}";
|
||||
return false;
|
||||
var pdf = _renderer.RenderToPdf(_templates.Load(SelectedTemplate.Model), new LetterDataProvider(BuildValues()));
|
||||
var directory = Path.GetDirectoryName(outputPath); if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
File.WriteAllBytes(outputPath, pdf); GenerationError = ""; return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
|
||||
{ GenerationError = $"Der Brief konnte nicht erzeugt werden: {ex.Message}"; return false; }
|
||||
}
|
||||
|
||||
private void RefreshValidation()
|
||||
{
|
||||
Issues.Clear();
|
||||
GenerationError = "";
|
||||
|
||||
if (SelectedTemplate is null)
|
||||
Issues.Add(new("Bitte zuerst in den Einstellungen eine DOCX-Briefvorlage importieren.", true));
|
||||
if (SelectedContact is null)
|
||||
Issues.Add(new("Für den Schüler ist kein aktueller Kontakt ausgewählt.", true));
|
||||
if (LetterDate is null)
|
||||
Issues.Add(new("Bitte ein Briefdatum auswählen.", true));
|
||||
|
||||
Issues.Clear(); GenerationError = "";
|
||||
if (SelectedTemplate is null) Issues.Add(new("Bitte zuerst in den Einstellungen ein .lavorlage-Paket importieren.", true));
|
||||
if (SelectedContact is null) Issues.Add(new("Für den Schüler ist kein aktueller Kontakt ausgewählt.", true));
|
||||
if (LetterDate is null) Issues.Add(new("Bitte ein Briefdatum auswählen.", true));
|
||||
if (SelectedTemplate is not null)
|
||||
{
|
||||
var validation = _templates.Validate(SelectedTemplate.Model);
|
||||
foreach (var issue in validation.Issues)
|
||||
Issues.Add(new(issue.Message, issue.Severity is TemplateIssueSeverity.StrongWarning or TemplateIssueSeverity.Error));
|
||||
|
||||
var tags = validation.Tags.ToHashSet(StringComparer.Ordinal);
|
||||
var values = BuildValues();
|
||||
foreach (var tag in tags.Where(t => values.TryGetValue(t, out var value) && string.IsNullOrWhiteSpace(value)))
|
||||
try
|
||||
{
|
||||
var message = tag switch
|
||||
{
|
||||
"Letter.Salutation" => "Beim ausgewählten Kontakt fehlt die Briefanrede.",
|
||||
"Contact.Address" or "Contact.Street" or "Contact.PostalCode" or "Contact.City" =>
|
||||
$"Beim ausgewählten Kontakt fehlt der Wert für „{tag}“.",
|
||||
"Group.Name" or "SchoolYear" => "Die Vorlage verwendet Gruppendaten; bitte eine Lerngruppe auswählen.",
|
||||
_ => $"Für das Vorlagenfeld „{tag}“ ist kein Wert vorhanden.",
|
||||
};
|
||||
Issues.Add(new(message, true));
|
||||
var loaded = _templates.Load(SelectedTemplate.Model); var values = BuildValues();
|
||||
var validation = new TemplateLoader().Validate(loaded, values.ToDictionary(x => x.Key, x => x.Value.Type));
|
||||
foreach (var issue in validation.Issues) Issues.Add(new(issue.Message, issue.Severity == ValidationSeverity.Error));
|
||||
foreach (var required in loaded.Manifest.Placeholders.Where(x => !x.IsConstant && x.Required
|
||||
&& values.TryGetValue(x.Name, out var value) && IsEmpty(value)))
|
||||
Issues.Add(new($"Für das Pflichtfeld „{required.Name}“ ist kein Wert vorhanden.", true));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
|
||||
{ Issues.Add(new($"Vorlage ist ungültig: {ex.Message}", true)); }
|
||||
}
|
||||
|
||||
CanGenerate = SelectedTemplate is not null && SelectedContact is not null && LetterDate is not null
|
||||
&& Issues.Count == 0;
|
||||
CanGenerate = SelectedTemplate is not null && SelectedContact is not null && LetterDate is not null && Issues.Count == 0;
|
||||
OnPropertyChanged(nameof(HasIssues));
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, string?> BuildValues()
|
||||
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues()
|
||||
{
|
||||
var contact = SelectedContact?.Model;
|
||||
var group = SelectedGroup?.Model;
|
||||
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v)));
|
||||
var address = string.Join(Environment.NewLine, new[] { contact?.Street, cityLine }
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v)));
|
||||
var date = LetterDate is null ? null : DateOnly.FromDateTime(LetterDate.Value.LocalDateTime)
|
||||
.ToString("dd.MM.yyyy", CultureInfo.GetCultureInfo("de-DE"));
|
||||
|
||||
return new Dictionary<string, string?>
|
||||
var contact = SelectedContact?.Model; var group = SelectedGroup?.Model;
|
||||
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
var date = DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
|
||||
{
|
||||
["Student.FirstName"] = _student.FirstName,
|
||||
["Student.LastName"] = _student.LastName,
|
||||
["Contact.Name"] = contact?.Name,
|
||||
["Contact.Address"] = address,
|
||||
["Contact.Street"] = contact?.Street,
|
||||
["Contact.PostalCode"] = contact?.PostalCode,
|
||||
["Contact.City"] = contact?.City,
|
||||
["Letter.Salutation"] = contact?.LetterSalutation,
|
||||
["Group.Name"] = group?.Name,
|
||||
["SchoolYear"] = group?.SchoolYear,
|
||||
["CurrentDate"] = date,
|
||||
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
|
||||
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||
["Brieftext"] = new MultilineValue(LetterText), ["LehrerName"] = new TextValue(TeacherName),
|
||||
["Student.FirstName"] = new TextValue(_student.FirstName), ["Student.LastName"] = new TextValue(_student.LastName),
|
||||
["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
|
||||
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
|
||||
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||
["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsEmpty(PlaceholderValue value) => value switch
|
||||
{ TextValue x => string.IsNullOrWhiteSpace(x.Value), MultilineValue x => string.IsNullOrWhiteSpace(x.Value), _ => false };
|
||||
private static string SanitizeFileName(string value)
|
||||
{
|
||||
foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_');
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LetterTemplateChoice
|
||||
{
|
||||
public LetterTemplateInfo Model { get; }
|
||||
public string Name => Model.Name;
|
||||
public LetterTemplateChoice(LetterTemplateInfo model) => Model = model;
|
||||
}
|
||||
|
||||
public sealed class LetterContactChoice
|
||||
{
|
||||
public Contact Model { get; }
|
||||
public string Display => string.IsNullOrWhiteSpace(Model.Relation)
|
||||
? Model.Name
|
||||
: $"{Model.Name} · {Model.Relation}";
|
||||
public LetterContactChoice(Contact model) => Model = model;
|
||||
}
|
||||
|
||||
public sealed class LetterGroupChoice
|
||||
{
|
||||
public LearningGroup Model { get; }
|
||||
public string Display => $"{Model.Name} · {Model.SchoolYear}";
|
||||
public LetterGroupChoice(LearningGroup model) => Model = model;
|
||||
{ foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; }
|
||||
}
|
||||
|
||||
internal sealed class LetterDataProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
|
||||
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }
|
||||
public sealed class LetterTemplateChoice(InstalledTemplate model) { public InstalledTemplate Model { get; } = model; public string Name => Model.Name; }
|
||||
public sealed class LetterContactChoice(Contact model) { public Contact Model { get; } = model; public string Display => string.IsNullOrWhiteSpace(Model.Relation) ? Model.Name : $"{Model.Name} · {Model.Relation}"; }
|
||||
public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; }
|
||||
public sealed class LetterGenerationIssue(string message, bool isStrong)
|
||||
{
|
||||
public string Icon { get; } = isStrong ? "⚠" : "ⓘ";
|
||||
public string Message { get; } = message;
|
||||
public string Color { get; } = isStrong ? "#D97706" : "#6B7280";
|
||||
}
|
||||
{ public string Icon { get; } = isStrong ? "⚠" : "ⓘ"; public string Message { get; } = message; public string Color { get; } = isStrong ? "#D97706" : "#6B7280"; }
|
||||
|
||||
@@ -45,9 +45,9 @@
|
||||
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
|
||||
</ComboBox>
|
||||
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
|
||||
<DatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/>
|
||||
<CalendarDatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/>
|
||||
<TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/>
|
||||
<DatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/>
|
||||
<CalendarDatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
|
||||
|
||||
@@ -439,7 +439,7 @@
|
||||
<ItemsControl ItemsSource="{Binding PatternNotices}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ClassTeacherPatternNotice">
|
||||
<Grid ColumnDefinitions="4,*" Margin="0,3">
|
||||
<Grid ColumnDefinitions="4,*,Auto" Margin="0,3">
|
||||
<Border CornerRadius="2" Margin="0,1,8,1" Classes="statusFill"
|
||||
Classes.info="{Binding IsInfoStatus}"
|
||||
Classes.warning="{Binding IsWarningStatus}"
|
||||
@@ -448,6 +448,11 @@
|
||||
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Message}" FontSize="11" Opacity="0.62" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Classes="rosterRowAction" Content="+"
|
||||
IsVisible="{Binding CanCreateReminder}"
|
||||
ToolTip.Tip="Wiedervorlage „Eltern kontaktieren“ anlegen"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).CreateReminderForPatternNoticeCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
|
||||
@@ -45,9 +45,9 @@
|
||||
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
|
||||
</ComboBox>
|
||||
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
|
||||
<DatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/>
|
||||
<CalendarDatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/>
|
||||
<TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/>
|
||||
<DatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/>
|
||||
<CalendarDatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
|
||||
|
||||
@@ -444,6 +444,32 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Unterrichtszeit nacherfassen (Nutzer-Feedback) -->
|
||||
<Border Grid.Column="{Binding MissingTeachingTimeCard.Column}" Grid.Row="{Binding MissingTeachingTimeCard.Row}"
|
||||
IsVisible="{Binding MissingTeachingTimeCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="8" Padding="16">
|
||||
<StackPanel>
|
||||
<TextBlock Text="UNTERRICHTSZEIT NACHERFASSEN" FontSize="11" FontWeight="Bold"
|
||||
Opacity="0.5" Margin="0,0,0,10"/>
|
||||
<ItemsControl ItemsSource="{Binding MissingTeachingTimeEntries}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:MissingTeachingTimeItem">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
|
||||
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="13"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Erfassen" FontSize="12" Padding="10,4"
|
||||
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).AddMissingTeachingTimeCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Keine fehlende Unterrichtszeit-Erfassung." Classes="emptyhint"
|
||||
IsVisible="{Binding !MissingTeachingTimeEntries.Count}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Förderplan-Wiedervorlage (5.3.2) -->
|
||||
<Border Grid.Column="{Binding SupportCard.Column}" Grid.Row="{Binding SupportCard.Row}"
|
||||
IsVisible="{Binding SupportCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
<Button Content="Noten verwalten" Command="{Binding ManageStudentGradesCommand}" Margin="12,0,0,0"
|
||||
IsVisible="{Binding SelectedRow, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
IsEnabled="{Binding !IsReadOnly}"/>
|
||||
<Button Content="Überblick" Command="{Binding ShowPerformanceOverviewCommand}" Margin="6,0,0,0"
|
||||
IsVisible="{Binding SelectedRow, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
ToolTip.Tip="Leistungsüberblick mit Zielnoten-Rechner für diesen Schüler"/>
|
||||
<Button Content="+ Sammelnote" Command="{Binding CollectiveGradeCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||
<Button Content="Zeugnisnoten" Command="{Binding ReportGradesCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||
<Button Content="Als PDF" Click="OnExportPdfClick" Margin="12,0,0,0"
|
||||
|
||||
@@ -25,6 +25,7 @@ public partial class GradeOverviewTabView : UserControl
|
||||
vm.OnManageStudentGrades = ShowStudentGradesDialog;
|
||||
vm.OnCollectiveGrade = ShowCollectiveGradeDialog;
|
||||
vm.OnReportGrades = ShowReportGradesDialog;
|
||||
vm.OnShowPerformanceOverview = ShowPerformanceOverviewDialog;
|
||||
vm.PropertyChanged += (_, pe) =>
|
||||
{
|
||||
if (pe.PropertyName == nameof(GradeOverviewTabViewModel.RebuildColumnsSignal))
|
||||
@@ -79,6 +80,22 @@ public partial class GradeOverviewTabView : UserControl
|
||||
if (owner is not null) await dialog.ShowDialog(owner);
|
||||
}
|
||||
|
||||
private async Task ShowPerformanceOverviewDialog(GradeOverviewRow row)
|
||||
{
|
||||
var dialogVm = new StudentPerformanceOverviewViewModel(
|
||||
App.Services.GetRequiredService<IGradeRepository>(),
|
||||
App.Services.GetRequiredService<IExamRepository>(),
|
||||
App.Services.GetRequiredService<IExamResultRepository>(),
|
||||
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||
App.Services.GetRequiredService<IGradingSchemeRepository>(),
|
||||
App.Services.GetRequiredService<GradingService>(),
|
||||
_vm!.GroupId, row.StudentId, _vm.GroupType, _vm.GradingSystem, row.Name, _vm.GroupLabel, _vm.SchoolYear);
|
||||
|
||||
var dialog = new StudentPerformanceOverviewDialog { DataContext = dialogVm };
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is not null) await dialog.ShowDialog(owner);
|
||||
}
|
||||
|
||||
private async void OnExportPdfClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
|
||||
@@ -66,6 +66,14 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Klausurschnitt-Vorjahresvergleich (Nutzer-Feedback) -->
|
||||
<Border Classes="card" IsVisible="{Binding HasYearComparison}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="VORJAHRESVERGLEICH" Classes="cardTitle"/>
|
||||
<TextBlock Text="{Binding YearComparisonLabel}" TextWrapping="Wrap" Classes="cardBody"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Mitarbeit -->
|
||||
<Border Classes="card">
|
||||
<StackPanel>
|
||||
|
||||
@@ -12,8 +12,12 @@
|
||||
<!-- Schülername + Fortschritt -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,16">
|
||||
<StackPanel Grid.Column="0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding StudentName}" FontSize="22" FontWeight="SemiBold"
|
||||
Opacity="{Binding CurrentStudentContentOpacity}"/>
|
||||
<TextBlock Text="{Binding CurrentStudentDayHighlightSymbol}" FontSize="20"
|
||||
VerticalAlignment="Center" ToolTip.Tip="{Binding CurrentStudentDayHighlightLabel}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding CurrentAspectLabel}" FontSize="13" Opacity="0.5"
|
||||
IsVisible="{Binding !CurrentStudentIsAbsent}"/>
|
||||
<TextBlock Text="{Binding CurrentStudentAttendanceLabel, StringFormat='⚠ Abwesend — {0}'}"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Groups;
|
||||
@@ -19,6 +20,18 @@ public partial class ParticipationQuickInputDialog : Window
|
||||
{
|
||||
if (DataContext is not QuickInputViewModel vm) { base.OnKeyDown(e); return; }
|
||||
|
||||
if (e.KeyModifiers.HasFlag(KeyModifiers.Shift))
|
||||
{
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.D1 or Key.NumPad1: vm.SetDayHighlight(DayHighlightKind.Standout); e.Handled = true; break;
|
||||
case Key.D2 or Key.NumPad2: vm.SetDayHighlight(DayHighlightKind.Sleepy); e.Handled = true; break;
|
||||
case Key.D3 or Key.NumPad3: vm.SetDayHighlight(DayHighlightKind.Rough); e.Handled = true; break;
|
||||
case Key.D0 or Key.NumPad0: vm.SetDayHighlight(null); e.Handled = true; break;
|
||||
}
|
||||
if (e.Handled) return;
|
||||
}
|
||||
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.D0 or Key.NumPad0: vm.SetRatingByNumber(0); e.Handled = true; break;
|
||||
|
||||
@@ -350,6 +350,7 @@ public partial class ParticipationTabView : UserControl
|
||||
App.Services.GetRequiredService<IExamRepository>(),
|
||||
App.Services.GetRequiredService<IExamResultRepository>(),
|
||||
App.Services.GetRequiredService<IGradeRepository>(),
|
||||
App.Services.GetRequiredService<IUnitRepository>(),
|
||||
App.Services.GetRequiredService<GradingService>(),
|
||||
tabVm.GroupId, tabVm.SchoolYear, tabVm.GradingSystem, tabVm.GroupLabel);
|
||||
|
||||
|
||||
@@ -128,10 +128,30 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="7">
|
||||
<TextBlock Text="TAGESFLAGGE" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
|
||||
<TextBlock Text="{Binding DayHighlightLabel}" FontSize="12"/>
|
||||
<ItemsControl ItemsSource="{Binding DayHighlightChoices}">
|
||||
<ItemsControl.ItemsPanel><ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate></ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SeatDayHighlightChoice">
|
||||
<Button Classes="choice" Classes.selected="{Binding IsSelected}"
|
||||
Command="{Binding ApplyCommand}" Margin="2">
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<TextBlock Text="{Binding Symbol}" FontWeight="Bold"/>
|
||||
<TextBlock Text="{Binding Label}"/>
|
||||
<TextBlock Text="{Binding Shortcut}" FontSize="9" Opacity="0.5" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<TextBlock Grid.Row="2" Margin="0,12,0,0" FontSize="10" Opacity="0.55" TextWrapping="Wrap"
|
||||
Text="Mitarbeit: Q/W/E/R/T Aspekt · 1–5 Bewertung · +/− anpassen · Backspace löschen · ←/→ Aspekt | Anwesenheit: Strg+1/2/5/7/9/0, Strg+X | Hausaufgaben: ⌥+1/3/4/5/7/8/0, ⌥+X | Esc schließen"/>
|
||||
Text="Mitarbeit: Q/W/E/R/T Aspekt · 1–5 Bewertung · +/− anpassen · Backspace löschen · ←/→ Aspekt | Anwesenheit: Strg+1/2/5/7/9/0, Strg+X | Hausaufgaben: ⌥+1/3/4/5/7/8/0, ⌥+X | Tagesflagge: ⇧1/2/3, ⇧X | Esc schließen"/>
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
@@ -36,6 +36,12 @@ public partial class SeatAssessmentDialog : Window
|
||||
e.Handled = clear || digit is not null;
|
||||
return;
|
||||
}
|
||||
if (e.KeyModifiers.HasFlag(KeyModifiers.Shift))
|
||||
{
|
||||
if (clear || digit is not null) vm.ApplyDayHighlightShortcut(digit, clear);
|
||||
e.Handled = clear || digit is not null;
|
||||
return;
|
||||
}
|
||||
if (digit is not null)
|
||||
{
|
||||
vm.SetRatingByNumber(digit.Value);
|
||||
|
||||
@@ -154,6 +154,9 @@
|
||||
<TextBlock Text="{Binding HomeworkBadge}" Foreground="White" FontSize="10"
|
||||
ToolTip.Tip="Hausaufgabe"/>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding DayHighlightBadge}" FontSize="14"
|
||||
IsVisible="{Binding HasDayHighlightBadge}"
|
||||
ToolTip.Tip="Tagesflagge"/>
|
||||
</StackPanel>
|
||||
<!-- Strichliste Meldungen (Nutzer-Feedback): schnelles Mitzählen ohne den
|
||||
vollen Bewertungsdialog zu öffnen -->
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||
xmlns:students="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.StudentPerformanceOverviewDialog"
|
||||
x:DataType="vm:StudentPerformanceOverviewViewModel"
|
||||
Title="Leistungsüberblick"
|
||||
Width="920" Height="720" MinWidth="700" MinHeight="520"
|
||||
CanResize="True" WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24">
|
||||
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBlock Text="{Binding StudentName}" Classes="dialogtitle"/>
|
||||
<TextBlock Text="{Binding GroupLabel}" FontSize="12" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="{Binding ModeButtonLabel}"
|
||||
Command="{Binding ToggleModeCommand}" VerticalAlignment="Top"
|
||||
ToolTip.Tip="Zwischen Bewerter- und Schülermodus umschalten"/>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="12" Margin="0,12,0,10">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="Zeitraum:" VerticalAlignment="Center"/>
|
||||
<ComboBox ItemsSource="{Binding PeriodOptions}" SelectedItem="{Binding SelectedPeriod}" MinWidth="170"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding SchemeSummary}" FontSize="12" Opacity="0.6" VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsTeacherMode}"/>
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer Grid.Row="2">
|
||||
<Grid ColumnDefinitions="*,300">
|
||||
<StackPanel Grid.Column="0" Spacing="18" Margin="0,0,16,0">
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="KLAUSUREN" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding ExamsAverageDisplay, StringFormat='Ø {0}'}"
|
||||
FontSize="12" Opacity="0.7" IsVisible="{Binding ExamsAverageDisplay, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</Grid>
|
||||
<ItemsControl ItemsSource="{Binding ExamRows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:StudentExamRow">
|
||||
<Grid ColumnDefinitions="Auto,*,80,80" Margin="0,4">
|
||||
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="12" Opacity="0.6" Width="80"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Title}" FontSize="13"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding OwnGradeDisplay}" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Right"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding ClassAverageDisplay}" Opacity="0.55"
|
||||
HorizontalAlignment="Right"
|
||||
IsVisible="{Binding $parent[ItemsControl].((vm:StudentPerformanceOverviewViewModel)DataContext).IsTeacherMode}"
|
||||
ToolTip.Tip="Kursdurchschnitt dieser Klausur"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Keine Klausuren im gewählten Zeitraum." Classes="emptyhint"
|
||||
IsVisible="{Binding !ExamRows.Count}"/>
|
||||
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
IsVisible="{Binding !!ExamHistory.Points.Count}">
|
||||
<ItemsControl ItemsSource="{Binding ExamHistory.Points}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="10"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="students:GradeHistoryPoint">
|
||||
<StackPanel Width="46" VerticalAlignment="Bottom" ToolTip.Tip="{Binding TooltipText}">
|
||||
<Border Classes="historybar" Height="{Binding BarHeight}" Width="16" CornerRadius="2"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Bottom"
|
||||
Classes.warning="{Binding IsWarning}"/>
|
||||
<TextBlock Text="{Binding Value}" FontSize="11" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" Margin="0,3,0,0"/>
|
||||
<TextBlock Text="{Binding DateDisplay}" FontSize="9" Opacity="0.5"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
<ItemsControl.Styles>
|
||||
<Style Selector="Border.historybar">
|
||||
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAccentBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.historybar.warning">
|
||||
<Setter Property="Background" Value="#E53935"/>
|
||||
</Style>
|
||||
</ItemsControl.Styles>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="MITARBEIT" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding ParticipationAverageDisplay, StringFormat='Ø {0}'}"
|
||||
FontSize="12" Opacity="0.7" IsVisible="{Binding ParticipationAverageDisplay, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</Grid>
|
||||
<ItemsControl ItemsSource="{Binding ParticipationRows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:StudentSimpleGradeRow">
|
||||
<Grid ColumnDefinitions="Auto,*,70,Auto" Margin="0,4">
|
||||
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="12" Opacity="0.6" Width="80"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding CategoryLabel}" FontSize="13"/>
|
||||
<TextBox Grid.Column="2" Text="{Binding Value}"
|
||||
IsVisible="{Binding $parent[ItemsControl].((vm:StudentPerformanceOverviewViewModel)DataContext).IsTeacherMode}"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding Value}" FontWeight="SemiBold" HorizontalAlignment="Right"
|
||||
IsVisible="{Binding !$parent[ItemsControl].((vm:StudentPerformanceOverviewViewModel)DataContext).IsTeacherMode}"/>
|
||||
<Button Grid.Column="3" Content="Speichern" Command="{Binding SaveCommand}" FontSize="11" Padding="8,3" Margin="6,0,0,0"
|
||||
IsVisible="{Binding $parent[ItemsControl].((vm:StudentPerformanceOverviewViewModel)DataContext).IsTeacherMode}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Keine Mitarbeitsnote im gewählten Zeitraum." Classes="emptyhint"
|
||||
IsVisible="{Binding !ParticipationRows.Count}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="SONSTIGE" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding OtherAverageDisplay, StringFormat='Ø {0}'}"
|
||||
FontSize="12" Opacity="0.7" IsVisible="{Binding OtherAverageDisplay, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</Grid>
|
||||
<ItemsControl ItemsSource="{Binding OtherRows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:StudentSimpleGradeRow">
|
||||
<Grid ColumnDefinitions="Auto,*,70,Auto" Margin="0,4">
|
||||
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="12" Opacity="0.6" Width="80"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding CategoryLabel}" FontSize="13"/>
|
||||
<TextBox Grid.Column="2" Text="{Binding Value}"
|
||||
IsVisible="{Binding $parent[ItemsControl].((vm:StudentPerformanceOverviewViewModel)DataContext).IsTeacherMode}"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding Value}" FontWeight="SemiBold" HorizontalAlignment="Right"
|
||||
IsVisible="{Binding !$parent[ItemsControl].((vm:StudentPerformanceOverviewViewModel)DataContext).IsTeacherMode}"/>
|
||||
<Button Grid.Column="3" Content="Speichern" Command="{Binding SaveCommand}" FontSize="11" Padding="8,3" Margin="6,0,0,0"
|
||||
IsVisible="{Binding $parent[ItemsControl].((vm:StudentPerformanceOverviewViewModel)DataContext).IsTeacherMode}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Keine sonstigen Noten im gewählten Zeitraum." Classes="emptyhint"
|
||||
IsVisible="{Binding !OtherRows.Count}"/>
|
||||
</StackPanel>
|
||||
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8" Padding="14">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="ZEUGNISNOTE (BERECHNET)" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
|
||||
<TextBlock Text="{Binding CurrentReportGradeDisplay}" FontSize="18" FontWeight="SemiBold"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Spacing="16">
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8" Padding="14">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="ZIELNOTE: WAS BRAUCHE ICH NOCH?" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="Ziel:" VerticalAlignment="Center"/>
|
||||
<TextBox Text="{Binding TargetGradeText}" Width="70" PlaceholderText="z.B. 2"/>
|
||||
<TextBlock Text="in" VerticalAlignment="Center"/>
|
||||
<ComboBox ItemsSource="{Binding TargetBucketOptions}" SelectedItem="{Binding TargetBucketName}" MinWidth="110"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding TargetResultDisplay}" TextWrapping="Wrap" FontSize="13"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8" Padding="14">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="WAS-WÄRE-WENN" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="Zusätzliche Klausurnote:" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||
<TextBox Text="{Binding WhatIfExamGradeText}" Width="70" PlaceholderText="z.B. 3"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding WhatIfResultDisplay}" TextWrapping="Wrap" FontSize="13"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
|
||||
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="0,16,0,0">
|
||||
<Button Grid.Column="1" Content="Schließen" Click="OnClose"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Groups;
|
||||
|
||||
public partial class StudentPerformanceOverviewDialog : Window
|
||||
{
|
||||
public StudentPerformanceOverviewDialog() => InitializeComponent();
|
||||
|
||||
private void OnClose(object? sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
<DataTemplate x:DataType="svc:UntisTeacherDto"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<DatePicker Grid.Column="1" SelectedDate="{Binding WeekDate}"/>
|
||||
<CalendarDatePicker Grid.Column="1" SelectedDate="{Binding WeekDate}"/>
|
||||
<Button Grid.Column="2" Content="Woche laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</Grid>
|
||||
<ScrollViewer Grid.Row="2">
|
||||
|
||||
@@ -328,15 +328,15 @@
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
<!-- Tab: Word-Briefvorlagen (7.1.4 / 11.5) -->
|
||||
<!-- Tab: portable PDF-Briefvorlagen -->
|
||||
<ContentPage Header="Briefvorlagen">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="32,20,32,28" Spacing="16" MaxWidth="760">
|
||||
<TextBlock Text="Word-Vorlagen bleiben vollständig in Word gestaltet. Die App befüllt Inhaltssteuerelemente anhand ihres Tags und prüft die Vorlage bereits beim Import."
|
||||
<TextBlock Text="Portable .lavorlage-Pakete werden beim Import vollständig geprüft und lokal kopiert. Briefe entstehen anschließend als flache, durchsuchbare PDF-Dateien."
|
||||
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
|
||||
<Button Grid.Column="0" Content="+ DOCX-Vorlage importieren" Click="OnImportLetterTemplateClick"/>
|
||||
<Button Grid.Column="0" Content="+ .lavorlage importieren" Click="OnImportLetterTemplateClick"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding LetterTemplateStatus}" FontSize="12"
|
||||
VerticalAlignment="Center" TextWrapping="Wrap"
|
||||
IsVisible="{Binding LetterTemplateStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
@@ -355,7 +355,7 @@
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="14"/>
|
||||
<TextBlock FontSize="11" Opacity="0.55">
|
||||
<Run Text="{Binding OriginalFileName}"/><Run Text=" · "/>
|
||||
<Run Text="{Binding PackageFileName}"/><Run Text=" · "/>
|
||||
<Run Text="{Binding ValidationSummary}"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
@@ -391,19 +391,8 @@
|
||||
</ItemsControl>
|
||||
|
||||
<Separator/>
|
||||
<TextBlock Text="Unterstützte Tags" FontSize="15" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="In Word: Entwicklertools → Rich-Text- oder Nur-Text-Inhaltssteuerelement → Eigenschaften → Tag. Der Titel ist frei wählbar; ausgewertet wird der Tag."
|
||||
<TextBlock Text="Vorlagen werden mit dem separaten LehrerApp Vorlagen-Designer erstellt. Designer und Hauptapp verwenden exakt dieselbe QuestPDF-Renderingbibliothek."
|
||||
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
|
||||
<ItemsControl ItemsSource="{Binding SupportedLetterPlaceholders}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="services:LetterPlaceholder">
|
||||
<Grid ColumnDefinitions="190,*" Margin="0,3">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Tag}" FontFamily="Monospace" FontSize="12"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Description}" FontSize="12" Opacity="0.7"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
@@ -263,9 +263,9 @@ public partial class SettingsView : UserControl
|
||||
|
||||
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Word-Briefvorlage importieren",
|
||||
Title = "LehrerApp-Briefvorlage importieren",
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter = [new FilePickerFileType("Word-Dokumente") { Patterns = ["*.docx"] }],
|
||||
FileTypeFilter = [new FilePickerFileType("LehrerApp-Vorlagen") { Patterns = ["*.lavorlage"] }],
|
||||
});
|
||||
if (files.Count > 0) vm.ImportLetterTemplate(files[0].Path.LocalPath);
|
||||
}
|
||||
@@ -273,8 +273,7 @@ public partial class SettingsView : UserControl
|
||||
private void OnOpenLetterTemplateClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Button { Tag: LetterTemplateListItem item }) return;
|
||||
var service = App.Services.GetRequiredService<LehrerApp.Core.Services.LetterTemplateService>();
|
||||
var path = service.GetTemplatePath(item.Model);
|
||||
var path = item.Model.PackagePath;
|
||||
if (!File.Exists(path)) return;
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<TextBlock Text="Briefanrede" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding LetterSalutation}"
|
||||
PlaceholderText="z.B. Sehr geehrte Frau Mustermann,"/>
|
||||
<TextBlock Text="Wird unverändert in Word-Vorlagen für Letter.Salutation eingesetzt."
|
||||
<TextBlock Text="Wird unverändert für den Vorlagen-Platzhalter Anrede eingesetzt."
|
||||
FontSize="11" Opacity="0.55" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
||||
x:Class="LehrerApp.Desktop.Views.Students.CreateLetterDialog"
|
||||
x:DataType="vm:CreateLetterDialogViewModel"
|
||||
Title="Word-Brief erstellen" Width="580" Height="650"
|
||||
Title="PDF-Brief erstellen" Width="580" Height="760"
|
||||
MinWidth="500" MinHeight="540" CanResize="True"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
<Grid RowDefinitions="*,Auto" Margin="24,20">
|
||||
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="15">
|
||||
<TextBlock Text="Word-Brief erstellen" Classes="dialogtitle"/>
|
||||
<TextBlock Text="PDF-Brief erstellen" Classes="dialogtitle"/>
|
||||
<TextBlock FontSize="12" Opacity="0.65" TextWrapping="Wrap"
|
||||
Text="Die Vorlage wird vor der Erzeugung erneut geprüft. Das Original bleibt unverändert; gespeichert wird eine frei bearbeitbare DOCX-Kopie."/>
|
||||
Text="Die portable Vorlage und alle Pflichtfelder werden vor der Erzeugung geprüft. Gespeichert wird ein flaches, durchsuchbares PDF."/>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Schüler" FontSize="12" Opacity="0.7"/>
|
||||
@@ -43,6 +43,15 @@
|
||||
<CalendarDatePicker SelectedDate="{Binding LetterDate}" HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Brieftext" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding LetterText}" AcceptsReturn="True" TextWrapping="Wrap" MinHeight="110"
|
||||
PlaceholderText="Inhalt des Briefes"/>
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Lehrkraft / Unterschrift" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding TeacherName}" PlaceholderText="Name der Lehrkraft"/>
|
||||
</StackPanel>
|
||||
|
||||
<Border BorderBrush="#D97706" BorderThickness="1" CornerRadius="6" Padding="10"
|
||||
IsVisible="{Binding HasIssues}">
|
||||
@@ -68,7 +77,7 @@
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,10,*" Margin="0,18,0,0">
|
||||
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
|
||||
<Button Grid.Column="2" Content="DOCX speichern …" HorizontalAlignment="Stretch"
|
||||
<Button Grid.Column="2" Content="PDF speichern …" HorizontalAlignment="Stretch"
|
||||
IsEnabled="{Binding CanGenerate}" Click="OnGenerate"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -14,10 +14,10 @@ public partial class CreateLetterDialog : Window
|
||||
if (DataContext is not CreateLetterDialogViewModel vm) return;
|
||||
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Word-Brief speichern",
|
||||
Title = "PDF-Brief speichern",
|
||||
SuggestedFileName = vm.SuggestedFileName,
|
||||
DefaultExtension = "docx",
|
||||
FileTypeChoices = [new FilePickerFileType("Word-Dokumente") { Patterns = ["*.docx"] }],
|
||||
DefaultExtension = "pdf",
|
||||
FileTypeChoices = [new FilePickerFileType("PDF-Dateien") { Patterns = ["*.pdf"] }],
|
||||
});
|
||||
if (file is null || !vm.Generate(file.Path.LocalPath)) return;
|
||||
Close(file.Path.LocalPath);
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top">
|
||||
<TextBlock Text="{Binding StudentStatus}" VerticalAlignment="Center" Opacity="0.65"/>
|
||||
<Button Content="Word-Brief" Command="{Binding CreateLetterCommand}"
|
||||
<Button Content="PDF-Brief" Command="{Binding CreateLetterCommand}"
|
||||
IsVisible="{Binding !IsEditing}"/>
|
||||
<Button Content="Bearbeiten" Command="{Binding StartEditCommand}"
|
||||
IsVisible="{Binding !IsEditing}"/>
|
||||
|
||||
@@ -8,6 +8,7 @@ using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Desktop.Views.Shared;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Students;
|
||||
|
||||
@@ -57,7 +58,8 @@ public partial class StudentDetailView : UserControl
|
||||
if (owner is null || DataContext is not StudentDetailViewModel { Student: { } student }) return;
|
||||
|
||||
var vm = new CreateLetterDialogViewModel(student,
|
||||
App.Services.GetRequiredService<LehrerApp.Core.Services.LetterTemplateService>(),
|
||||
App.Services.GetRequiredService<TemplateStore>(),
|
||||
App.Services.GetRequiredService<ITemplateRenderer>(),
|
||||
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||
App.Services.GetRequiredService<IGroupRepository>());
|
||||
var dialog = new CreateLetterDialog { DataContext = vm };
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
Text="Nur eigene WebUntis-Einträge (Benutzer = eigener Login). Zeilen ohne automatische Zuordnung bitte manuell einem/einer Schüler*in zuweisen. Bereits lokal vorhandene Einträge sind gesperrt."/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
|
||||
<DatePicker SelectedDate="{Binding StartDate}"/>
|
||||
<CalendarDatePicker SelectedDate="{Binding StartDate}"/>
|
||||
<TextBlock Text="bis" VerticalAlignment="Center"/>
|
||||
<DatePicker SelectedDate="{Binding EndDate}"/>
|
||||
<CalendarDatePicker SelectedDate="{Binding EndDate}"/>
|
||||
<Button Content="Klassenbucheinträge laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</StackPanel>
|
||||
|
||||
|
||||
@@ -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,109 @@
|
||||
using Avalonia;
|
||||
using LehrerApp.TemplateDesigner;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner.Tests;
|
||||
|
||||
public sealed class OverlayEditorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Koordinatenabbildung_BeruecksichtigtLetterboxUndIstUmkehrbar()
|
||||
{
|
||||
var displayedPage = OverlayCoordinateMapper.PageRect(new Size(1000, 1000), 210, 297);
|
||||
var dslRect = new Rect(20, 30, 80, 45);
|
||||
|
||||
var controlRect = OverlayCoordinateMapper.ToControl(dslRect, displayedPage, 210, 297);
|
||||
var mappedTopLeft = OverlayCoordinateMapper.ToDsl(controlRect.TopLeft, displayedPage, 210, 297);
|
||||
var mappedBottomRight = OverlayCoordinateMapper.ToDsl(controlRect.BottomRight, displayedPage, 210, 297);
|
||||
|
||||
Assert.Equal(1000, displayedPage.Height, 6);
|
||||
Assert.True(displayedPage.X > 140);
|
||||
Assert.Equal(dslRect.X, mappedTopLeft.X, 6);
|
||||
Assert.Equal(dslRect.Y, mappedTopLeft.Y, 6);
|
||||
Assert.Equal(dslRect.Right, mappedBottomRight.X, 6);
|
||||
Assert.Equal(dslRect.Bottom, mappedBottomRight.Y, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Messbereich_WirdInElementformularUebernommen()
|
||||
{
|
||||
var viewModel = new DesignerViewModel();
|
||||
|
||||
viewModel.ApplyMeasurement(new(12.5, 34.25, 80, 22.5, true));
|
||||
|
||||
Assert.Equal("12.5", viewModel.NewX);
|
||||
Assert.Equal("34.25", viewModel.NewY);
|
||||
Assert.Equal("80", viewModel.NewWidth);
|
||||
Assert.Equal("22.5", viewModel.NewHeight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextVerschieben_ErhaeltInhaltUndAttribute()
|
||||
{
|
||||
var viewModel = new DesignerViewModel
|
||||
{ LayoutSource = "PAGE 210 297 mm\nTEXT 20 30 \"Hallo Welt\" size=10 bold=true" };
|
||||
|
||||
viewModel.ApplyElementGeometry(new(2, "TEXT", 25.5, 40, 70, 5));
|
||||
|
||||
Assert.Contains("TEXT 25.5 40 \"Hallo Welt\" size=10 bold=true", viewModel.LayoutSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkaliertesBildResize_RechnetEffektivenRahmenZurueckUndErhaeltScale()
|
||||
{
|
||||
var viewModel = new DesignerViewModel
|
||||
{ LayoutSource = "PAGE 210 297 mm\nIMG logo.png 10 15 30 12 scale=50%" };
|
||||
|
||||
viewModel.ApplyElementGeometry(new(2, "IMG", 20, 25, 30, 12));
|
||||
|
||||
Assert.Contains("IMG logo.png 20 25 60 24 scale=50%", viewModel.LayoutSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DslSeitengroesse_SteuertOverlayAuchBeiAbweichendemManifestformular()
|
||||
{
|
||||
var viewModel = new DesignerViewModel { PageWidth = 210, PageHeight = 297 };
|
||||
|
||||
viewModel.LayoutSource = "PAGE 200 200 pt";
|
||||
|
||||
Assert.Equal(200, viewModel.OverlayPageWidth);
|
||||
Assert.Equal(200, viewModel.OverlayPageHeight);
|
||||
Assert.Equal("pt", viewModel.OverlayPageUnit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlowSlotVerschieben_AendertNurDasPragmaDerAusgewaehltenSeite()
|
||||
{
|
||||
var viewModel = new DesignerViewModel();
|
||||
var layout = new LehrerApp.Templating.LayoutParser().Parse(viewModel.LayoutSource);
|
||||
var firstSlot = layout.PageTemplates.Single(x => x.Name == "first").FlowSlots.Single();
|
||||
var continuationSlot = layout.PageTemplates.Single(x => x.Name == "continuation").FlowSlots.Single();
|
||||
|
||||
viewModel.ApplyElementGeometry(new(firstSlot.Line, "FLOW", 25, 95, 160, 165));
|
||||
|
||||
var updated = new LehrerApp.Templating.LayoutParser().Parse(viewModel.LayoutSource);
|
||||
var updatedFirst = updated.PageTemplates.Single(x => x.Name == "first").FlowSlots.Single();
|
||||
var updatedContinuation = updated.PageTemplates.Single(x => x.Name == "continuation").FlowSlots.Single();
|
||||
Assert.Equal(25, updatedFirst.X);
|
||||
Assert.Equal(95, updatedFirst.Y);
|
||||
Assert.Equal(continuationSlot.X, updatedContinuation.X);
|
||||
Assert.Equal(continuationSlot.Y, updatedContinuation.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ElementKannGezieltInContentFlowEingefuegtWerden()
|
||||
{
|
||||
var viewModel = new DesignerViewModel
|
||||
{
|
||||
NewElementScope = "Content-Flow", SelectedContentFlow = "body",
|
||||
NewElementType = "TEXT", NewContent = "Nachsatz", NewAttributes = "gap=5",
|
||||
};
|
||||
|
||||
viewModel.AddElement();
|
||||
|
||||
var flow = new LehrerApp.Templating.LayoutParser().Parse(viewModel.LayoutSource).ContentFlows.Single();
|
||||
var text = Assert.IsType<LehrerApp.Templating.TextElement>(flow.Elements.Last());
|
||||
Assert.Equal("Nachsatz", text.Content);
|
||||
Assert.Equal("5", text.Attributes["gap"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using LehrerApp.TemplateDesigner;
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
using System.Runtime.Versioning;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner.Tests;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
[SupportedOSPlatform("linux")]
|
||||
[SupportedOSPlatform("macos")]
|
||||
public sealed class PdfImportPipelineTests
|
||||
{
|
||||
[Fact]
|
||||
public void TemplateDiff_LaesstIdentischenTextStatischUndMarkiertAbweichung()
|
||||
{
|
||||
var pipeline = new PdfImportPipeline();
|
||||
var template = Document(
|
||||
Block("a", 40, 30, "Schule am Park"),
|
||||
Block("b", 40, 100, "Max Mustermann"));
|
||||
var example = Document(
|
||||
Block("a2", 40, 30, "Schule am Park"),
|
||||
Block("b2", 40, 100, "Erika Beispiel"));
|
||||
|
||||
var candidate = Assert.Single(pipeline.FindCandidates(example, template));
|
||||
|
||||
Assert.Equal("Erika Beispiel", candidate.OriginalText);
|
||||
Assert.Equal(PdfImportConfidence.High, candidate.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EinzelPdf_ErkenntDatumTypDeterministisch()
|
||||
{
|
||||
var pipeline = new PdfImportPipeline();
|
||||
|
||||
var candidate = Assert.Single(pipeline.FindCandidates(Document(Block("d", 400, 60, "31.08.2026")), null));
|
||||
|
||||
Assert.Equal("Datum", candidate.Name);
|
||||
Assert.Equal(PlaceholderType.Date, candidate.Type);
|
||||
Assert.Equal(PdfImportConfidence.High, candidate.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UebernahmeErsetztDasAktuelleDesignerprojekt()
|
||||
{
|
||||
var result = new PdfImportResult("PAGE 595 842 pt\nTEXT 40 100 $Name\n",
|
||||
new TemplateManifest
|
||||
{
|
||||
Id = "pdf-import", Name = "PDF-Import", PageSize = new(595, 842, "pt"),
|
||||
Placeholders = [new("Name", PlaceholderType.Text)],
|
||||
}, new Dictionary<string, byte[]>(),
|
||||
[new PdfImportCandidate { Id = "x", BlockIds = ["x"], OriginalText = "Erika Beispiel", Name = "Name", Confidence = PdfImportConfidence.High }]);
|
||||
var viewModel = new DesignerViewModel();
|
||||
|
||||
viewModel.ApplyPdfImport(result);
|
||||
|
||||
Assert.Equal("pt", viewModel.Unit);
|
||||
Assert.Equal("Erika Beispiel", Assert.Single(viewModel.Placeholders).Sample);
|
||||
Assert.Contains("$Name", viewModel.LayoutSource);
|
||||
Assert.False(viewModel.CanExport);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EchtesPdf_WirdExtrahiertUndAlsRenderbareVorlageErzeugt()
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
var path = Path.Combine(Path.GetTempPath(), $"pdf-import-{Guid.NewGuid():N}.pdf");
|
||||
try
|
||||
{
|
||||
QuestPDF.Fluent.Document.Create(document => document.Page(page =>
|
||||
{
|
||||
page.Size(595, 842); page.Margin(40); page.Content().Text("Erika Beispiel").FontSize(11);
|
||||
})).GeneratePdf(path);
|
||||
|
||||
var result = await new PdfImportPipeline().BuildAsync(path, null, null, null);
|
||||
var viewModel = new DesignerViewModel();
|
||||
viewModel.ApplyPdfImport(result);
|
||||
var png = new QuestTemplateRenderer().RenderFirstPageToPng(viewModel.BuildLoaded(), viewModel.BuildDataProvider());
|
||||
|
||||
Assert.NotEmpty(result.Candidates);
|
||||
Assert.Contains("pdf-import-background.png", result.Assets.Keys);
|
||||
Assert.True(png.Length > 1_000);
|
||||
}
|
||||
finally { if (File.Exists(path)) File.Delete(path); }
|
||||
}
|
||||
|
||||
private static PdfImportDocument Document(params PdfTextBlock[] blocks) =>
|
||||
new([new PdfImportPage(1, 595, 842, blocks)]);
|
||||
|
||||
private static PdfTextBlock Block(string id, double x, double y, string text) =>
|
||||
new(id, 1, x, y, 120, 12, 11, "Arial", false, false, text);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using LehrerApp.TemplateDesigner;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner.Tests;
|
||||
|
||||
public sealed class ProjectLifecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public void Reset_ErzeugtEinVollstaendigLeeresUnabhaengigesProjekt()
|
||||
{
|
||||
var viewModel = new DesignerViewModel();
|
||||
viewModel.ImportAsset("logo.png", OnePixelPng);
|
||||
viewModel.Placeholders.Add(new("Test", LehrerApp.Templating.PlaceholderType.Text, false, "Wert"));
|
||||
viewModel.NewElementType = "IMG";
|
||||
viewModel.NewContent = "logo.png";
|
||||
|
||||
viewModel.Reset();
|
||||
|
||||
Assert.Equal("neue-vorlage", viewModel.TemplateId);
|
||||
Assert.Contains("#pragma page-template first", viewModel.LayoutSource);
|
||||
Assert.Contains("#pragma page-template continuation", viewModel.LayoutSource);
|
||||
Assert.Contains("#pragma content-flow body", viewModel.LayoutSource);
|
||||
Assert.Empty(viewModel.Placeholders);
|
||||
Assert.Empty(viewModel.Assets);
|
||||
Assert.Empty(viewModel.AssetItems);
|
||||
Assert.Equal("de-DE", viewModel.MetadataItems.Single(x => x.Name == "language").Value);
|
||||
Assert.Equal("letter", viewModel.MetadataItems.Single(x => x.Name == "report-type").Value);
|
||||
Assert.Null(viewModel.SelectedAsset);
|
||||
Assert.Null(viewModel.PreviewImage);
|
||||
Assert.Equal("TEXT", viewModel.NewElementType);
|
||||
Assert.Equal("$Brieftext", viewModel.NewContent);
|
||||
Assert.False(viewModel.CanExport);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreieMetadaten_WerdenInDasManifestUebernommen()
|
||||
{
|
||||
var viewModel = new DesignerViewModel();
|
||||
viewModel.MetadataItems.Add(new("school-year", "2026/27"));
|
||||
|
||||
var manifest = viewModel.BuildManifest();
|
||||
|
||||
Assert.Equal("de-DE", manifest.Metadata["language"]);
|
||||
Assert.Equal("2026/27", manifest.Metadata["school-year"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlatzhalterAnlegen_WaehltIhnAusUndUebernimmtBearbeiteteDefinition()
|
||||
{
|
||||
var viewModel = new DesignerViewModel();
|
||||
viewModel.Placeholders.Clear();
|
||||
|
||||
var placeholder = viewModel.AddPlaceholder();
|
||||
placeholder.Name = "Sprache";
|
||||
placeholder.Type = LehrerApp.Templating.PlaceholderType.Text;
|
||||
placeholder.Required = true;
|
||||
placeholder.Sample = "Deutsch";
|
||||
placeholder.IsConstant = true;
|
||||
placeholder.Bold = true;
|
||||
placeholder.Italic = true;
|
||||
placeholder.Underline = true;
|
||||
|
||||
Assert.Same(placeholder, viewModel.SelectedPlaceholder);
|
||||
Assert.True(viewModel.HasSelectedPlaceholder);
|
||||
Assert.False(viewModel.HasNoSelectedPlaceholder);
|
||||
var definition = Assert.Single(viewModel.BuildManifest().Placeholders);
|
||||
Assert.Equal("Sprache", definition.Name);
|
||||
Assert.Equal(LehrerApp.Templating.PlaceholderType.Text, definition.Type);
|
||||
Assert.True(definition.Required);
|
||||
Assert.True(definition.IsConstant);
|
||||
Assert.Equal("Deutsch", definition.ConstantValue);
|
||||
Assert.True(definition.Bold);
|
||||
Assert.True(definition.Italic);
|
||||
Assert.True(definition.Underline);
|
||||
|
||||
viewModel.RemoveSelectedPlaceholder();
|
||||
Assert.Empty(viewModel.Placeholders);
|
||||
Assert.Null(viewModel.SelectedPlaceholder);
|
||||
Assert.False(viewModel.HasSelectedPlaceholder);
|
||||
Assert.True(viewModel.HasNoSelectedPlaceholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoppeltePlatzhalternamen_WerdenVerstaendlichAbgelehnt()
|
||||
{
|
||||
var viewModel = new DesignerViewModel();
|
||||
viewModel.Placeholders.Clear();
|
||||
viewModel.Placeholders.Add(new("Name", LehrerApp.Templating.PlaceholderType.Text, false, "A"));
|
||||
viewModel.Placeholders.Add(new("Name", LehrerApp.Templating.PlaceholderType.Text, false, "B"));
|
||||
|
||||
var exception = Assert.Throws<InvalidDataException>(() => viewModel.BuildManifest());
|
||||
|
||||
Assert.Contains("mehrfach", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlteAbsoluteLayouts_WerdenBeimOeffnenVerlustfreiInSeitentypEingebettet()
|
||||
{
|
||||
const string source = "PAGE 210 297 mm\nTEXT 20 30 \"Altbestand\" size=12\n";
|
||||
var layout = new LehrerApp.Templating.LayoutParser().Parse(source);
|
||||
|
||||
var migrated = DesignerViewModel.MigrateLegacyLayout(source, layout);
|
||||
var parsed = new LehrerApp.Templating.LayoutParser().Parse(migrated);
|
||||
|
||||
Assert.True(parsed.UsesPageTemplates);
|
||||
Assert.Equal("Altbestand", Assert.IsType<LehrerApp.Templating.TextElement>(
|
||||
parsed.PageTemplates.Single(x => x.Name == "first").Elements.Single()).Content);
|
||||
Assert.Empty(parsed.ContentFlows.Single().Elements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AktuelleAnsicht_KannAlsPdfGerendertWerden()
|
||||
{
|
||||
var pdf = new DesignerViewModel().RenderCurrentPdf();
|
||||
|
||||
Assert.True(pdf.Length > 100);
|
||||
Assert.Equal("%PDF-", System.Text.Encoding.ASCII.GetString(pdf, 0, 5));
|
||||
}
|
||||
|
||||
private static readonly byte[] OnePixelPng = Convert.FromBase64String(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=");
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using LehrerApp.TemplateDesigner;
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner.Tests;
|
||||
|
||||
public sealed class StarterTemplateLibraryTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"starter-library-{Guid.NewGuid():N}");
|
||||
public StarterTemplateLibraryTests() => Directory.CreateDirectory(_directory);
|
||||
|
||||
[Fact]
|
||||
public void SpeichernUndAktualisieren_VerwendetStabileIdUndErsetztInhalt()
|
||||
{
|
||||
var library = new StarterTemplateLibrary(_directory);
|
||||
var manifest = Manifest("briefkopf", "Briefkopf");
|
||||
library.Save(manifest, "PAGE 210 297 mm\nTEXT 10 10 \"Version 1\"", EmptyAssets());
|
||||
|
||||
library.Save(manifest, "PAGE 210 297 mm\nTEXT 10 10 \"Version 2\"", EmptyAssets());
|
||||
|
||||
var item = Assert.Single(library.GetAll());
|
||||
var (_, layout) = library.Load(item);
|
||||
Assert.Equal("briefkopf", item.Id);
|
||||
Assert.Contains("Version 2", layout);
|
||||
Assert.DoesNotContain("Version 1", layout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Duplizieren_ErzeugtUnabhaengigeIdUndLaesstOriginalUnveraendert()
|
||||
{
|
||||
var library = new StarterTemplateLibrary(_directory);
|
||||
var original = library.Save(Manifest("briefkopf", "Briefkopf"),
|
||||
"PAGE 210 297 mm", EmptyAssets());
|
||||
|
||||
var firstCopy = library.Duplicate(original);
|
||||
var secondCopy = library.Duplicate(original);
|
||||
|
||||
Assert.Equal("briefkopf-kopie", firstCopy.Id);
|
||||
Assert.Equal("briefkopf-kopie-2", secondCopy.Id);
|
||||
Assert.Equal(3, library.GetAll().Count);
|
||||
Assert.Equal("Briefkopf", library.Load(original).Template.Manifest.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportUndExport_UebertragenPortablesPaket()
|
||||
{
|
||||
var sourceLibrary = new StarterTemplateLibrary(Path.Combine(_directory, "source"));
|
||||
var targetLibrary = new StarterTemplateLibrary(Path.Combine(_directory, "target"));
|
||||
var item = sourceLibrary.Save(Manifest("kopf", "Schulbriefkopf"), "PAGE 210 297 mm", EmptyAssets());
|
||||
var exported = Path.Combine(_directory, "export.lavorlage");
|
||||
|
||||
sourceLibrary.Export(item, exported);
|
||||
var imported = targetLibrary.Import(exported);
|
||||
|
||||
Assert.Equal("kopf", imported.Id);
|
||||
Assert.Equal("Schulbriefkopf", Assert.Single(targetLibrary.GetAll()).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlsNeuesProjekt_BehaeltLayoutUndVergibtNeueMetadaten()
|
||||
{
|
||||
var library = new StarterTemplateLibrary(_directory);
|
||||
var item = library.Save(Manifest("kopf", "Schulbriefkopf"),
|
||||
"PAGE 210 297 mm\nTEXT 10 10 \"Schule\"", EmptyAssets());
|
||||
var (template, layout) = library.Load(item);
|
||||
var viewModel = new DesignerViewModel();
|
||||
|
||||
viewModel.LoadAsNewProject(template, layout);
|
||||
|
||||
Assert.Equal("kopf-neu", viewModel.TemplateId);
|
||||
Assert.Equal("Schulbriefkopf - Neu", viewModel.TemplateName);
|
||||
Assert.Contains("Schule", viewModel.LayoutSource);
|
||||
Assert.Equal("kopf", library.Load(item).Template.Manifest.Id);
|
||||
}
|
||||
|
||||
private static TemplateManifest Manifest(string id, string name) => new()
|
||||
{ Id = id, Name = name, Description = "Wiederverwendbarer Ausgangspunkt" };
|
||||
private static IReadOnlyDictionary<string, byte[]> EmptyAssets() => new Dictionary<string, byte[]>();
|
||||
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<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/>
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
||||
<Style Selector="TextBlock.section"><Setter Property="FontWeight" Value="SemiBold"/><Setter Property="FontSize" Value="15"/></Style>
|
||||
<Style Selector="TextBlock.label"><Setter Property="Opacity" Value="0.7"/><Setter Property="FontSize" Value="12"/></Style>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
@@ -0,0 +1,16 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner;
|
||||
|
||||
public sealed class App : Application
|
||||
{
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
desktop.MainWindow = new MainWindow();
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,780 @@
|
||||
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
|
||||
{
|
||||
private Bitmap? _pageTemplatePreview;
|
||||
[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 bool _useContinuationLayout;
|
||||
[ObservableProperty] private string _continuationLayoutSource = "PAGE 210 297 mm\nFLOWBOX 20 20 170 257 $Brieftext size=11\n";
|
||||
[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 string _newElementScope = "Seitenvorlage (fest)";
|
||||
[ObservableProperty] private string _selectedContentFlow = "body";
|
||||
[ObservableProperty] private string _newPageTemplateName = "continuation";
|
||||
[ObservableProperty] private string _newFlowName = "body";
|
||||
[ObservableProperty] private Bitmap? _previewImage;
|
||||
[ObservableProperty] private string _status = "Bereit.";
|
||||
[ObservableProperty] private string _statusColor = "#475569";
|
||||
[ObservableProperty] private bool _canExport;
|
||||
[ObservableProperty] private DesignerPlaceholder? _selectedPlaceholder;
|
||||
[ObservableProperty] private DesignerMetadata? _selectedMetadata;
|
||||
[ObservableProperty] private DesignerAsset? _selectedAsset;
|
||||
[ObservableProperty] private StarterTemplateItem? _selectedStarterTemplate;
|
||||
[ObservableProperty] private OverlayEditorMode _overlayMode = OverlayEditorMode.Measure;
|
||||
[ObservableProperty] private bool _snapOverlayToGrid = true;
|
||||
[ObservableProperty] private double _overlayGridSize = 1;
|
||||
[ObservableProperty] private string _overlayCoordinates = "x=– · y=–";
|
||||
[ObservableProperty] private string _selectedOverlayElement = "Kein Element ausgewählt";
|
||||
[ObservableProperty] private double _overlayPageWidth = 210;
|
||||
[ObservableProperty] private double _overlayPageHeight = 297;
|
||||
[ObservableProperty] private string _overlayPageUnit = "mm";
|
||||
[ObservableProperty] private string _selectedPageTemplate = "first";
|
||||
[ObservableProperty] private DesignerPreviewPage? _selectedPreviewPage;
|
||||
|
||||
public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"];
|
||||
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
|
||||
public IReadOnlyList<string> ElementTypes { get; } =
|
||||
["TEXT", "TEXTBOX", "FLOWBOX", "DRAWBOX", "FLOWDRAWBOX", "IMG", "TABLE", "CHART"];
|
||||
public IReadOnlyList<string> ElementScopes { get; } = ["Seitenvorlage (fest)", "Content-Flow"];
|
||||
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 ObservableCollection<DesignerMetadata> MetadataItems { get; } =
|
||||
[
|
||||
new(TemplateMetadataKeys.Language, "de-DE"),
|
||||
new(TemplateMetadataKeys.ReportType, "letter"),
|
||||
];
|
||||
public Dictionary<string, byte[]> Assets { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public ObservableCollection<DesignerAsset> AssetItems { get; } = [];
|
||||
public ObservableCollection<StarterTemplateItem> StarterTemplates { get; } = [];
|
||||
public ObservableCollection<string> PageTemplateNames { get; } = ["first"];
|
||||
public ObservableCollection<string> ContentFlowNames { get; } = ["body"];
|
||||
public ObservableCollection<DesignerPreviewPage> PreviewPages { get; } = [];
|
||||
public bool HasSelectedPlaceholder => SelectedPlaceholder is not null;
|
||||
public bool HasNoSelectedPlaceholder => SelectedPlaceholder is null;
|
||||
|
||||
public DesignerViewModel() => SelectedPlaceholder = Placeholders.FirstOrDefault();
|
||||
|
||||
partial void OnSelectedPlaceholderChanged(DesignerPlaceholder? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasSelectedPlaceholder));
|
||||
OnPropertyChanged(nameof(HasNoSelectedPlaceholder));
|
||||
}
|
||||
|
||||
partial void OnSelectedPreviewPageChanged(DesignerPreviewPage? value) => PreviewImage = value?.Image;
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
TemplateId = "neue-vorlage"; TemplateName = "Neue Vorlage"; Description = "";
|
||||
PageWidth = 210; PageHeight = 297; Unit = "mm"; LayoutSource = EmptyStructuredLayout;
|
||||
UseContinuationLayout = false;
|
||||
ContinuationLayoutSource = "PAGE 210 297 mm\nFLOWBOX 20 20 170 257 $Brieftext size=11\n";
|
||||
Placeholders.Clear(); SelectedPlaceholder = null;
|
||||
MetadataItems.Clear(); MetadataItems.Add(new(TemplateMetadataKeys.Language, "de-DE"));
|
||||
MetadataItems.Add(new(TemplateMetadataKeys.ReportType, "letter"));
|
||||
SelectedMetadata = null;
|
||||
Assets.Clear(); AssetItems.Clear(); SelectedAsset = null;
|
||||
NewElementType = "TEXT"; NewX = "20"; NewY = "50"; NewWidth = "170"; NewHeight = "30";
|
||||
NewImageScale = "100"; NewContent = "$Brieftext"; NewAttributes = "size=11";
|
||||
ClearPreviewPages(); CanExport = false;
|
||||
OverlayMode = OverlayEditorMode.Measure; OverlayCoordinates = "x=– · y=–";
|
||||
SelectedOverlayElement = "Kein Element ausgewählt";
|
||||
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",
|
||||
ContinuationLayoutFile = UseContinuationLayout ? "continuation.tpl" : null,
|
||||
Metadata = BuildMetadata(),
|
||||
Placeholders = BuildPlaceholders(),
|
||||
};
|
||||
|
||||
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 continuationLayout = UseContinuationLayout ? new LayoutParser().Parse(ContinuationLayoutSource) : null;
|
||||
var issues = new List<ValidationIssue>();
|
||||
var layouts = continuationLayout is null ? new[] { layout } : new[] { layout, continuationLayout };
|
||||
var declared = manifest.Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var used in layouts.SelectMany(TemplateLoader.UsedPlaceholders).Distinct(StringComparer.Ordinal)
|
||||
.Where(x => !declared.Contains(x)))
|
||||
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{used}“ ist nicht deklariert."));
|
||||
foreach (var path in layouts.SelectMany(x => x.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 (layout.UsesPageTemplates && continuationLayout is not null)
|
||||
issues.Add(new(ValidationSeverity.Error,
|
||||
"Layoutformat 3 enthält Folgeseiten als page-template und kann nicht zusätzlich continuation.tpl verwenden."));
|
||||
if (!layout.UsesPageTemplates)
|
||||
{
|
||||
var firstFlows = layout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement).ToList();
|
||||
if (firstFlows.Count > 1)
|
||||
issues.Add(new(ValidationSeverity.Error, "Ein Legacy-Layout darf höchstens eine FLOWBOX enthalten."));
|
||||
if (continuationLayout is not null)
|
||||
{
|
||||
var continuationFlows = continuationLayout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement).ToList();
|
||||
if (firstFlows.Count != 1 || continuationFlows.Count != 1)
|
||||
issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen jeweils genau ein fließendes Element enthalten."));
|
||||
else if (firstFlows[0].GetType() != continuationFlows[0].GetType())
|
||||
issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen denselben fließenden Elementtyp verwenden."));
|
||||
else if (firstFlows[0].X != continuationFlows[0].X || firstFlows[0].Y != continuationFlows[0].Y
|
||||
|| firstFlows[0].Width != continuationFlows[0].Width || firstFlows[0].Height != continuationFlows[0].Height)
|
||||
issues.Add(new(ValidationSeverity.Error, "Die FLOWBOX muss auf Haupt- und Folgeseiten dieselbe Position und Größe haben."));
|
||||
}
|
||||
}
|
||||
foreach (var issue in TemplateDataResolver.ValidateConstants(manifest))
|
||||
issues.Add(new(ValidationSeverity.Error, issue));
|
||||
if (issues.Count > 0) throw new TemplateValidationException(new(issues));
|
||||
return new(manifest, layout, new Dictionary<string, byte[]>(Assets), ContinuationLayout: continuationLayout);
|
||||
}
|
||||
|
||||
public ITemplateDataProvider BuildDataProvider() => new DesignerDataProvider(Placeholders.ToDictionary(
|
||||
x => x.Name.Trim(), x => x.ToValue(), StringComparer.Ordinal));
|
||||
|
||||
public byte[] RenderCurrentPdf() =>
|
||||
new QuestTemplateRenderer().RenderToPdf(BuildLoaded(), BuildDataProvider());
|
||||
|
||||
public void ApplyPdfImport(PdfImportResult import)
|
||||
{
|
||||
TemplateId = import.Manifest.Id; TemplateName = import.Manifest.Name; Description = import.Manifest.Description;
|
||||
PageWidth = (decimal)import.Manifest.PageSize.Width; PageHeight = (decimal)import.Manifest.PageSize.Height;
|
||||
Unit = import.Manifest.PageSize.Unit; LayoutSource = import.LayoutSource;
|
||||
UseContinuationLayout = false;
|
||||
Placeholders.Clear();
|
||||
foreach (var definition in import.Manifest.Placeholders)
|
||||
{
|
||||
var candidate = import.Candidates.FirstOrDefault(x => x.Name.Equals(definition.Name, StringComparison.Ordinal));
|
||||
Placeholders.Add(new(definition.Name, definition.Type, definition.Required,
|
||||
candidate?.OriginalText ?? DesignerPlaceholder.SampleFor(definition.Type)));
|
||||
}
|
||||
SelectedPlaceholder = Placeholders.FirstOrDefault();
|
||||
Assets.Clear(); AssetItems.Clear();
|
||||
foreach (var asset in import.Assets) AddOrReplaceAsset(asset.Key, asset.Value, keepName: true);
|
||||
MetadataItems.Clear();
|
||||
foreach (var metadata in import.Manifest.Metadata) MetadataItems.Add(new(metadata.Key, metadata.Value));
|
||||
CanExport = false;
|
||||
SetStatus("PDF-Import übernommen. Bitte Vorschau, Platzhalter und Layout vor dem Speichern prüfen.", false);
|
||||
}
|
||||
|
||||
public DesignerPlaceholder AddPlaceholder()
|
||||
{
|
||||
var existing = Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal);
|
||||
var index = Placeholders.Count + 1;
|
||||
while (existing.Contains($"Feld{index}")) index++;
|
||||
var placeholder = new DesignerPlaceholder($"Feld{index}", PlaceholderType.Text, false, "Beispiel");
|
||||
Placeholders.Add(placeholder); SelectedPlaceholder = placeholder; CanExport = false;
|
||||
SetStatus($"Platzhalter „{placeholder.Name}“ angelegt. Details können jetzt bearbeitet werden.", false);
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
public void RemoveSelectedPlaceholder()
|
||||
{
|
||||
if (SelectedPlaceholder is not { } selected) return;
|
||||
Placeholders.Remove(selected); SelectedPlaceholder = null; CanExport = false;
|
||||
SetStatus($"Platzhalter „{selected.Name}“ entfernt.", false);
|
||||
}
|
||||
|
||||
public void Load(LoadedTemplate template, string layoutSource, string? continuationLayoutSource = null)
|
||||
{
|
||||
if (!template.Layout.UsesPageTemplates && continuationLayoutSource is null)
|
||||
layoutSource = MigrateLegacyLayout(layoutSource, template.Layout);
|
||||
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;
|
||||
UseContinuationLayout = !template.Layout.UsesPageTemplates
|
||||
&& template.Manifest.ContinuationLayoutFile is not null;
|
||||
ContinuationLayoutSource = continuationLayoutSource ?? "PAGE 210 297 mm\n";
|
||||
MetadataItems.Clear();
|
||||
foreach (var item in template.Manifest.Metadata.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase))
|
||||
MetadataItems.Add(new(item.Key, item.Value));
|
||||
SelectedMetadata = null;
|
||||
Placeholders.Clear();
|
||||
foreach (var placeholder in template.Manifest.Placeholders)
|
||||
Placeholders.Add(new(placeholder.Name, placeholder.Type, placeholder.Required,
|
||||
placeholder.IsConstant ? placeholder.ConstantValue ?? "" : DesignerPlaceholder.SampleFor(placeholder.Type),
|
||||
placeholder.IsConstant, placeholder.Bold, placeholder.Italic, placeholder.Underline));
|
||||
SelectedPlaceholder = Placeholders.FirstOrDefault();
|
||||
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 static string MigrateLegacyLayout(string source, TemplateLayout layout)
|
||||
{
|
||||
if (layout.UsesPageTemplates) return source;
|
||||
var statements = source.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n')
|
||||
.Where(line =>
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
return trimmed.Length > 0 && !trimmed.StartsWith('#')
|
||||
&& !trimmed.StartsWith("PAGE ", StringComparison.OrdinalIgnoreCase);
|
||||
}).Select(line => line.Trim()).ToList();
|
||||
var margin = layout.Unit.Equals("mm", StringComparison.OrdinalIgnoreCase) ? 20f : layout.Width * 0.08f;
|
||||
var x = FormatNumber(margin); var y = FormatNumber(margin);
|
||||
var width = FormatNumber(Math.Max(1, layout.Width - margin * 2));
|
||||
var height = FormatNumber(Math.Max(1, layout.Height - margin * 2));
|
||||
var result = new List<string>
|
||||
{
|
||||
$"PAGE {FormatNumber(layout.Width)} {FormatNumber(layout.Height)} {layout.Unit}",
|
||||
"#pragma format-version 3", "", "#pragma page-template first",
|
||||
};
|
||||
result.AddRange(statements);
|
||||
result.AddRange([
|
||||
$"#pragma flow-slot body x={x} y={y} w={width} h={height}",
|
||||
"#pragma end-page-template", "", "#pragma page-template continuation",
|
||||
$"#pragma flow-slot body x={x} y={y} w={width} h={height}",
|
||||
"#pragma end-page-template", "", "#pragma content-flow body",
|
||||
"#pragma end-content-flow", "",
|
||||
]);
|
||||
return string.Join('\n', result);
|
||||
}
|
||||
|
||||
public void LoadAsNewProject(LoadedTemplate template, string layoutSource, string? continuationLayoutSource = null, string? newId = null)
|
||||
{
|
||||
Load(template, layoutSource, continuationLayoutSource);
|
||||
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);
|
||||
RenderPreviewPages(template, provider);
|
||||
SetStatus($"Vorschau von „{template.Manifest.Name}“ mit {PreviewPages.Count} Seite(n).", false);
|
||||
}
|
||||
|
||||
public void RenderPreviewPages(LoadedTemplate template, ITemplateDataProvider provider)
|
||||
{
|
||||
var pages = new QuestTemplateRenderer().RenderPagesToPng(template, provider);
|
||||
ClearPreviewPages();
|
||||
for (var index = 0; index < pages.Count; index++)
|
||||
{
|
||||
using var stream = new MemoryStream(pages[index]);
|
||||
PreviewPages.Add(new(index + 1, new Bitmap(stream)));
|
||||
}
|
||||
SelectedPreviewPage = PreviewPages.FirstOrDefault();
|
||||
}
|
||||
|
||||
public void PreviewSelectedPageTemplateCanvas()
|
||||
{
|
||||
var loaded = BuildLoaded();
|
||||
var pageTemplate = loaded.Layout.PageTemplates.FirstOrDefault(x =>
|
||||
x.Name.Equals(SelectedPageTemplate, StringComparison.OrdinalIgnoreCase));
|
||||
if (pageTemplate is null) return;
|
||||
var canvasLayout = new TemplateLayout(loaded.Layout.Width, loaded.Layout.Height, loaded.Layout.Unit,
|
||||
pageTemplate.Elements);
|
||||
var canvasTemplate = new LoadedTemplate(loaded.Manifest, canvasLayout, loaded.Assets, loaded.SourceName);
|
||||
var bytes = new QuestTemplateRenderer().RenderFirstPageToPng(canvasTemplate, BuildDataProvider());
|
||||
using var stream = new MemoryStream(bytes);
|
||||
_pageTemplatePreview?.Dispose(); _pageTemplatePreview = new Bitmap(stream);
|
||||
SelectedPreviewPage = null; PreviewImage = _pageTemplatePreview;
|
||||
SetStatus($"Bearbeitungsansicht des Seitentyps „{SelectedPageTemplate}“.", false);
|
||||
}
|
||||
|
||||
private void ClearPreviewPages()
|
||||
{
|
||||
_pageTemplatePreview?.Dispose(); _pageTemplatePreview = null;
|
||||
PreviewImage = null; SelectedPreviewPage = null;
|
||||
foreach (var page in PreviewPages) page.Image.Dispose();
|
||||
PreviewPages.Clear();
|
||||
}
|
||||
|
||||
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 Dictionary<string, string> BuildMetadata()
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var item in MetadataItems)
|
||||
{
|
||||
var key = item.Name.Trim(); var value = item.Value.Trim();
|
||||
if (!result.TryAdd(key, value))
|
||||
throw new InvalidDataException($"Metadatenschlüssel „{key}“ ist mehrfach vorhanden.");
|
||||
}
|
||||
TemplateMetadataText.Serialize(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<PlaceholderDefinition> BuildPlaceholders()
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
var result = new List<PlaceholderDefinition>(Placeholders.Count);
|
||||
foreach (var placeholder in Placeholders)
|
||||
{
|
||||
var name = placeholder.Name.Trim();
|
||||
if (name.Length == 0) throw new InvalidDataException("Ein Platzhaltername darf nicht leer sein.");
|
||||
if (!names.Add(name)) throw new InvalidDataException($"Platzhalter „{name}“ ist mehrfach definiert.");
|
||||
result.Add(new(name, placeholder.Type, placeholder.Required, placeholder.IsConstant,
|
||||
placeholder.IsConstant ? placeholder.Sample : null,
|
||||
placeholder.Bold, placeholder.Italic, placeholder.Underline));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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 flowElement = NewElementScope == "Content-Flow";
|
||||
var line = (NewElementType, flowElement) switch
|
||||
{
|
||||
("TEXT", false) => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}",
|
||||
("TEXTBOX", false) => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
|
||||
("FLOWBOX", false) => $"FLOWBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
|
||||
("DRAWBOX", false) => $"DRAWBOX {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
|
||||
("FLOWDRAWBOX", false) => $"FLOWDRAWBOX {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
|
||||
("IMG", false) => $"IMG {NewContent} {NewX} {NewY} {NewWidth} {NewHeight} scale={NormalizedImageScale()}%",
|
||||
("TABLE", false) => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
|
||||
("CHART", false) => $"CHART {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
|
||||
("TEXT", true) => $"TEXT {QuoteIfLiteral(NewContent)}{attrs}",
|
||||
("TEXTBOX", true) => $"TEXTBOX {QuoteIfLiteral(NewContent)}{attrs}",
|
||||
("FLOWBOX", true) => $"FLOWBOX {QuoteIfLiteral(NewContent)}{attrs}",
|
||||
("DRAWBOX", true) => $"DRAWBOX {NewContent} w={NewWidth} h={NewHeight}{attrs}",
|
||||
("FLOWDRAWBOX", true) => $"FLOWDRAWBOX {NewContent} w={NewWidth} h={NewHeight}{attrs}",
|
||||
("IMG", true) => $"IMG {NewContent} w={NewWidth} h={NewHeight} scale={NormalizedImageScale()}%{attrs}",
|
||||
("TABLE", true) => $"TABLE {NewContent}{attrs}",
|
||||
("CHART", true) => $"CHART {NewContent} h={NewHeight}{attrs}",
|
||||
_ => throw new InvalidOperationException("Unbekannter Elementtyp."),
|
||||
};
|
||||
var parsedLayout = new LayoutParser().Parse(LayoutSource);
|
||||
LayoutSource = parsedLayout.UsesPageTemplates
|
||||
? InsertIntoSection(LayoutSource, line, flowElement ? "content-flow" : "page-template",
|
||||
flowElement ? SelectedContentFlow : SelectedPageTemplate)
|
||||
: LayoutSource.TrimEnd() + Environment.NewLine + line + Environment.NewLine;
|
||||
CanExport = false; SetStatus("Element ergänzt. Vorschau zur Prüfung aktualisieren.", false);
|
||||
}
|
||||
|
||||
public void AddPageTemplate()
|
||||
{
|
||||
var name = ValidateSectionName(NewPageTemplateName, "Seitentyp");
|
||||
if (PageTemplateNames.Contains(name, StringComparer.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException($"Seitentyp „{name}“ existiert bereits.");
|
||||
var block = $"#pragma page-template {name}\n#pragma end-page-template\n";
|
||||
var marker = LayoutSource.IndexOf("#pragma content-flow", StringComparison.OrdinalIgnoreCase);
|
||||
LayoutSource = marker < 0 ? LayoutSource.TrimEnd() + "\n\n" + block
|
||||
: LayoutSource.Insert(marker, block + "\n");
|
||||
SelectedPageTemplate = name; CanExport = false;
|
||||
SetStatus($"Seitentyp „{name}“ angelegt.", false);
|
||||
}
|
||||
|
||||
public void AddContentFlow()
|
||||
{
|
||||
var name = ValidateSectionName(NewFlowName, "Flow");
|
||||
if (ContentFlowNames.Contains(name, StringComparer.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException($"Content-Flow „{name}“ existiert bereits.");
|
||||
LayoutSource = LayoutSource.TrimEnd() + $"\n\n#pragma content-flow {name}\n#pragma end-content-flow\n";
|
||||
SelectedContentFlow = name; CanExport = false;
|
||||
SetStatus($"Content-Flow „{name}“ angelegt. Lege nun gleichnamige Slots auf den Seitentypen an.", false);
|
||||
}
|
||||
|
||||
public void AddFlowSlot()
|
||||
{
|
||||
var name = ValidateSectionName(NewFlowName, "Flow-Slot");
|
||||
var line = $"#pragma flow-slot {name} x={NewX} y={NewY} w={NewWidth} h={NewHeight}";
|
||||
LayoutSource = InsertIntoSection(LayoutSource, line, "page-template", SelectedPageTemplate);
|
||||
CanExport = false;
|
||||
SetStatus($"Flow-Slot „{name}“ auf „{SelectedPageTemplate}“ angelegt.", false);
|
||||
}
|
||||
|
||||
private static string ValidateSectionName(string value, string label)
|
||||
{
|
||||
var name = value.Trim();
|
||||
if (name.Length == 0 || name.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '-' or '_')))
|
||||
throw new InvalidDataException($"{label}-Namen dürfen nur Buchstaben, Ziffern, - und _ enthalten.");
|
||||
return name;
|
||||
}
|
||||
|
||||
private static string InsertIntoSection(string source, string line, string section, string name)
|
||||
{
|
||||
var lines = source.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList();
|
||||
var start = lines.FindIndex(x =>
|
||||
{
|
||||
var tokens = x.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
return tokens.Length >= 3 && tokens[0].Equals("#pragma", StringComparison.OrdinalIgnoreCase)
|
||||
&& tokens[1].Equals(section, StringComparison.OrdinalIgnoreCase)
|
||||
&& tokens[2].Equals(name, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
if (start < 0) throw new InvalidDataException($"{section} „{name}“ wurde nicht gefunden.");
|
||||
var endDirective = section == "content-flow" ? "end-content-flow" : "end-page-template";
|
||||
var end = lines.FindIndex(start + 1, x => x.Trim().Equals($"#pragma {endDirective}", StringComparison.OrdinalIgnoreCase));
|
||||
if (end < 0) throw new InvalidDataException($"{section} „{name}“ ist nicht geschlossen.");
|
||||
lines.Insert(end, line);
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public void ApplyMeasurement(OverlayMeasurement measurement)
|
||||
{
|
||||
NewX = FormatNumber(measurement.X); NewY = FormatNumber(measurement.Y);
|
||||
if (measurement.HasArea)
|
||||
{
|
||||
NewWidth = FormatNumber(measurement.Width); NewHeight = FormatNumber(measurement.Height);
|
||||
SetStatus($"Bereich übernommen: x={NewX}, y={NewY}, b={NewWidth}, h={NewHeight} {OverlayPageUnit}.", false);
|
||||
}
|
||||
else SetStatus($"Koordinate übernommen: x={NewX}, y={NewY} {OverlayPageUnit}.", false);
|
||||
}
|
||||
|
||||
public void ApplyElementGeometry(OverlayElementGeometry geometry)
|
||||
{
|
||||
var layout = new LayoutParser().Parse(LayoutSource);
|
||||
if (geometry.Keyword == "FLOW")
|
||||
{
|
||||
var slot = layout.PageTemplates.SelectMany(x => x.FlowSlots)
|
||||
.FirstOrDefault(x => x.Line == geometry.Line)
|
||||
?? throw new InvalidDataException($"Flow-Slot in Zeile {geometry.Line} wurde nicht gefunden.");
|
||||
var slotLines = LayoutSource.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList();
|
||||
slotLines[geometry.Line - 1] = $"#pragma flow-slot {slot.Name} x={FormatNumber(geometry.X)} "
|
||||
+ $"y={FormatNumber(geometry.Y)} w={FormatNumber(geometry.Width)} h={FormatNumber(geometry.Height)}";
|
||||
LayoutSource = string.Join('\n', slotLines); CanExport = false;
|
||||
SelectedOverlayElement = $"Flow {slot.Name} · Zeile {geometry.Line}";
|
||||
SetStatus($"Flow-Slot „{slot.Name}“ verschoben/skaliert.", false);
|
||||
return;
|
||||
}
|
||||
var element = layout.Elements.FirstOrDefault(x => x.Line == geometry.Line)
|
||||
?? throw new InvalidDataException($"Element in Zeile {geometry.Line} wurde nicht gefunden.");
|
||||
var lines = LayoutSource.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList();
|
||||
var index = geometry.Line - 1;
|
||||
if (index < 0 || index >= lines.Count) throw new InvalidDataException("Elementzeile liegt außerhalb des Layouts.");
|
||||
var x = FormatNumber(geometry.X); var y = FormatNumber(geometry.Y);
|
||||
var width = geometry.Width; var height = geometry.Height;
|
||||
lines[index] = element switch
|
||||
{
|
||||
ImageElement image => BuildImageLine(image, x, y, width, height),
|
||||
TextElement text => $"TEXT {x} {y} {Content(text.Content, text.Placeholder, text.Format)}{Attributes(text.Attributes)}",
|
||||
TextBoxElement box => $"TEXTBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
|
||||
+ $"{Content(box.Content, box.Placeholder, box.Format)}{Attributes(box.Attributes)}",
|
||||
FlowBoxElement box => $"FLOWBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
|
||||
+ $"{Content(box.Content, box.Placeholder, box.Format)}{Attributes(box.Attributes)}",
|
||||
DrawBoxElement box => $"DRAWBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
|
||||
+ $"${box.Placeholder}{Attributes(box.Attributes)}",
|
||||
FlowDrawBoxElement box => $"FLOWDRAWBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
|
||||
+ $"${box.Placeholder}{Attributes(box.Attributes)}",
|
||||
TableElement table => $"TABLE {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
|
||||
+ $"${table.Placeholder}{Attributes(table.Attributes)}",
|
||||
ChartElement chart => $"CHART {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
|
||||
+ $"${chart.Placeholder}{Attributes(chart.Attributes)}",
|
||||
_ => lines[index],
|
||||
};
|
||||
LayoutSource = string.Join('\n', lines); CanExport = false;
|
||||
SelectedOverlayElement = $"{geometry.Keyword} · Zeile {geometry.Line}";
|
||||
SetStatus($"{geometry.Keyword} verschoben/skalisiert. PDF-Vorschau wird aktualisiert.", false);
|
||||
}
|
||||
|
||||
partial void OnLayoutSourceChanged(string value)
|
||||
{
|
||||
RefreshAssetUsage();
|
||||
try
|
||||
{
|
||||
var page = new LayoutParser().Parse(value);
|
||||
OverlayPageWidth = page.Width; OverlayPageHeight = page.Height; OverlayPageUnit = page.Unit;
|
||||
var names = page.PageTemplates.Select(x => x.Name).ToList();
|
||||
if (names.Count == 0) names.Add("legacy");
|
||||
var selected = names.Contains(SelectedPageTemplate, StringComparer.OrdinalIgnoreCase)
|
||||
? SelectedPageTemplate : names[0];
|
||||
PageTemplateNames.Clear();
|
||||
foreach (var name in names) PageTemplateNames.Add(name);
|
||||
SelectedPageTemplate = selected;
|
||||
var flows = page.ContentFlows.Select(x => x.Name).ToList();
|
||||
if (flows.Count == 0) flows.Add("body");
|
||||
var selectedFlow = flows.Contains(SelectedContentFlow, StringComparer.OrdinalIgnoreCase)
|
||||
? SelectedContentFlow : flows[0];
|
||||
ContentFlowNames.Clear();
|
||||
foreach (var flow in flows) ContentFlowNames.Add(flow);
|
||||
SelectedContentFlow = selectedFlow;
|
||||
}
|
||||
catch (TemplateValidationException) { }
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private static string BuildImageLine(ImageElement image, string x, string y, double effectiveWidth, double effectiveHeight)
|
||||
{
|
||||
var scale = image.Attributes.TryGetValue("scale", out var raw) ? LayoutParser.Percentage(raw) : 1f;
|
||||
return $"IMG {image.Path} {x} {y} {FormatNumber(effectiveWidth / scale)} "
|
||||
+ $"{FormatNumber(effectiveHeight / scale)}{Attributes(image.Attributes)}";
|
||||
}
|
||||
private static string Content(string original, string? placeholder, string? format) => placeholder is null
|
||||
? $"\"{original.Replace("\"", "\\\"")}\""
|
||||
: $"${placeholder}{(format is null ? "" : "|" + format)}";
|
||||
private static string Attributes(IReadOnlyDictionary<string, string> attributes) => attributes.Count == 0
|
||||
? "" : " " + string.Join(' ', attributes.Select(x => $"{x.Key}={x.Value}"));
|
||||
private static string FormatNumber(double value) => 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 EmptyStructuredLayout = """
|
||||
PAGE 210 297 mm
|
||||
#pragma format-version 3
|
||||
|
||||
#pragma page-template first
|
||||
#pragma flow-slot body x=20 y=30 w=170 h=247
|
||||
#pragma end-page-template
|
||||
|
||||
#pragma page-template continuation
|
||||
#pragma flow-slot body x=20 y=20 w=170 h=257
|
||||
#pragma end-page-template
|
||||
|
||||
#pragma content-flow body
|
||||
#pragma end-content-flow
|
||||
""";
|
||||
|
||||
private const string DefaultLayout = """
|
||||
# Elternbrief Standard · Format 3
|
||||
PAGE 210 297 mm
|
||||
#pragma format-version 3
|
||||
|
||||
#pragma page-template first
|
||||
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
|
||||
#pragma flow-slot body x=20 y=90 w=170 h=175
|
||||
#pragma end-page-template
|
||||
|
||||
#pragma page-template continuation
|
||||
#pragma flow-slot body x=20 y=25 w=170 h=240
|
||||
#pragma end-page-template
|
||||
|
||||
#pragma content-flow body
|
||||
TEXTBOX $Brieftext size=11 overflow=continue
|
||||
TEXT "Mit freundlichen Grüßen" size=10 gap=8 keep-with-next=true
|
||||
TEXT $LehrerName size=10 italic=true gap=4
|
||||
#pragma end-content-flow
|
||||
""";
|
||||
}
|
||||
|
||||
public partial class DesignerMetadata(string name, string value) : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private string _name = name;
|
||||
[ObservableProperty] private string _value = value;
|
||||
}
|
||||
|
||||
public sealed record DesignerPreviewPage(int Number, Bitmap Image)
|
||||
{
|
||||
public string Display => $"Dokumentseite {Number}";
|
||||
}
|
||||
|
||||
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;
|
||||
[ObservableProperty] private bool _isConstant;
|
||||
[ObservableProperty] private bool _bold;
|
||||
[ObservableProperty] private bool _italic;
|
||||
[ObservableProperty] private bool _underline;
|
||||
public bool SupportsConstantValue => Type is PlaceholderType.Text or PlaceholderType.Multiline
|
||||
or PlaceholderType.Date or PlaceholderType.Number;
|
||||
public bool SupportsRichText => IsConstant && Type is PlaceholderType.Text or PlaceholderType.Multiline;
|
||||
|
||||
public DesignerPlaceholder(string name, PlaceholderType type, bool required, string sample,
|
||||
bool isConstant = false, bool bold = false, bool italic = false, bool underline = false)
|
||||
{
|
||||
_name = name; _type = type; _required = required; _sample = sample;
|
||||
_isConstant = isConstant; _bold = bold; _italic = italic; _underline = underline;
|
||||
}
|
||||
|
||||
partial void OnTypeChanged(PlaceholderType value)
|
||||
{
|
||||
OnPropertyChanged(nameof(SupportsConstantValue));
|
||||
OnPropertyChanged(nameof(SupportsRichText));
|
||||
if (!SupportsConstantValue) IsConstant = false;
|
||||
}
|
||||
|
||||
partial void OnIsConstantChanged(bool value) => OnPropertyChanged(nameof(SupportsRichText));
|
||||
|
||||
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),
|
||||
PlaceholderType.Drawing => SampleDrawing(),
|
||||
_ => 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",
|
||||
PlaceholderType.Drawing => "Externe Zeichenbefehle", _ => "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())]);
|
||||
private static DrawingValue SampleDrawing() => new DrawingValue(
|
||||
[new DrawRectangle(0, 0, 80, 24, "#2563EB", 0.8f, "#EFF6FF"),
|
||||
new DrawString(4, 4, "Dynamischer Inhalt", 10, "#1E3A8A", Bold: true),
|
||||
new MoveTo(4, 19), new LineTo(76, 19, "#93C5FD", 0.6f)], 24);
|
||||
}
|
||||
|
||||
internal sealed class DesignerDataProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
|
||||
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }
|
||||
@@ -0,0 +1,292 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner;
|
||||
|
||||
public enum OverlayEditorMode { Measure, Edit }
|
||||
|
||||
public sealed record OverlayMeasurement(double X, double Y, double Width, double Height, bool HasArea);
|
||||
public sealed record OverlayElementGeometry(int Line, string Keyword, double X, double Y, double Width, double Height);
|
||||
|
||||
public sealed class LayoutOverlayEditor : Control
|
||||
{
|
||||
public static readonly StyledProperty<Bitmap?> PreviewImageProperty =
|
||||
AvaloniaProperty.Register<LayoutOverlayEditor, Bitmap?>(nameof(PreviewImage));
|
||||
public static readonly StyledProperty<string> LayoutSourceProperty =
|
||||
AvaloniaProperty.Register<LayoutOverlayEditor, string>(nameof(LayoutSource), "");
|
||||
public static readonly StyledProperty<double> PageWidthProperty =
|
||||
AvaloniaProperty.Register<LayoutOverlayEditor, double>(nameof(PageWidth), 210);
|
||||
public static readonly StyledProperty<double> PageHeightProperty =
|
||||
AvaloniaProperty.Register<LayoutOverlayEditor, double>(nameof(PageHeight), 297);
|
||||
public static readonly StyledProperty<string> PageUnitProperty =
|
||||
AvaloniaProperty.Register<LayoutOverlayEditor, string>(nameof(PageUnit), "mm");
|
||||
public static readonly StyledProperty<OverlayEditorMode> ModeProperty =
|
||||
AvaloniaProperty.Register<LayoutOverlayEditor, OverlayEditorMode>(nameof(Mode), OverlayEditorMode.Measure);
|
||||
public static readonly StyledProperty<bool> SnapToGridProperty =
|
||||
AvaloniaProperty.Register<LayoutOverlayEditor, bool>(nameof(SnapToGrid), true);
|
||||
public static readonly StyledProperty<double> GridSizeProperty =
|
||||
AvaloniaProperty.Register<LayoutOverlayEditor, double>(nameof(GridSize), 1);
|
||||
public static readonly StyledProperty<string> PageTemplateNameProperty =
|
||||
AvaloniaProperty.Register<LayoutOverlayEditor, string>(nameof(PageTemplateName), "first");
|
||||
|
||||
private readonly Pen _normalPen = new(new SolidColorBrush(Color.Parse("#2563EB")), 1.5);
|
||||
private readonly Pen _selectedPen = new(new SolidColorBrush(Color.Parse("#DC2626")), 2.5);
|
||||
private readonly Pen _measurePen = new(new SolidColorBrush(Color.Parse("#D97706")), 2);
|
||||
private readonly Pen _gridPen = new(new SolidColorBrush(Color.FromArgb(55, 37, 99, 235)), 1);
|
||||
private readonly Pen _flowPen = new(new SolidColorBrush(Color.Parse("#7C3AED")), 2, dashStyle: DashStyle.Dash);
|
||||
private readonly IBrush _normalFill = new SolidColorBrush(Color.FromArgb(30, 37, 99, 235));
|
||||
private readonly IBrush _selectedFill = new SolidColorBrush(Color.FromArgb(35, 220, 38, 38));
|
||||
private readonly IBrush _handleFill = new SolidColorBrush(Color.Parse("#DC2626"));
|
||||
private readonly IBrush _flowFill = new SolidColorBrush(Color.FromArgb(38, 124, 58, 237));
|
||||
private readonly List<OverlayItem> _items = [];
|
||||
private OverlayItem? _selected;
|
||||
private Point? _pointerStartDsl;
|
||||
private Rect? _workingRectDsl;
|
||||
private DragKind _dragKind;
|
||||
|
||||
public Bitmap? PreviewImage { get => GetValue(PreviewImageProperty); set => SetValue(PreviewImageProperty, value); }
|
||||
public string LayoutSource { get => GetValue(LayoutSourceProperty); set => SetValue(LayoutSourceProperty, value); }
|
||||
public double PageWidth { get => GetValue(PageWidthProperty); set => SetValue(PageWidthProperty, value); }
|
||||
public double PageHeight { get => GetValue(PageHeightProperty); set => SetValue(PageHeightProperty, value); }
|
||||
public string PageUnit { get => GetValue(PageUnitProperty); set => SetValue(PageUnitProperty, value); }
|
||||
public OverlayEditorMode Mode { get => GetValue(ModeProperty); set => SetValue(ModeProperty, value); }
|
||||
public bool SnapToGrid { get => GetValue(SnapToGridProperty); set => SetValue(SnapToGridProperty, value); }
|
||||
public double GridSize { get => GetValue(GridSizeProperty); set => SetValue(GridSizeProperty, value); }
|
||||
public string PageTemplateName { get => GetValue(PageTemplateNameProperty); set => SetValue(PageTemplateNameProperty, value); }
|
||||
|
||||
public event EventHandler<OverlayMeasurement>? MeasurementCompleted;
|
||||
public event EventHandler<OverlayElementGeometry>? ElementGeometryChanged;
|
||||
public event EventHandler<string>? CursorCoordinatesChanged;
|
||||
public event EventHandler<string>? ElementSelected;
|
||||
|
||||
static LayoutOverlayEditor()
|
||||
{
|
||||
AffectsRender<LayoutOverlayEditor>(PreviewImageProperty, LayoutSourceProperty, PageWidthProperty,
|
||||
PageHeightProperty, PageUnitProperty, ModeProperty, SnapToGridProperty, GridSizeProperty,
|
||||
PageTemplateNameProperty);
|
||||
}
|
||||
|
||||
public LayoutOverlayEditor() { Focusable = true; ClipToBounds = true; }
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
base.Render(context);
|
||||
var page = PageRect();
|
||||
context.FillRectangle(Brushes.White, page);
|
||||
if (PreviewImage is { } image)
|
||||
context.DrawImage(image, new Rect(image.Size), page);
|
||||
DrawGrid(context, page);
|
||||
RefreshItems();
|
||||
foreach (var item in _items)
|
||||
{
|
||||
var geometry = ReferenceEquals(item, _selected) && _workingRectDsl is { } working ? working : item.Rect;
|
||||
var rect = ToControl(geometry, page);
|
||||
var selected = ReferenceEquals(item, _selected);
|
||||
var fill = selected ? _selectedFill : item.IsFlowSlot ? _flowFill : _normalFill;
|
||||
var pen = selected ? _selectedPen : item.IsFlowSlot ? _flowPen : _normalPen;
|
||||
context.DrawRectangle(fill, pen,
|
||||
rect, 2, 2);
|
||||
if (selected && item.Resizable)
|
||||
context.FillRectangle(_handleFill, new Rect(rect.Right - 6, rect.Bottom - 6, 12, 12), 2);
|
||||
}
|
||||
if (Mode == OverlayEditorMode.Measure && _workingRectDsl is { } measurement)
|
||||
context.DrawRectangle(null, _measurePen, ToControl(measurement, page), 2, 2);
|
||||
}
|
||||
|
||||
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||
{
|
||||
base.OnPointerPressed(e);
|
||||
var point = e.GetPosition(this);
|
||||
if (!PageRect().Contains(point)) return;
|
||||
Focus(); e.Pointer.Capture(this);
|
||||
var dsl = Snap(ToDsl(point));
|
||||
_pointerStartDsl = dsl;
|
||||
if (Mode == OverlayEditorMode.Measure)
|
||||
{
|
||||
_selected = null; _dragKind = DragKind.Measure;
|
||||
_workingRectDsl = new Rect(dsl, dsl); InvalidateVisual(); return;
|
||||
}
|
||||
|
||||
RefreshItems();
|
||||
_selected = _items.LastOrDefault(item => ToControl(item.Rect, PageRect()).Inflate(4).Contains(point));
|
||||
if (_selected is null) { _workingRectDsl = null; InvalidateVisual(); return; }
|
||||
_workingRectDsl = _selected.Rect;
|
||||
var selectedControl = ToControl(_selected.Rect, PageRect());
|
||||
_dragKind = _selected.Resizable && new Point(selectedControl.Right, selectedControl.Bottom).Distance(point) <= 14
|
||||
? DragKind.Resize : DragKind.Move;
|
||||
ElementSelected?.Invoke(this, $"{_selected.Keyword} · Zeile {_selected.Line}");
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
protected override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerMoved(e);
|
||||
var point = e.GetPosition(this); var page = PageRect();
|
||||
if (page.Contains(point))
|
||||
{
|
||||
var coordinate = ToDsl(point);
|
||||
CursorCoordinatesChanged?.Invoke(this,
|
||||
$"x={coordinate.X:0.##} · y={coordinate.Y:0.##} {PageUnit}");
|
||||
}
|
||||
if (_pointerStartDsl is not { } start || _dragKind == DragKind.None) return;
|
||||
var current = Snap(ToDsl(Clamp(point, page)));
|
||||
if (_dragKind == DragKind.Measure)
|
||||
_workingRectDsl = Normalize(start, current);
|
||||
else if (_selected is not null)
|
||||
{
|
||||
var source = _selected.Rect;
|
||||
if (_dragKind == DragKind.Move)
|
||||
{
|
||||
var x = Math.Clamp(source.X + current.X - start.X, 0, Math.Max(0, PageWidth - source.Width));
|
||||
var y = Math.Clamp(source.Y + current.Y - start.Y, 0, Math.Max(0, PageHeight - source.Height));
|
||||
_workingRectDsl = new Rect(x, y, source.Width, source.Height);
|
||||
}
|
||||
else
|
||||
{
|
||||
var width = Math.Max(GridStep(), source.Width + current.X - start.X);
|
||||
var height = Math.Max(GridStep(), source.Height + current.Y - start.Y);
|
||||
_workingRectDsl = new Rect(source.X, source.Y,
|
||||
Math.Min(width, PageWidth - source.X), Math.Min(height, PageHeight - source.Y));
|
||||
}
|
||||
}
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
||||
{
|
||||
base.OnPointerReleased(e); e.Pointer.Capture(null);
|
||||
if (_workingRectDsl is { } result)
|
||||
{
|
||||
if (_dragKind == DragKind.Measure)
|
||||
{
|
||||
var hasArea = result.Width >= GridStep() / 2 && result.Height >= GridStep() / 2;
|
||||
MeasurementCompleted?.Invoke(this, new(result.X, result.Y, result.Width, result.Height, hasArea));
|
||||
}
|
||||
else if (_selected is not null)
|
||||
ElementGeometryChanged?.Invoke(this, new(_selected.Line, _selected.Keyword,
|
||||
result.X, result.Y, result.Width, result.Height));
|
||||
}
|
||||
_pointerStartDsl = null; _dragKind = DragKind.None;
|
||||
if (Mode == OverlayEditorMode.Edit) _workingRectDsl = null;
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
base.OnKeyDown(e);
|
||||
if (Mode != OverlayEditorMode.Edit || _selected is null) return;
|
||||
var step = GridStep(); var dx = 0d; var dy = 0d;
|
||||
switch (e.Key)
|
||||
{ case Key.Left: dx = -step; break; case Key.Right: dx = step; break;
|
||||
case Key.Up: dy = -step; break; case Key.Down: dy = step; break; default: return; }
|
||||
var source = _selected.Rect;
|
||||
var rect = new Rect(Math.Clamp(source.X + dx, 0, Math.Max(0, PageWidth - source.Width)),
|
||||
Math.Clamp(source.Y + dy, 0, Math.Max(0, PageHeight - source.Height)), source.Width, source.Height);
|
||||
ElementGeometryChanged?.Invoke(this, new(_selected.Line, _selected.Keyword, rect.X, rect.Y, rect.Width, rect.Height));
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void RefreshItems()
|
||||
{
|
||||
_items.Clear();
|
||||
try
|
||||
{
|
||||
var layout = new LayoutParser().Parse(LayoutSource);
|
||||
var pageTemplate = layout.PageTemplates.FirstOrDefault(x =>
|
||||
x.Name.Equals(PageTemplateName, StringComparison.OrdinalIgnoreCase))
|
||||
?? layout.PageTemplates.FirstOrDefault();
|
||||
var visibleElements = pageTemplate?.Elements ?? layout.Elements;
|
||||
foreach (var element in visibleElements)
|
||||
{
|
||||
if (element is BackgroundElement) continue;
|
||||
var keyword = element switch
|
||||
{ ImageElement => "IMG", TextElement => "TEXT", TextBoxElement => "TEXTBOX", FlowBoxElement => "FLOWBOX",
|
||||
DrawBoxElement => "DRAWBOX", FlowDrawBoxElement => "FLOWDRAWBOX",
|
||||
TableElement => "TABLE", ChartElement => "CHART", _ => "?" };
|
||||
var (width, height, resizable) = element switch
|
||||
{
|
||||
TextElement text => (Math.Min(70d, Math.Max(5, PageWidth - text.X)),
|
||||
Math.Max(3, PointsToUnit(ParseSize(text.Attributes) * 1.8)), false),
|
||||
ImageElement image => (image.Width * ImageScale(image), image.Height * ImageScale(image), true),
|
||||
_ => ((double)element.Width, element.Height, true),
|
||||
};
|
||||
_items.Add(new(element.Line, keyword, new Rect(element.X, element.Y, width, height), resizable, false));
|
||||
}
|
||||
if (pageTemplate is not null)
|
||||
foreach (var slot in pageTemplate.FlowSlots)
|
||||
_items.Add(new(slot.Line, "FLOW", new Rect(slot.X, slot.Y, slot.Width, slot.Height), true, true));
|
||||
if (_selected is not null)
|
||||
_selected = _items.FirstOrDefault(x => x.Line == _selected.Line);
|
||||
}
|
||||
catch (TemplateValidationException) { _selected = null; }
|
||||
}
|
||||
|
||||
private void DrawGrid(DrawingContext context, Rect page)
|
||||
{
|
||||
if (!SnapToGrid || GridSize <= 0) return;
|
||||
var xStep = page.Width * GridSize / PageWidth; var yStep = page.Height * GridSize / PageHeight;
|
||||
if (xStep < 5 || yStep < 5) return;
|
||||
for (var x = page.X + xStep; x < page.Right; x += xStep)
|
||||
context.DrawLine(_gridPen, new(x, page.Y), new(x, page.Bottom));
|
||||
for (var y = page.Y + yStep; y < page.Bottom; y += yStep)
|
||||
context.DrawLine(_gridPen, new(page.X, y), new(page.Right, y));
|
||||
}
|
||||
|
||||
private Rect PageRect()
|
||||
{
|
||||
return OverlayCoordinateMapper.PageRect(Bounds.Size, PageWidth, PageHeight);
|
||||
}
|
||||
private Rect ToControl(Rect rect, Rect page) => OverlayCoordinateMapper.ToControl(rect, page, PageWidth, PageHeight);
|
||||
private Point ToDsl(Point point) => OverlayCoordinateMapper.ToDsl(point, PageRect(), PageWidth, PageHeight);
|
||||
private Point Snap(Point point)
|
||||
{
|
||||
if (!SnapToGrid) return point;
|
||||
var step = GridStep(); return new(Math.Round(point.X / step) * step, Math.Round(point.Y / step) * step);
|
||||
}
|
||||
private double GridStep() => SnapToGrid && GridSize > 0 ? GridSize : 0.5;
|
||||
private static Rect Normalize(Point first, Point second) => new(Math.Min(first.X, second.X), Math.Min(first.Y, second.Y),
|
||||
Math.Abs(second.X - first.X), Math.Abs(second.Y - first.Y));
|
||||
private static Point Clamp(Point point, Rect rect) => new(Math.Clamp(point.X, rect.X, rect.Right), Math.Clamp(point.Y, rect.Y, rect.Bottom));
|
||||
private static float ParseSize(IReadOnlyDictionary<string, string> attributes) =>
|
||||
attributes.TryGetValue("size", out var raw) && float.TryParse(raw,
|
||||
System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var size) ? size : 11;
|
||||
private static float ImageScale(ImageElement image) => image.Attributes.TryGetValue("scale", out var raw)
|
||||
? LayoutParser.Percentage(raw) : 1;
|
||||
private double PointsToUnit(double points) => PageUnit.ToLowerInvariant() switch
|
||||
{ "mm" => points * 25.4 / 72, "cm" => points * 2.54 / 72, "in" => points / 72, _ => points };
|
||||
|
||||
private sealed record OverlayItem(int Line, string Keyword, Rect Rect, bool Resizable, bool IsFlowSlot);
|
||||
private enum DragKind { None, Measure, Move, Resize }
|
||||
}
|
||||
|
||||
public static class OverlayCoordinateMapper
|
||||
{
|
||||
public static Rect PageRect(Size viewport, double pageWidth, double pageHeight)
|
||||
{
|
||||
if (pageWidth <= 0 || pageHeight <= 0 || viewport.Width <= 0 || viewport.Height <= 0)
|
||||
return new Rect(viewport);
|
||||
var scale = Math.Min(viewport.Width / pageWidth, viewport.Height / pageHeight);
|
||||
var width = pageWidth * scale; var height = pageHeight * scale;
|
||||
return new((viewport.Width - width) / 2, (viewport.Height - height) / 2, width, height);
|
||||
}
|
||||
|
||||
public static Point ToDsl(Point point, Rect displayedPage, double pageWidth, double pageHeight) =>
|
||||
new((point.X - displayedPage.X) / displayedPage.Width * pageWidth,
|
||||
(point.Y - displayedPage.Y) / displayedPage.Height * pageHeight);
|
||||
|
||||
public static Rect ToControl(Rect dslRect, Rect displayedPage, double pageWidth, double pageHeight) =>
|
||||
new(displayedPage.X + dslRect.X / pageWidth * displayedPage.Width,
|
||||
displayedPage.Y + dslRect.Y / pageHeight * displayedPage.Height,
|
||||
dslRect.Width / pageWidth * displayedPage.Width,
|
||||
dslRect.Height / pageHeight * displayedPage.Height);
|
||||
}
|
||||
|
||||
internal static class PointDistanceExtensions
|
||||
{
|
||||
public static double Distance(this Point point, Point other) =>
|
||||
Math.Sqrt(Math.Pow(point.X - other.X, 2) + Math.Pow(point.Y - other.Y, 2));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<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" />
|
||||
<PackageReference Include="PdfPig" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,324 @@
|
||||
<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,Auto,*">
|
||||
<Menu>
|
||||
<MenuItem Header="_Datei">
|
||||
<MenuItem Header="Neu (leeres Projekt)" InputGesture="Ctrl+N" Click="OnNew"/>
|
||||
<MenuItem Header="Öffnen …" InputGesture="Ctrl+O" Click="OnOpenPackage"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Speichern" InputGesture="Ctrl+S" Click="OnSave"/>
|
||||
<MenuItem Header="Speichern unter …" InputGesture="Ctrl+Shift+S" Click="OnSaveAs"/>
|
||||
<MenuItem Header="Aktuelle Ansicht als PDF exportieren …" Click="OnExportCurrentPdf"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Ausgangsvorlage importieren …" Click="OnImportStarterTemplate"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Beenden" Click="OnExit"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Vorlage">
|
||||
<MenuItem Header="Prüfen und Vorschau" InputGesture="F5" Click="OnPreview"/>
|
||||
<MenuItem Header="Aktuelles Projekt als Ausgangsvorlage speichern" Click="OnSaveStarterTemplate"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Ausgewählte Ausgangsvorlage bearbeiten" Click="OnEditStarterTemplate"/>
|
||||
<MenuItem Header="Ausgewählte Ausgangsvorlage als neues Projekt" Click="OnUseStarterTemplate"/>
|
||||
<MenuItem Header="Ausgewählte Ausgangsvorlage duplizieren" Click="OnDuplicateStarterTemplate"/>
|
||||
<MenuItem Header="Ausgewählte Ausgangsvorlage exportieren …" Click="OnExportStarterTemplate"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Einfügen">
|
||||
<MenuItem Header="PDF als Vorlage importieren …" Click="OnImportPdfTemplate"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Bild-Asset …" Click="OnImportAsset"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="PNG/JPEG als Hintergrund …" Click="OnImportBackground"/>
|
||||
<MenuItem Header="PDF als Hintergrund …" Click="OnImportPdfBackground"/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<Border Grid.Row="1" Padding="18,12" Background="#172554">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel><TextBlock Text="Vorlagen-Designer" Foreground="White" FontWeight="Bold" FontSize="20"/>
|
||||
<TextBlock Text="Portable PDF-Briefpakete · Layoutformat 3" Foreground="#BFDBFE" FontSize="12"/></StackPanel>
|
||||
<StackPanel Grid.Column="1" HorizontalAlignment="Right">
|
||||
<TextBlock x:Name="DocumentNameText" Foreground="White" FontWeight="SemiBold" HorizontalAlignment="Right"/>
|
||||
<TextBlock x:Name="DocumentPathText" Foreground="#BFDBFE" FontSize="11" HorizontalAlignment="Right"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid Grid.Row="2" ColumnDefinitions="460,*,390">
|
||||
<TabControl Grid.Column="0" Margin="10">
|
||||
<TabItem Header="Projekt">
|
||||
<ScrollViewer Padding="8">
|
||||
<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="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>
|
||||
<Separator/>
|
||||
<TextBlock Text="Paketdaten" 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="Freie Metadaten" Classes="section"/>
|
||||
<Button Grid.Column="1" Content="+" Click="OnAddMetadata"/></Grid>
|
||||
<DataGrid ItemsSource="{Binding MetadataItems}" SelectedItem="{Binding SelectedMetadata}"
|
||||
AutoGenerateColumns="False" Height="150" CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Name" Binding="{Binding Name, Mode=TwoWay}" Width="*"/>
|
||||
<DataGridTextColumn Header="Wert" Binding="{Binding Value, Mode=TwoWay}" Width="1.4*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<Button Content="Markierten Metadateneintrag entfernen" HorizontalAlignment="Left" Click="OnRemoveMetadata"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Platzhalter">
|
||||
<Grid Margin="8" RowDefinitions="Auto,*,Auto,Auto">
|
||||
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Platzhalter" Classes="section" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="+ Neu" Click="OnAddPlaceholder"/></Grid>
|
||||
<DataGrid Grid.Row="1" Margin="0,10" ItemsSource="{Binding Placeholders}"
|
||||
SelectedItem="{Binding SelectedPlaceholder, Mode=TwoWay}" AutoGenerateColumns="False"
|
||||
CanUserResizeColumns="True" IsReadOnly="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="*"/>
|
||||
<DataGridTextColumn Header="Typ" Binding="{Binding Type}" Width="105"/>
|
||||
<DataGridCheckBoxColumn Header="Pflicht" Binding="{Binding Required}" Width="65"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<TextBlock Grid.Row="2" IsVisible="{Binding HasNoSelectedPlaceholder}"
|
||||
Text="Wähle einen Platzhalter aus oder lege einen neuen an." Opacity="0.65"
|
||||
TextWrapping="Wrap" Margin="0,4,0,10"/>
|
||||
<Border Grid.Row="3" IsVisible="{Binding HasSelectedPlaceholder}" Padding="12"
|
||||
BorderBrush="#CBD5E1" BorderThickness="1" CornerRadius="4">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Auswahl bearbeiten" FontWeight="SemiBold"/>
|
||||
<Grid ColumnDefinitions="*,10,*">
|
||||
<StackPanel><TextBlock Text="Name" Classes="label"/>
|
||||
<TextBox Text="{Binding SelectedPlaceholder.Name, Mode=TwoWay}" PlaceholderText="z. B. Empfaenger"/></StackPanel>
|
||||
<StackPanel Grid.Column="2"><TextBlock Text="Datentyp" Classes="label"/>
|
||||
<ComboBox ItemsSource="{Binding PlaceholderTypes}" SelectedItem="{Binding SelectedPlaceholder.Type, Mode=TwoWay}"/></StackPanel>
|
||||
</Grid>
|
||||
<CheckBox Content="Pflichtwert" IsChecked="{Binding SelectedPlaceholder.Required, Mode=TwoWay}"/>
|
||||
<CheckBox IsChecked="{Binding SelectedPlaceholder.IsConstant, Mode=TwoWay}"
|
||||
IsEnabled="{Binding SelectedPlaceholder.SupportsConstantValue}">
|
||||
<TextBlock Text="Beispielwert fest im Paket verwenden (konstant, extern nicht überschreibbar)"
|
||||
TextWrapping="Wrap"/>
|
||||
</CheckBox>
|
||||
<StackPanel><TextBlock Text="Beispielwert / konstanter Paketwert" Classes="label"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,3,0,5">
|
||||
<Button Content="F" FontWeight="Bold" Padding="10,3" Click="OnBoldSelection"
|
||||
IsEnabled="{Binding SelectedPlaceholder.SupportsRichText}" ToolTip.Tip="Markierten Text fett setzen"/>
|
||||
<Button Content="K" FontStyle="Italic" Padding="10,3" Click="OnItalicSelection"
|
||||
IsEnabled="{Binding SelectedPlaceholder.SupportsRichText}" ToolTip.Tip="Markierten Text kursiv setzen"/>
|
||||
<Button Content="U̲" Padding="10,3" Click="OnUnderlineSelection"
|
||||
IsEnabled="{Binding SelectedPlaceholder.SupportsRichText}" ToolTip.Tip="Markierten Text unterstreichen"/>
|
||||
</StackPanel>
|
||||
<TextBox x:Name="PlaceholderValueTextBox" Text="{Binding SelectedPlaceholder.Sample, Mode=TwoWay}" AcceptsReturn="True"
|
||||
MinHeight="68" MaxHeight="120" TextWrapping="Wrap" PlaceholderText="Beispieldaten eingeben"/></StackPanel>
|
||||
<TextBlock IsVisible="{Binding SelectedPlaceholder.SupportsRichText}"
|
||||
Text="Externe Werte im Text: ${Student.LastName}. Markup: [b]fett[/b], [i]kursiv[/i], [u]unterstrichen[/u]."
|
||||
FontSize="11" Opacity="0.65" TextWrapping="Wrap"/>
|
||||
<StackPanel>
|
||||
<TextBlock Text="Hervorhebung bei direkter Verwendung in TEXT/TEXTBOX" Classes="label"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="14">
|
||||
<CheckBox Content="Fett" IsChecked="{Binding SelectedPlaceholder.Bold, Mode=TwoWay}"/>
|
||||
<CheckBox Content="Kursiv" IsChecked="{Binding SelectedPlaceholder.Italic, Mode=TwoWay}"/>
|
||||
<CheckBox Content="Unterstrichen" IsChecked="{Binding SelectedPlaceholder.Underline, Mode=TwoWay}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<Button Content="Platzhalter entfernen" HorizontalAlignment="Left" Click="OnRemovePlaceholder"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Bilder">
|
||||
<Grid Margin="8" RowDefinitions="Auto,*,Auto,Auto">
|
||||
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Paket-Assets" Classes="section" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="+ Importieren …" Click="OnImportAsset"/></Grid>
|
||||
<DataGrid Grid.Row="1" Margin="0,10" ItemsSource="{Binding AssetItems}" SelectedItem="{Binding SelectedAsset}"
|
||||
AutoGenerateColumns="False" IsReadOnly="True" CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Datei" Binding="{Binding Name}" Width="*"/>
|
||||
<DataGridTextColumn Header="Auflösung" Binding="{Binding Dimensions}" Width="110"/>
|
||||
<DataGridTextColumn Header="Verwendung" Binding="{Binding Usage}" Width="105"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<Grid Grid.Row="2" 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>
|
||||
<StackPanel Grid.Row="3" Margin="0,14,0,0" Spacing="8">
|
||||
<TextBlock Text="Seitenhintergrund" Classes="section"/>
|
||||
<Button Content="PNG/JPEG als Hintergrund importieren …" Click="OnImportBackground"/>
|
||||
<Button Content="PDF als Hintergrund (300 DPI) importieren …" Click="OnImportPdfBackground"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Seiten & Flows">
|
||||
<ScrollViewer Padding="8">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Text="Seitentypen" Classes="section"/>
|
||||
<TextBlock Text="Jeder Seitentyp beschreibt nur seine eigenen festen Elemente und Flow-Slots."
|
||||
TextWrapping="Wrap" Opacity="0.7" FontSize="12"/>
|
||||
<ComboBox ItemsSource="{Binding PageTemplateNames}" SelectedItem="{Binding SelectedPageTemplate, Mode=TwoWay}"/>
|
||||
<Grid ColumnDefinitions="*,8,Auto">
|
||||
<TextBox Text="{Binding NewPageTemplateName}" PlaceholderText="z. B. continuation"/>
|
||||
<Button Grid.Column="2" Content="Seitentyp anlegen" Click="OnAddPageTemplate"/>
|
||||
</Grid>
|
||||
<Separator/>
|
||||
<TextBlock Text="Content-Flows" Classes="section"/>
|
||||
<TextBlock Text="Ein Flow läuft automatisch in den gleichnamigen Slot der ersten und anschließend der Folgeseiten."
|
||||
TextWrapping="Wrap" Opacity="0.7" FontSize="12"/>
|
||||
<ComboBox ItemsSource="{Binding ContentFlowNames}" SelectedItem="{Binding SelectedContentFlow, Mode=TwoWay}"/>
|
||||
<Grid ColumnDefinitions="*,8,Auto">
|
||||
<TextBox Text="{Binding NewFlowName}" PlaceholderText="z. B. body"/>
|
||||
<Button Grid.Column="2" Content="Flow anlegen" Click="OnAddContentFlow"/>
|
||||
</Grid>
|
||||
<Separator/>
|
||||
<TextBlock Text="Flow-Slot auf ausgewählter Seite" Classes="section"/>
|
||||
<TextBlock Text="Die Bounding Box wird rechts violett gestrichelt dargestellt und kann dort verschoben und skaliert werden."
|
||||
TextWrapping="Wrap" Opacity="0.7" FontSize="12"/>
|
||||
<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>
|
||||
<Button Content="Flow-Slot anlegen" HorizontalAlignment="Left" Click="OnAddFlowSlot"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Elemente">
|
||||
<ScrollViewer Padding="8">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Element hinzufügen" Classes="section"/>
|
||||
<StackPanel><TextBlock Text="Einfügen in" Classes="label"/>
|
||||
<ComboBox ItemsSource="{Binding ElementScopes}" SelectedItem="{Binding NewElementScope}"/></StackPanel>
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<StackPanel><TextBlock Text="Seitentyp für feste Elemente" Classes="label"/>
|
||||
<ComboBox ItemsSource="{Binding PageTemplateNames}" SelectedItem="{Binding SelectedPageTemplate}"/></StackPanel>
|
||||
<StackPanel Grid.Column="2"><TextBlock Text="Content-Flow für fließende Elemente" Classes="label"/>
|
||||
<ComboBox ItemsSource="{Binding ContentFlowNames}" SelectedItem="{Binding SelectedContentFlow}"/></StackPanel>
|
||||
</Grid>
|
||||
<StackPanel><TextBlock Text="Elementtyp" Classes="label"/>
|
||||
<ComboBox ItemsSource="{Binding ElementTypes}" SelectedItem="{Binding NewElementType}"/></StackPanel>
|
||||
<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"/>
|
||||
<TextBlock Text="Tipp: Koordinaten können rechts im Messmodus direkt aus der Vorschau übernommen werden."
|
||||
TextWrapping="Wrap" Opacity="0.65" FontSize="11"/>
|
||||
<TextBlock Text="Elemente im Content-Flow verwenden Reihenfolge, gap und keep-with-next; ihre X/Y-Koordinaten werden nicht benötigt."
|
||||
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
|
||||
<TextBlock Text="FLOWBOX verteilt Text automatisch über beliebig viele Seiten. TEXTBOX bleibt ein fester Bereich."
|
||||
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
|
||||
<TextBlock Text="Systemvariablen ohne Deklaration: $$today, $$curPage und $$maxPageNum (z. B. "Seite $$curPage von $$maxPageNum")."
|
||||
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
|
||||
<TextBlock Text="DRAWBOX und FLOWDRAWBOX nehmen deklarative Zeichenbefehle (Text, Linien, Rechtecke, Bilder) aus einer externen App entgegen."
|
||||
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,*" Margin="0,18">
|
||||
<Grid ColumnDefinitions="*,Auto,8,Auto" Margin="12,0,12,10"><TextBlock Text="Layout-DSL" Classes="section"/>
|
||||
<Button Grid.Column="1" Content="Prüfen & Vorschau" Click="OnPreview"/>
|
||||
<Button Grid.Column="3" Content="Als PDF exportieren …" Click="OnExportCurrentPdf"/></Grid>
|
||||
<TabControl Grid.Row="1" Margin="12">
|
||||
<TabItem Header="Seite 1">
|
||||
<TextBox Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap"
|
||||
FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
||||
</TabItem>
|
||||
<TabItem Header="Folgeseiten">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<CheckBox Margin="8" Content="Eigenes Layout für Seite 2 und alle weiteren Seiten im Paket speichern"
|
||||
IsChecked="{Binding UseContinuationLayout}"/>
|
||||
<TextBox Grid.Row="1" Text="{Binding ContinuationLayoutSource}" IsEnabled="{Binding UseContinuationLayout}"
|
||||
AcceptsReturn="True" TextWrapping="NoWrap" FontFamily="Menlo,Consolas,monospace" FontSize="13"
|
||||
VerticalContentAlignment="Top" ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Column="2" Background="#E2E8F0" Padding="18">
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel><TextBlock Text="Visueller Layout-Editor" Classes="section"/>
|
||||
<ComboBox ItemsSource="{Binding PageTemplateNames}" SelectedItem="{Binding SelectedPageTemplate, Mode=TwoWay}"
|
||||
MinWidth="150" Margin="0,5,0,0"/>
|
||||
<Button Content="Seitentyp anzeigen" Margin="0,5,0,0" Click="OnPageTemplateSelectionChanged"/></StackPanel>
|
||||
<TextBlock Grid.Column="1" Text="{Binding OverlayCoordinates}" FontFamily="Monospace"
|
||||
FontSize="11" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
<StackPanel Grid.Row="1" Spacing="7" Margin="0,10,0,0">
|
||||
<Grid ColumnDefinitions="Auto,8,*">
|
||||
<TextBlock Text="Vorschau" VerticalAlignment="Center" Classes="label"/>
|
||||
<ComboBox Grid.Column="2" ItemsSource="{Binding PreviewPages}" SelectedItem="{Binding SelectedPreviewPage}"
|
||||
DisplayMemberBinding="{Binding Display}" PlaceholderText="Noch nicht gerendert"/>
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Content="Koordinaten messen" Click="OnMeasureOverlayMode"/>
|
||||
<Button Grid.Column="2" Content="Elemente verschieben" Click="OnEditOverlayMode"/>
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="Auto,8,90,*">
|
||||
<CheckBox Content="Rasterfang" IsChecked="{Binding SnapOverlayToGrid}" VerticalAlignment="Center"/>
|
||||
<NumericUpDown Grid.Column="2" Value="{Binding OverlayGridSize}" Minimum="0.1" Maximum="50"
|
||||
Increment="0.5" FormatString="0.##"/>
|
||||
<TextBlock Grid.Column="3" Margin="8,0,0,0" Text="{Binding OverlayPageUnit}" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding SelectedOverlayElement}" FontSize="11" Opacity="0.7"/>
|
||||
</StackPanel>
|
||||
<Border Grid.Row="2" Margin="0,12" Background="#CBD5E1" BorderBrush="#94A3B8" BorderThickness="1">
|
||||
<local:LayoutOverlayEditor PreviewImage="{Binding PreviewImage}"
|
||||
LayoutSource="{Binding LayoutSource, Mode=TwoWay}"
|
||||
PageWidth="{Binding OverlayPageWidth}" PageHeight="{Binding OverlayPageHeight}"
|
||||
PageUnit="{Binding OverlayPageUnit}" Mode="{Binding OverlayMode}"
|
||||
PageTemplateName="{Binding SelectedPageTemplate}"
|
||||
SnapToGrid="{Binding SnapOverlayToGrid}" GridSize="{Binding OverlayGridSize}"
|
||||
MeasurementCompleted="OnOverlayMeasurementCompleted"
|
||||
ElementGeometryChanged="OnOverlayElementGeometryChanged"
|
||||
CursorCoordinatesChanged="OnOverlayCursorCoordinatesChanged"
|
||||
ElementSelected="OnOverlayElementSelected"/>
|
||||
</Border>
|
||||
<StackPanel Grid.Row="3" Spacing="4"><TextBlock Text="{Binding Status}" TextWrapping="Wrap" Foreground="{Binding StatusColor}"/>
|
||||
<TextBlock Text="Messen: klicken oder Bereich aufziehen. Bearbeiten: Rahmen ziehen, unten rechts skalieren; Pfeiltasten verschieben."
|
||||
FontSize="11" Opacity="0.65" TextWrapping="Wrap"/></StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,393 @@
|
||||
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();
|
||||
private string? _currentPackagePath;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent(); DataContext = _viewModel;
|
||||
RefreshStarterTemplates();
|
||||
UpdateDocumentTitle();
|
||||
}
|
||||
|
||||
private void OnNew(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_viewModel.Reset(); _currentPackagePath = null; UpdateDocumentTitle();
|
||||
}
|
||||
private void OnExit(object? sender, RoutedEventArgs e) => Close();
|
||||
private void OnAddPlaceholder(object? sender, RoutedEventArgs e) => _viewModel.AddPlaceholder();
|
||||
private void OnAddMetadata(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var item = new DesignerMetadata($"custom-{_viewModel.MetadataItems.Count + 1}", "");
|
||||
_viewModel.MetadataItems.Add(item); _viewModel.SelectedMetadata = item; _viewModel.CanExport = false;
|
||||
}
|
||||
private void OnRemoveMetadata(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedMetadata is not { } selected) return;
|
||||
_viewModel.MetadataItems.Remove(selected); _viewModel.SelectedMetadata = null; _viewModel.CanExport = false;
|
||||
}
|
||||
private void OnRemovePlaceholder(object? sender, RoutedEventArgs e) => _viewModel.RemoveSelectedPlaceholder();
|
||||
private void OnBoldSelection(object? sender, RoutedEventArgs e) => WrapPlaceholderSelection("[b]", "[/b]");
|
||||
private void OnItalicSelection(object? sender, RoutedEventArgs e) => WrapPlaceholderSelection("[i]", "[/i]");
|
||||
private void OnUnderlineSelection(object? sender, RoutedEventArgs e) => WrapPlaceholderSelection("[u]", "[/u]");
|
||||
|
||||
private void WrapPlaceholderSelection(string opening, string closing)
|
||||
{
|
||||
if (_viewModel.SelectedPlaceholder is not { SupportsRichText: true } placeholder
|
||||
|| this.FindControl<TextBox>("PlaceholderValueTextBox") is not { } editor) return;
|
||||
var source = editor.Text ?? "";
|
||||
var start = Math.Min(editor.SelectionStart, editor.SelectionEnd);
|
||||
var end = Math.Max(editor.SelectionStart, editor.SelectionEnd);
|
||||
var updated = source[..start] + opening + source[start..end] + closing + source[end..];
|
||||
placeholder.Sample = updated; editor.Text = updated;
|
||||
editor.SelectionStart = start + opening.Length;
|
||||
editor.SelectionEnd = end + opening.Length;
|
||||
editor.Focus(); _viewModel.CanExport = false;
|
||||
}
|
||||
private void OnAddElement(object? sender, RoutedEventArgs e)
|
||||
{ try { _viewModel.AddElement(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
|
||||
private void OnAddPageTemplate(object? sender, RoutedEventArgs e)
|
||||
{ try { _viewModel.AddPageTemplate(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
|
||||
private void OnAddContentFlow(object? sender, RoutedEventArgs e)
|
||||
{ try { _viewModel.AddContentFlow(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
|
||||
private void OnAddFlowSlot(object? sender, RoutedEventArgs e)
|
||||
{ try { _viewModel.AddFlowSlot(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
|
||||
private void OnPageTemplateSelectionChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!IsLoaded) return;
|
||||
try { _viewModel.PreviewSelectedPageTemplateCanvas(); }
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Seitentyp-Vorschau fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
private void OnMeasureOverlayMode(object? sender, RoutedEventArgs e)
|
||||
{ _viewModel.OverlayMode = OverlayEditorMode.Measure; _viewModel.SelectedOverlayElement = "Messmodus aktiv"; }
|
||||
private void OnEditOverlayMode(object? sender, RoutedEventArgs e)
|
||||
{ _viewModel.OverlayMode = OverlayEditorMode.Edit; _viewModel.SelectedOverlayElement = "Elementmodus aktiv"; }
|
||||
private void OnOverlayMeasurementCompleted(object? sender, OverlayMeasurement measurement) =>
|
||||
_viewModel.ApplyMeasurement(measurement);
|
||||
private void OnOverlayCursorCoordinatesChanged(object? sender, string coordinates) =>
|
||||
_viewModel.OverlayCoordinates = coordinates;
|
||||
private void OnOverlayElementSelected(object? sender, string element) =>
|
||||
_viewModel.SelectedOverlayElement = element;
|
||||
private void OnOverlayElementGeometryChanged(object? sender, OverlayElementGeometry geometry)
|
||||
{
|
||||
try { _viewModel.ApplyElementGeometry(geometry); OnPreview(sender, new RoutedEventArgs()); }
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Elementänderung fehlgeschlagen: {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); }
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
[SupportedOSPlatform("linux")]
|
||||
[SupportedOSPlatform("macos")]
|
||||
private async void OnImportPdfTemplate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var dialog = new PdfImportDialog();
|
||||
var accepted = await dialog.ShowDialog<bool>(this);
|
||||
if (!accepted || dialog.Result is null) return;
|
||||
try
|
||||
{
|
||||
_viewModel.ApplyPdfImport(dialog.Result);
|
||||
_currentPackagePath = null; UpdateDocumentTitle(); OnPreview(sender, e);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"PDF-Vorschlag konnte nicht übernommen werden: {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
|
||||
{
|
||||
_viewModel.RenderPreviewPages(_viewModel.BuildLoaded(), _viewModel.BuildDataProvider());
|
||||
_viewModel.CanExport = true;
|
||||
_viewModel.SetStatus($"Validierung erfolgreich. Vorschau enthält {_viewModel.PreviewPages.Count} Seite(n).", 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, continuationLayout) = _starterTemplates.LoadWithContinuation(selected);
|
||||
_viewModel.Load(template, layout, continuationLayout); _currentPackagePath = null; UpdateDocumentTitle(); 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, continuationLayout) = _starterTemplates.LoadWithContinuation(selected);
|
||||
var projectId = _starterTemplates.CreateUniqueId(template.Manifest.Id + "-neu");
|
||||
_viewModel.LoadAsNewProject(template, layout, continuationLayout, projectId);
|
||||
_currentPackagePath = null; UpdateDocumentTitle(); 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,
|
||||
_viewModel.UseContinuationLayout ? _viewModel.ContinuationLayoutSource : null);
|
||||
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);
|
||||
string layoutSource;
|
||||
using (var reader = new StreamReader(archive.GetEntry(loaded.Manifest.LayoutFile)!.Open())) layoutSource = reader.ReadToEnd();
|
||||
string? continuationLayoutSource = null;
|
||||
if (loaded.Manifest.ContinuationLayoutFile is { } continuationPath)
|
||||
{
|
||||
using var reader = new StreamReader(archive.GetEntry(continuationPath)!.Open());
|
||||
continuationLayoutSource = reader.ReadToEnd();
|
||||
}
|
||||
_viewModel.Load(loaded, layoutSource, continuationLayoutSource); _currentPackagePath = Path.GetFullPath(path);
|
||||
UpdateDocumentTitle(); OnPreview(sender, e);
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Öffnen fehlgeschlagen: {ex.Message}", true); }
|
||||
}
|
||||
|
||||
private async void OnSave(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_currentPackagePath is null) { await SaveAsAsync(); return; }
|
||||
SaveTo(_currentPackagePath);
|
||||
}
|
||||
|
||||
private async void OnSaveAs(object? sender, RoutedEventArgs e) => await SaveAsAsync();
|
||||
|
||||
private async void OnExportCurrentPdf(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
byte[] pdf;
|
||||
try
|
||||
{
|
||||
pdf = _viewModel.RenderCurrentPdf();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_viewModel.SetStatus($"PDF-Export fehlgeschlagen: {ex.Message}", true);
|
||||
return;
|
||||
}
|
||||
|
||||
var file = await StorageProvider.SaveFilePickerAsync(new()
|
||||
{
|
||||
Title = "Aktuelle Vorlagenansicht als PDF exportieren",
|
||||
SuggestedFileName = _viewModel.TemplateId + "-vorschau.pdf",
|
||||
DefaultExtension = "pdf",
|
||||
FileTypeChoices = [new("PDF-Dateien") { Patterns = ["*.pdf"] }],
|
||||
});
|
||||
if (file is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var destination = EnsurePdfExtension(file.Path.LocalPath);
|
||||
await File.WriteAllBytesAsync(destination, pdf);
|
||||
_viewModel.SetStatus($"PDF-Vorschau exportiert: {Path.GetFileName(destination)}", false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_viewModel.SetStatus($"PDF-Export fehlgeschlagen: {ex.Message}", true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsAsync()
|
||||
{
|
||||
try { _viewModel.BuildLoaded(); }
|
||||
catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); return; }
|
||||
var file = await StorageProvider.SaveFilePickerAsync(new()
|
||||
{ Title = "Vorlagenpaket speichern unter", SuggestedFileName = _viewModel.TemplateId + TemplatePackage.Extension,
|
||||
DefaultExtension = TemplatePackage.Extension[1..], FileTypeChoices = [PackageType()] });
|
||||
if (file is null) return;
|
||||
var destination = EnsurePackageExtension(file.Path.LocalPath);
|
||||
if (SaveTo(destination))
|
||||
{
|
||||
_currentPackagePath = Path.GetFullPath(destination);
|
||||
UpdateDocumentTitle();
|
||||
}
|
||||
}
|
||||
|
||||
private bool SaveTo(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
_viewModel.BuildLoaded();
|
||||
TemplatePackage.Create(path, _viewModel.BuildManifest(), _viewModel.LayoutSource, _viewModel.Assets,
|
||||
_viewModel.UseContinuationLayout ? _viewModel.ContinuationLayoutSource : null);
|
||||
_viewModel.CanExport = true;
|
||||
_viewModel.SetStatus($"Gespeichert: {Path.GetFileName(path)}", false);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) { _viewModel.SetStatus($"Speichern fehlgeschlagen: {ex.Message}", true); return false; }
|
||||
}
|
||||
|
||||
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 static string EnsurePackageExtension(string path) =>
|
||||
string.Equals(Path.GetExtension(path), TemplatePackage.Extension, StringComparison.OrdinalIgnoreCase)
|
||||
? path : path + TemplatePackage.Extension;
|
||||
private static string EnsurePdfExtension(string path) =>
|
||||
string.Equals(Path.GetExtension(path), ".pdf", StringComparison.OrdinalIgnoreCase)
|
||||
? path : path + ".pdf";
|
||||
private void RefreshStarterTemplates(string? selectedId = null) =>
|
||||
_viewModel.SetStarterTemplates(_starterTemplates.GetAll(), selectedId);
|
||||
|
||||
private void UpdateDocumentTitle()
|
||||
{
|
||||
var fileName = _currentPackagePath is null ? "Unbenannt" : Path.GetFileName(_currentPackagePath);
|
||||
Title = $"{fileName} – LehrerApp Vorlagen-Designer";
|
||||
DocumentNameText.Text = fileName;
|
||||
DocumentPathText.Text = _currentPackagePath ?? "Noch nicht gespeichert";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<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.PdfImportDialog" x:DataType="local:PdfImportDialogViewModel" Title="PDF als Vorlage importieren"
|
||||
Width="920" Height="720" MinWidth="760" MinHeight="600" WindowStartupLocation="CenterOwner">
|
||||
<Grid Margin="18" RowDefinitions="Auto,Auto,Auto,Auto,*,Auto" RowSpacing="12">
|
||||
<TextBlock FontSize="20" FontWeight="Bold" Text="PDF-Import mit geometrischer Analyse"/>
|
||||
<TextBlock Grid.Row="1" TextWrapping="Wrap" Opacity="0.75"
|
||||
Text="Koordinaten werden lokal aus dem PDF gelesen. Die optionale KI ordnet nur Namen und Datentypen zu; sie erhält weder PDF noch Bilder."/>
|
||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,*,Auto" RowDefinitions="Auto,8,Auto">
|
||||
<TextBlock VerticalAlignment="Center" Text="Ausgefülltes Beispiel:"/>
|
||||
<TextBox Grid.Column="1" Margin="10,0" Text="{Binding ExamplePath}" IsReadOnly="True"/>
|
||||
<Button Grid.Column="2" Content="Auswählen …" Click="OnChooseExample"/>
|
||||
<TextBlock Grid.Row="2" VerticalAlignment="Center" Text="Leeres Template (optional):"/>
|
||||
<TextBox Grid.Row="2" Grid.Column="1" Margin="10,0" Text="{Binding TemplatePath}" IsReadOnly="True"/>
|
||||
<Button Grid.Row="2" Grid.Column="2" Content="Auswählen …" Click="OnChooseTemplate"/>
|
||||
</Grid>
|
||||
<StackPanel Grid.Row="3" Spacing="8">
|
||||
<CheckBox Content="KI-Backend für semantische Klassifikation verwenden" IsChecked="{Binding UseAi}"/>
|
||||
<Grid ColumnDefinitions="*,10,*" IsEnabled="{Binding UseAi}">
|
||||
<TextBox PlaceholderText="Benutzername" Text="{Binding Username}"/>
|
||||
<TextBox Grid.Column="2" PlaceholderText="Passwort (wird nicht gespeichert)" PasswordChar="●" Text="{Binding Password}"/>
|
||||
</Grid>
|
||||
<Button HorizontalAlignment="Left" Content="Analysieren" Click="OnAnalyze" IsEnabled="{Binding CanAnalyze}"/>
|
||||
<TextBlock Text="{Binding Status}" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
<DataGrid Grid.Row="4" ItemsSource="{Binding Candidates}" AutoGenerateColumns="False" CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridCheckBoxColumn Header="Übernehmen" Binding="{Binding Include}" Width="85"/>
|
||||
<DataGridTextColumn Header="Erkannter Inhalt" Binding="{Binding OriginalText}" IsReadOnly="True" Width="2*"/>
|
||||
<DataGridTextColumn Header="Platzhalter" Binding="{Binding Name}" Width="*"/>
|
||||
<DataGridTemplateColumn Header="Typ" Width="130">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate x:DataType="local:PdfImportCandidate">
|
||||
<ComboBox ItemsSource="{Binding AvailableTypes}" SelectedItem="{Binding Type}"/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTemplateColumn Header="Konfidenz" Width="90">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate x:DataType="local:PdfImportCandidate">
|
||||
<TextBlock Text="{Binding ConfidenceLabel}" Foreground="{Binding ConfidenceColor}" FontWeight="SemiBold"/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<Grid Grid.Row="5" ColumnDefinitions="*,Auto,10,Auto">
|
||||
<TextBlock VerticalAlignment="Center" Text="Rot/niedrig und gelb/mittel bitte besonders sorgfältig prüfen." Opacity="0.7"/>
|
||||
<Button Grid.Column="1" Content="Abbrechen" Click="OnCancel"/>
|
||||
<Button Grid.Column="3" Content="Geprüften Vorschlag übernehmen" Click="OnAccept" IsEnabled="{Binding HasResult}"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LehrerApp.Templating;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner;
|
||||
|
||||
public partial class PdfImportDialogViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private string _examplePath = "";
|
||||
[ObservableProperty] private string _templatePath = "";
|
||||
[ObservableProperty] private bool _useAi = true;
|
||||
[ObservableProperty] private string _username = "";
|
||||
[ObservableProperty] private string _password = "";
|
||||
[ObservableProperty] private string _status = "Bitte mindestens ein ausgefülltes Beispiel-PDF auswählen.";
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private bool _hasResult;
|
||||
public ObservableCollection<PdfImportCandidate> Candidates { get; } = [];
|
||||
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } =
|
||||
[PlaceholderType.Text, PlaceholderType.Multiline, PlaceholderType.Date, PlaceholderType.Number];
|
||||
public bool CanAnalyze => !IsBusy && File.Exists(ExamplePath) && (!UseAi
|
||||
|| (!string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password)));
|
||||
partial void OnExamplePathChanged(string value) => OnPropertyChanged(nameof(CanAnalyze));
|
||||
partial void OnUseAiChanged(bool value) => OnPropertyChanged(nameof(CanAnalyze));
|
||||
partial void OnUsernameChanged(string value) => OnPropertyChanged(nameof(CanAnalyze));
|
||||
partial void OnPasswordChanged(string value) => OnPropertyChanged(nameof(CanAnalyze));
|
||||
partial void OnIsBusyChanged(bool value) => OnPropertyChanged(nameof(CanAnalyze));
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
[SupportedOSPlatform("linux")]
|
||||
[SupportedOSPlatform("macos")]
|
||||
public partial class PdfImportDialog : Window
|
||||
{
|
||||
private readonly PdfImportDialogViewModel _viewModel = new();
|
||||
private PdfImportResult? _result;
|
||||
public PdfImportResult? Result { get; private set; }
|
||||
|
||||
public PdfImportDialog() { InitializeComponent(); DataContext = _viewModel; }
|
||||
|
||||
private async void OnChooseExample(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var file = await ChoosePdf("Ausgefülltes Beispiel-PDF auswählen");
|
||||
if (file is not null) _viewModel.ExamplePath = file;
|
||||
}
|
||||
|
||||
private async void OnChooseTemplate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var file = await ChoosePdf("Leeres Template-PDF auswählen");
|
||||
if (file is not null) _viewModel.TemplatePath = file;
|
||||
}
|
||||
|
||||
private async Task<string?> ChoosePdf(string title)
|
||||
{
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new()
|
||||
{ Title = title, AllowMultiple = false, FileTypeFilter = [new("PDF-Dateien") { Patterns = ["*.pdf"] }] });
|
||||
return files.Count == 0 ? null : files[0].Path.LocalPath;
|
||||
}
|
||||
|
||||
private async void OnAnalyze(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_viewModel.IsBusy = true; _viewModel.HasResult = false; _viewModel.Status = "PDF wird lokal analysiert …";
|
||||
try
|
||||
{
|
||||
_result = await new PdfImportPipeline().BuildAsync(_viewModel.ExamplePath,
|
||||
string.IsNullOrWhiteSpace(_viewModel.TemplatePath) ? null : _viewModel.TemplatePath,
|
||||
_viewModel.UseAi ? _viewModel.Username : null, _viewModel.UseAi ? _viewModel.Password : null);
|
||||
_viewModel.Candidates.Clear();
|
||||
foreach (var candidate in _result.Candidates) _viewModel.Candidates.Add(candidate);
|
||||
_viewModel.HasResult = true;
|
||||
_viewModel.Status = $"{_viewModel.Candidates.Count} variable Textbereiche erkannt. Bitte Zuordnung prüfen und bestätigen.";
|
||||
}
|
||||
catch (Exception ex) { _viewModel.Status = $"Import fehlgeschlagen: {ex.Message}"; }
|
||||
finally { _viewModel.IsBusy = false; }
|
||||
}
|
||||
|
||||
private void OnAccept(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_result is null) return;
|
||||
var document = new PdfImportPipeline().Extract(_viewModel.ExamplePath);
|
||||
Result = new PdfImportPipeline().BuildResult(_viewModel.ExamplePath, document, _viewModel.Candidates);
|
||||
Close(true);
|
||||
}
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Runtime.Versioning;
|
||||
using LehrerApp.Templating;
|
||||
using PDFtoImage;
|
||||
using UglyToad.PdfPig;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner;
|
||||
|
||||
public enum PdfImportConfidence { Low, Medium, High }
|
||||
|
||||
public sealed record PdfTextBlock(string Id, int PageNumber, double X, double Y, double Width, double Height,
|
||||
double FontSize, string FontName, bool Bold, bool Italic, string Text);
|
||||
|
||||
public sealed record PdfImportPage(int PageNumber, double Width, double Height, IReadOnlyList<PdfTextBlock> TextBlocks);
|
||||
|
||||
public sealed record PdfImportDocument(IReadOnlyList<PdfImportPage> Pages);
|
||||
|
||||
public sealed class PdfImportCandidate
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required List<string> BlockIds { get; set; }
|
||||
public required string OriginalText { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public PlaceholderType Type { get; set; } = PlaceholderType.Text;
|
||||
public PdfImportConfidence Confidence { get; set; }
|
||||
public bool Include { get; set; } = true;
|
||||
public string ConfidenceLabel => Confidence switch
|
||||
{ PdfImportConfidence.High => "Hoch", PdfImportConfidence.Medium => "Mittel", _ => "Niedrig" };
|
||||
public string ConfidenceColor => Confidence switch
|
||||
{ PdfImportConfidence.High => "#15803D", PdfImportConfidence.Medium => "#A16207", _ => "#B91C1C" };
|
||||
public IReadOnlyList<PlaceholderType> AvailableTypes { get; } =
|
||||
[PlaceholderType.Text, PlaceholderType.Multiline, PlaceholderType.Date, PlaceholderType.Number];
|
||||
}
|
||||
|
||||
public sealed record PdfImportResult(string LayoutSource, TemplateManifest Manifest,
|
||||
IReadOnlyDictionary<string, byte[]> Assets, IReadOnlyList<PdfImportCandidate> Candidates);
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
[SupportedOSPlatform("linux")]
|
||||
[SupportedOSPlatform("macos")]
|
||||
public sealed class PdfImportPipeline
|
||||
{
|
||||
private const double PositionTolerance = 2;
|
||||
|
||||
public PdfImportDocument Extract(string path)
|
||||
{
|
||||
using var document = PdfDocument.Open(path);
|
||||
var pages = new List<PdfImportPage>();
|
||||
foreach (var page in document.GetPages())
|
||||
{
|
||||
var words = page.GetWords().OrderByDescending(x => x.BoundingBox.Top).ThenBy(x => x.BoundingBox.Left).ToList();
|
||||
var lines = new List<List<UglyToad.PdfPig.Content.Word>>();
|
||||
foreach (var word in words)
|
||||
{
|
||||
var line = lines.FirstOrDefault(x => Math.Abs(x[0].BoundingBox.Bottom - word.BoundingBox.Bottom)
|
||||
<= Math.Max(1.5, word.BoundingBox.Height * .35));
|
||||
if (line is null) lines.Add([word]); else line.Add(word);
|
||||
}
|
||||
|
||||
var blocks = lines.Select((line, index) =>
|
||||
{
|
||||
var ordered = line.OrderBy(x => x.BoundingBox.Left).ToList();
|
||||
var left = ordered.Min(x => x.BoundingBox.Left); var right = ordered.Max(x => x.BoundingBox.Right);
|
||||
var bottom = ordered.Min(x => x.BoundingBox.Bottom); var top = ordered.Max(x => x.BoundingBox.Top);
|
||||
var letters = ordered.SelectMany(x => x.Letters).ToList();
|
||||
var font = letters.FirstOrDefault()?.FontName ?? "";
|
||||
return new PdfTextBlock($"p{page.Number}-t{index + 1}", page.Number,
|
||||
left, page.Height - top, right - left, top - bottom,
|
||||
letters.Count == 0 ? top - bottom : letters.Average(x => x.FontSize), font,
|
||||
font.Contains("Bold", StringComparison.OrdinalIgnoreCase),
|
||||
font.Contains("Italic", StringComparison.OrdinalIgnoreCase) || font.Contains("Oblique", StringComparison.OrdinalIgnoreCase),
|
||||
string.Join(' ', ordered.Select(x => x.Text)));
|
||||
}).Where(x => !string.IsNullOrWhiteSpace(x.Text)).ToList();
|
||||
pages.Add(new(page.Number, page.Width, page.Height, blocks));
|
||||
}
|
||||
return new(pages);
|
||||
}
|
||||
|
||||
public List<PdfImportCandidate> FindCandidates(PdfImportDocument example, PdfImportDocument? blankTemplate)
|
||||
{
|
||||
var result = new List<PdfImportCandidate>();
|
||||
foreach (var page in example.Pages)
|
||||
{
|
||||
var templateBlocks = blankTemplate?.Pages.FirstOrDefault(x => x.PageNumber == page.PageNumber)?.TextBlocks ?? [];
|
||||
foreach (var block in page.TextBlocks)
|
||||
{
|
||||
var same = templateBlocks.Any(other => Near(block, other) && other.Text.Equals(block.Text, StringComparison.Ordinal));
|
||||
if (same) continue;
|
||||
var confidence = blankTemplate is not null ? PdfImportConfidence.High : HeuristicConfidence(block, page);
|
||||
result.Add(new PdfImportCandidate
|
||||
{
|
||||
Id = block.Id, BlockIds = [block.Id], OriginalText = block.Text,
|
||||
Name = SuggestedName(block.Text, block, page), Type = SuggestedType(block.Text), Confidence = confidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<PdfImportResult> BuildAsync(string examplePath, string? templatePath,
|
||||
string? username, string? password, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var example = Extract(examplePath);
|
||||
var blank = templatePath is null ? null : Extract(templatePath);
|
||||
EnsureCompatible(example, blank);
|
||||
if (example.Pages.Count > 1)
|
||||
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend als Folgeseitenlayout ergänzt werden.");
|
||||
var candidates = FindCandidates(example, blank);
|
||||
if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
var client = new PdfImportAiClient(new HttpClient { BaseAddress = new Uri("https://backapi.science-teaching.de/") });
|
||||
var classifications = await client.ClassifyAsync(username, password, example, candidates, cancellationToken);
|
||||
ApplyClassifications(candidates, classifications);
|
||||
}
|
||||
return BuildResult(templatePath ?? examplePath, example, candidates);
|
||||
}
|
||||
|
||||
public PdfImportResult BuildResult(string backgroundPdfPath, PdfImportDocument document,
|
||||
IReadOnlyList<PdfImportCandidate> candidates)
|
||||
{
|
||||
if (document.Pages.Count == 0) throw new InvalidDataException("Das PDF enthält keine Seiten.");
|
||||
if (document.Pages.Count > 1)
|
||||
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend als Folgeseitenlayout ergänzt werden.");
|
||||
var first = document.Pages[0];
|
||||
var assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["pdf-import-background.png"] = RenderPage(backgroundPdfPath),
|
||||
["pdf-import-mask.png"] = WhitePixelPng,
|
||||
};
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"PAGE {N(first.Width)} {N(first.Height)} pt",
|
||||
"BG pdf-import-background.png",
|
||||
};
|
||||
var definitions = new List<PlaceholderDefinition>();
|
||||
var usedNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var candidate in candidates.Where(x => x.Include))
|
||||
{
|
||||
var blocks = candidate.BlockIds.Select(id => first.TextBlocks.FirstOrDefault(x => x.Id == id))
|
||||
.Where(x => x is not null).Cast<PdfTextBlock>().ToList();
|
||||
if (blocks.Count == 0) continue;
|
||||
var name = UniqueName(SanitizeName(candidate.Name), usedNames);
|
||||
var x = blocks.Min(b => b.X); var y = blocks.Min(b => b.Y);
|
||||
var right = blocks.Max(b => b.X + b.Width); var bottom = blocks.Max(b => b.Y + b.Height);
|
||||
var padding = Math.Max(1, blocks.Average(b => b.FontSize) * .18);
|
||||
lines.Add($"IMG pdf-import-mask.png {N(x - padding)} {N(y - padding)} {N(right - x + 2 * padding)} {N(bottom - y + 2 * padding)}");
|
||||
var attrs = $"size={N(blocks.Average(b => b.FontSize))}"
|
||||
+ (blocks.Any(b => b.Bold) ? " bold=true" : "") + (blocks.Any(b => b.Italic) ? " italic=true" : "");
|
||||
var multiline = candidate.Type == PlaceholderType.Multiline || blocks.Count > 1 || candidate.OriginalText.Contains('\n');
|
||||
lines.Add(multiline
|
||||
? $"TEXTBOX {N(x)} {N(y)} {N(Math.Max(20, right - x))} {N(Math.Max(bottom - y, blocks.Average(b => b.FontSize) * 2.5))} ${name} {attrs}"
|
||||
: $"TEXT {N(x)} {N(y)} ${name} {attrs}");
|
||||
definitions.Add(new(name, multiline ? PlaceholderType.Multiline : candidate.Type, false));
|
||||
candidate.Name = name;
|
||||
}
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = "pdf-import", Name = "PDF-Import", Description = "Automatisch aus einem PDF rekonstruiert",
|
||||
PageSize = new((float)first.Width, (float)first.Height, "pt"), Placeholders = definitions,
|
||||
Metadata = new(StringComparer.OrdinalIgnoreCase) { [TemplateMetadataKeys.Language] = "de-DE" },
|
||||
};
|
||||
return new(string.Join(Environment.NewLine, lines) + Environment.NewLine, manifest, assets, candidates);
|
||||
}
|
||||
|
||||
private static void ApplyClassifications(List<PdfImportCandidate> candidates,
|
||||
IReadOnlyList<PdfAiClassification> classifications)
|
||||
{
|
||||
foreach (var classification in classifications)
|
||||
{
|
||||
var candidate = candidates.FirstOrDefault(x => x.Id == classification.Id);
|
||||
if (candidate is null) continue;
|
||||
candidate.Name = classification.Name;
|
||||
candidate.Type = classification.Type;
|
||||
candidate.Confidence = classification.Confidence;
|
||||
if (classification.BlockIds.Count > 0) candidate.BlockIds = classification.BlockIds;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Near(PdfTextBlock a, PdfTextBlock b) => Math.Abs(a.X - b.X) <= PositionTolerance
|
||||
&& Math.Abs(a.Y - b.Y) <= PositionTolerance && Math.Abs(a.Width - b.Width) <= Math.Max(PositionTolerance, a.Width * .08);
|
||||
|
||||
private static PdfImportConfidence HeuristicConfidence(PdfTextBlock block, PdfImportPage page)
|
||||
{
|
||||
if (SuggestedType(block.Text) is PlaceholderType.Date or PlaceholderType.Number) return PdfImportConfidence.High;
|
||||
if (block.Y < page.Height * .42 && block.X < page.Width * .6) return PdfImportConfidence.Medium;
|
||||
return PdfImportConfidence.Low;
|
||||
}
|
||||
|
||||
private static PlaceholderType SuggestedType(string text)
|
||||
{
|
||||
if (System.Text.RegularExpressions.Regex.IsMatch(text, @"\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b")) return PlaceholderType.Date;
|
||||
if (decimal.TryParse(text, NumberStyles.Number, CultureInfo.GetCultureInfo("de-DE"), out _)) return PlaceholderType.Number;
|
||||
return text.Length > 100 ? PlaceholderType.Multiline : PlaceholderType.Text;
|
||||
}
|
||||
|
||||
private static string SuggestedName(string text, PdfTextBlock block, PdfImportPage page)
|
||||
{
|
||||
if (SuggestedType(text) == PlaceholderType.Date) return "Datum";
|
||||
if (System.Text.RegularExpressions.Regex.IsMatch(text, @"^\d{5}\s+")) return "PlzOrt";
|
||||
if (text.StartsWith("Betreff", StringComparison.OrdinalIgnoreCase)) return "Betreff";
|
||||
if (block.Y < page.Height * .42 && block.X < page.Width * .6) return "Adresse";
|
||||
return "Feld";
|
||||
}
|
||||
|
||||
private static string SanitizeName(string name)
|
||||
{
|
||||
var safe = string.Concat(name.Trim().Where(char.IsLetterOrDigit));
|
||||
return string.IsNullOrEmpty(safe) ? "Feld" : safe;
|
||||
}
|
||||
|
||||
private static string UniqueName(string name, HashSet<string> used)
|
||||
{
|
||||
if (used.Add(name)) return name;
|
||||
for (var i = 2; ; i++) if (used.Add(name + i)) return name + i;
|
||||
}
|
||||
|
||||
private static string N(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
|
||||
private static void EnsureCompatible(PdfImportDocument example, PdfImportDocument? blank)
|
||||
{
|
||||
if (blank is null) return;
|
||||
if (example.Pages.Count != blank.Pages.Count) throw new InvalidDataException("Template und Beispiel haben unterschiedlich viele Seiten.");
|
||||
for (var i = 0; i < example.Pages.Count; i++)
|
||||
if (Math.Abs(example.Pages[i].Width - blank.Pages[i].Width) > PositionTolerance
|
||||
|| Math.Abs(example.Pages[i].Height - blank.Pages[i].Height) > PositionTolerance)
|
||||
throw new InvalidDataException("Template und Beispiel haben unterschiedliche Seitengrößen.");
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
[SupportedOSPlatform("linux")]
|
||||
[SupportedOSPlatform("macos")]
|
||||
private static byte[] RenderPage(string path)
|
||||
{
|
||||
var temporary = Path.Combine(Path.GetTempPath(), $"lehrerapp-pdf-import-{Guid.NewGuid():N}.png");
|
||||
try
|
||||
{
|
||||
using var pdf = File.OpenRead(path);
|
||||
Conversion.SavePng(temporary, pdf, page: 0, options: new RenderOptions(Dpi: 300));
|
||||
return File.ReadAllBytes(temporary);
|
||||
}
|
||||
finally { if (File.Exists(temporary)) File.Delete(temporary); }
|
||||
}
|
||||
|
||||
private static readonly byte[] WhitePixelPng = Convert.FromBase64String(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nJkAAAAASUVORK5CYII=");
|
||||
}
|
||||
|
||||
public sealed record PdfAiClassification(string Id, List<string> BlockIds, string Name,
|
||||
PlaceholderType Type, PdfImportConfidence Confidence);
|
||||
|
||||
internal sealed class PdfImportAiClient(HttpClient http)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
|
||||
public async Task<IReadOnlyList<PdfAiClassification>> ClassifyAsync(string username, string password,
|
||||
PdfImportDocument document, IReadOnlyList<PdfImportCandidate> candidates, CancellationToken cancellationToken)
|
||||
{
|
||||
HttpResponseMessage login;
|
||||
try { login = await http.PostAsJsonAsync("login.php", new { username, password }, JsonOptions, cancellationToken); }
|
||||
catch (HttpRequestException ex) { throw new InvalidOperationException("Der KI-Dienst ist nicht erreichbar.", ex); }
|
||||
if (login.StatusCode == HttpStatusCode.Unauthorized) throw new InvalidOperationException("Benutzername oder Passwort ist falsch.");
|
||||
login.EnsureSuccessStatusCode();
|
||||
var token = (await login.Content.ReadFromJsonAsync<LoginResult>(JsonOptions, cancellationToken))?.Token
|
||||
?? throw new InvalidOperationException("Das KI-Backend lieferte kein Token.");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "pdf-template.php")
|
||||
{
|
||||
Content = JsonContent.Create(new { document, candidates = candidates.Select(x => new
|
||||
{ x.Id, x.BlockIds, x.OriginalText, suggestedName = x.Name, x.Type, x.Confidence }) }, options: JsonOptions),
|
||||
};
|
||||
request.Headers.Authorization = new("Bearer", token);
|
||||
using var response = await http.SendAsync(request, cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.PaymentRequired) throw new InvalidOperationException("Nicht genügend KI-Guthaben.");
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized) throw new InvalidOperationException("Die KI-Anmeldung ist abgelaufen.");
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadFromJsonAsync<ErrorResult>(JsonOptions, cancellationToken);
|
||||
throw new InvalidOperationException(error?.Error ?? "Die KI-Klassifikation ist fehlgeschlagen.");
|
||||
}
|
||||
var result = await response.Content.ReadFromJsonAsync<ClassificationResult>(JsonOptions, cancellationToken);
|
||||
return result?.Classifications ?? throw new InvalidOperationException("Die KI-Antwort ist unvollständig.");
|
||||
}
|
||||
|
||||
private sealed record LoginResult(string Token);
|
||||
private sealed record ErrorResult(string Error);
|
||||
private sealed record ClassificationResult(List<PdfAiClassification> Classifications);
|
||||
}
|
||||
@@ -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,141 @@
|
||||
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, string? continuationLayoutSource = null)
|
||||
{
|
||||
var path = Path.Combine(_directory, SafeId(manifest.Id) + TemplatePackage.Extension);
|
||||
TemplatePackage.Create(path, manifest, layoutSource, assets, continuationLayoutSource);
|
||||
return ToItem(manifest, path);
|
||||
}
|
||||
|
||||
public StarterTemplateItem Import(string sourcePath)
|
||||
{
|
||||
var (template, layout, continuationLayout) = LoadPackage(sourcePath);
|
||||
return Save(template.Manifest, layout, template.Assets, continuationLayout);
|
||||
}
|
||||
|
||||
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, continuationLayout) = LoadWithContinuation(item);
|
||||
var id = CreateUniqueId(template.Manifest.Id + "-kopie");
|
||||
var manifest = CopyManifest(template.Manifest, id, template.Manifest.Name + " - Kopie");
|
||||
return Save(manifest, layout, template.Assets, continuationLayout);
|
||||
}
|
||||
|
||||
public (LoadedTemplate Template, string LayoutSource) Load(StarterTemplateItem item)
|
||||
{
|
||||
var (template, layout, _) = LoadPackage(item.PackagePath);
|
||||
return (template, layout);
|
||||
}
|
||||
|
||||
public (LoadedTemplate Template, string LayoutSource, string? ContinuationLayoutSource) LoadWithContinuation(StarterTemplateItem item) =>
|
||||
LoadPackage(item.PackagePath);
|
||||
|
||||
public void Delete(StarterTemplateItem item)
|
||||
{
|
||||
if (File.Exists(item.PackagePath)) File.Delete(item.PackagePath);
|
||||
}
|
||||
|
||||
private (LoadedTemplate Template, string LayoutSource, string? ContinuationLayoutSource) 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.");
|
||||
string layoutSource;
|
||||
using (var reader = new StreamReader(layoutEntry.Open())) layoutSource = reader.ReadToEnd();
|
||||
string? continuationLayoutSource = null;
|
||||
if (template.Manifest.ContinuationLayoutFile is { } continuationPath)
|
||||
{
|
||||
var continuationEntry = archive.Entries.First(x => x.FullName.Equals(
|
||||
continuationPath.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase));
|
||||
using var reader = new StreamReader(continuationEntry.Open());
|
||||
continuationLayoutSource = reader.ReadToEnd();
|
||||
}
|
||||
return (template, layoutSource, continuationLayoutSource);
|
||||
}
|
||||
|
||||
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,
|
||||
ContinuationLayoutFile = source.ContinuationLayoutFile,
|
||||
MetadataFile = source.MetadataFile,
|
||||
Metadata = new(source.Metadata, StringComparer.OrdinalIgnoreCase),
|
||||
Placeholders = source.Placeholders.Select(x => new PlaceholderDefinition(x.Name, x.Type, x.Required,
|
||||
x.IsConstant, x.ConstantValue, x.Bold, x.Italic, x.Underline)).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,144 @@
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Templating.Tests;
|
||||
|
||||
public sealed class ConstantPlaceholderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resolver_KonstanterWertUeberschreibtExternenWert()
|
||||
{
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Placeholders = [new("Hinweis", PlaceholderType.Multiline, true, true, "Interner Langtext")],
|
||||
};
|
||||
var external = new Dictionary<string, PlaceholderValue>
|
||||
{ ["Hinweis"] = new MultilineValue("Extern manipuliert") };
|
||||
|
||||
var resolved = TemplateDataResolver.Resolve(manifest, external);
|
||||
|
||||
Assert.Equal("Interner Langtext", Assert.IsType<MultilineValue>(resolved["Hinweis"]).Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renderer_BenoetigtFuerKonstantenPflichtwertKeineExternenDatenUndUnterstuetztHervorhebung()
|
||||
{
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = "konstant", Name = "Konstant",
|
||||
Placeholders = [new("Hinweis", PlaceholderType.Multiline, true, true,
|
||||
"Dieser Text lebt im Paket.", Bold: true, Italic: true, Underline: true)],
|
||||
};
|
||||
var layout = new LayoutParser().Parse("PAGE 210 297 mm\nTEXTBOX 20 20 170 50 $Hinweis size=11");
|
||||
var template = new LoadedTemplate(manifest, layout, new Dictionary<string, byte[]>());
|
||||
|
||||
var pdf = new QuestTemplateRenderer().RenderToPdf(template, new EmptyProvider());
|
||||
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_BehandeltKonstantenPflichtwertAlsInternErfuellt()
|
||||
{
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Placeholders =
|
||||
[
|
||||
new("Intern", PlaceholderType.Text, true, true, "Fest"),
|
||||
new("Extern", PlaceholderType.Text, true),
|
||||
],
|
||||
};
|
||||
var template = new LoadedTemplate(manifest, new(210, 297, "mm", []), new Dictionary<string, byte[]>());
|
||||
|
||||
var result = new TemplateLoader().Validate(template, new Dictionary<string, PlaceholderType>());
|
||||
|
||||
Assert.Single(result.Issues);
|
||||
Assert.Contains("Extern", result.Issues[0].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PaketRoundtrip_BehaeltKonstantenWertUndTextformatierung()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"constant-{Guid.NewGuid():N}.lavorlage");
|
||||
try
|
||||
{
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = "konstant", Name = "Konstant",
|
||||
Placeholders = [new("Baustein", PlaceholderType.Text, false, true, "Fest im Paket",
|
||||
Bold: true, Italic: false, Underline: true)],
|
||||
};
|
||||
TemplatePackage.Create(path, manifest, "PAGE 210 297 mm\nTEXT 20 20 $Baustein",
|
||||
new Dictionary<string, byte[]>());
|
||||
|
||||
var definition = Assert.Single(new TemplateLoader().LoadFromPackage(path).Manifest.Placeholders);
|
||||
|
||||
Assert.True(definition.IsConstant);
|
||||
Assert.Equal("Fest im Paket", definition.ConstantValue);
|
||||
Assert.True(definition.Bold);
|
||||
Assert.True(definition.Underline);
|
||||
}
|
||||
finally { if (File.Exists(path)) File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RichTextParser_UnterstuetztVerschachtelteHervorhebungUndExternePlatzhalter()
|
||||
{
|
||||
var runs = TemplateRichText.Parse("Hallo [b]Familie [i]${Student.LastName}[/i][/b]!");
|
||||
|
||||
var placeholder = Assert.Single(runs, x => x.IsPlaceholder);
|
||||
Assert.Equal("Student.LastName", placeholder.Placeholder);
|
||||
Assert.True(placeholder.Bold);
|
||||
Assert.True(placeholder.Italic);
|
||||
Assert.False(placeholder.Underline);
|
||||
Assert.Throws<InvalidDataException>(() => TemplateRichText.Parse("[b]nicht geschlossen"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternerWertWirdImKonstantenRichTextNichtAlsMarkupInterpretiert()
|
||||
{
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = "sicher", Name = "Sicher",
|
||||
Placeholders =
|
||||
[
|
||||
new("Baustein", PlaceholderType.Multiline, true, true,
|
||||
"Sehr geehrte Familie [b]${Student.LastName}[/b],"),
|
||||
new("Student.LastName", PlaceholderType.Text, true),
|
||||
],
|
||||
};
|
||||
var template = new LoadedTemplate(manifest,
|
||||
new LayoutParser().Parse("PAGE 210 297 mm\nTEXTBOX 20 20 170 50 $Baustein"),
|
||||
new Dictionary<string, byte[]>());
|
||||
var provider = new ValuesProvider(new Dictionary<string, PlaceholderValue>
|
||||
{ ["Student.LastName"] = new TextValue("[u]nicht als Markup geöffnet") });
|
||||
|
||||
var pdf = new QuestTemplateRenderer().RenderToPdf(template, provider);
|
||||
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KonstantenValidierung_MeldetNichtDeklarierteEingebettetePlatzhalter()
|
||||
{
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Placeholders = [new("Baustein", PlaceholderType.Text, false, true, "Hallo ${Unbekannt}")],
|
||||
};
|
||||
|
||||
var issue = Assert.Single(TemplateDataResolver.ValidateConstants(manifest));
|
||||
|
||||
Assert.Contains("Unbekannt", issue);
|
||||
}
|
||||
|
||||
private sealed class EmptyProvider : ITemplateDataProvider
|
||||
{
|
||||
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() =>
|
||||
new Dictionary<string, PlaceholderValue>();
|
||||
}
|
||||
|
||||
private sealed class ValuesProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
|
||||
{
|
||||
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values;
|
||||
}
|
||||
}
|
||||
@@ -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,59 @@
|
||||
using System.IO.Compression;
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Templating.Tests;
|
||||
|
||||
public sealed class TemplateMetadataTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"template-metadata-{Guid.NewGuid():N}");
|
||||
|
||||
public TemplateMetadataTests() => Directory.CreateDirectory(_directory);
|
||||
|
||||
[Fact]
|
||||
public void Paket_SchreibtLesbareMetadatendateiUndLaedtWerteInsManifest()
|
||||
{
|
||||
var path = Path.Combine(_directory, "metadata.lavorlage");
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = "metadata", Name = "Metadaten",
|
||||
Metadata = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["language"] = "de-DE",
|
||||
["report-type"] = "parent-letter",
|
||||
["filter"] = "class=7a",
|
||||
},
|
||||
};
|
||||
|
||||
TemplatePackage.Create(path, manifest, "PAGE 210 297 mm", new Dictionary<string, byte[]>());
|
||||
|
||||
using (var archive = ZipFile.OpenRead(path))
|
||||
using (var reader = new StreamReader(archive.GetEntry("metadata.txt")!.Open()))
|
||||
{
|
||||
var text = reader.ReadToEnd();
|
||||
Assert.Contains("language=de-DE", text);
|
||||
Assert.Contains("report-type=parent-letter", text);
|
||||
}
|
||||
var loaded = new TemplateLoader().LoadFromPackage(path);
|
||||
Assert.Equal("de-DE", loaded.Manifest.Metadata["language"]);
|
||||
Assert.Equal("parent-letter", loaded.Manifest.Metadata["REPORT-TYPE"]);
|
||||
Assert.Equal("class=7a", loaded.Manifest.Metadata["filter"]);
|
||||
Assert.DoesNotContain("metadata.txt", loaded.Assets.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Textformat_IgnoriertKommentareUndLehntDoppelteSchluesselAb()
|
||||
{
|
||||
var parsed = TemplateMetadataText.Parse("# Kommentar\nlanguage = de-DE\nreport-type=letter\n");
|
||||
Assert.Equal("de-DE", parsed["language"]);
|
||||
|
||||
var exception = Assert.Throws<InvalidDataException>(() =>
|
||||
TemplateMetadataText.Parse("language=de-DE\nLANGUAGE=en-US"));
|
||||
Assert.Contains("mehrfach", exception.Message);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_directory)) Directory.Delete(_directory, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
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
|
||||
FLOWBOX 20 20 170 250 $Brieftext size=11
|
||||
TABLE 20 215 170 40 $Zeilen size=9
|
||||
CHART 20 260 170 25 $Werte type=line
|
||||
""");
|
||||
|
||||
Assert.Equal(7, 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.IsType<FlowBoxElement>(layout.Elements[4]);
|
||||
Assert.Equal("line", Assert.IsType<ChartElement>(layout.Elements[6]).ChartType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Systemvariablen_BrauchenKeineManifestDeklarationUndRendernMitSeitenzahlen()
|
||||
{
|
||||
var manifest = new TemplateManifest { Id = "system", Name = "Systemvariablen" };
|
||||
var layout = new LayoutParser().Parse("""
|
||||
PAGE 210 297 mm
|
||||
TEXT 20 10 "Stand: $$today" size=9
|
||||
TEXT 140 285 "Seite $$curPage von $$maxPageNum" size=9
|
||||
FLOWBOX 20 20 170 250 "Langer Inhalt für zwei Seiten.\nLanger Inhalt für zwei Seiten."
|
||||
""");
|
||||
var template = new LoadedTemplate(manifest, layout, new Dictionary<string, byte[]>());
|
||||
|
||||
var validation = new TemplateLoader().Validate(template, new Dictionary<string, PlaceholderType>());
|
||||
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
|
||||
new DictionaryProvider(new Dictionary<string, PlaceholderValue>()));
|
||||
|
||||
Assert.True(validation.IsValid);
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlowBox_FliesstAufFolgeseitenUndPaketEnthaeltFolgeseitenLayout()
|
||||
{
|
||||
var path = Path.Combine(_directory, "flow.lavorlage");
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = "flow", Name = "Fließtext", ContinuationLayoutFile = "continuation.tpl",
|
||||
Placeholders = [new("Text", PlaceholderType.Multiline, true)],
|
||||
};
|
||||
var first = "PAGE 210 297 mm\nTEXT 20 10 \"Erste Seite\" size=14\nFLOWBOX 20 25 170 252 $Text size=11";
|
||||
var continuation = "PAGE 210 297 mm\nTEXT 20 10 \"Folgeseite\" size=10\nFLOWBOX 20 25 170 252 $Text size=11";
|
||||
TemplatePackage.Create(path, manifest, first, new Dictionary<string, byte[]>(), continuation);
|
||||
|
||||
var loaded = new TemplateLoader().LoadFromPackage(path);
|
||||
var longText = string.Join('\n', Enumerable.Repeat(
|
||||
"Ein langer Klassenbucheintrag mit ausreichend Inhalt für den automatischen Seitenumbruch.", 250));
|
||||
var pdf = new QuestTemplateRenderer().RenderToPdf(loaded,
|
||||
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Text"] = new MultilineValue(longText) }));
|
||||
var source = System.Text.Encoding.ASCII.GetString(pdf);
|
||||
|
||||
Assert.NotNull(loaded.ContinuationLayout);
|
||||
Assert.True(System.Text.RegularExpressions.Regex.Matches(source, @"/Type\s*/Page\b").Count > 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawBox_RendertDeklarativeExterneZeichenbefehleInFesterBox()
|
||||
{
|
||||
var template = new LoadedTemplate(new TemplateManifest
|
||||
{
|
||||
Placeholders = [new("Grafik", PlaceholderType.Drawing, true)],
|
||||
}, new LayoutParser().Parse("PAGE 210 297 mm\nDRAWBOX 20 20 100 60 $Grafik"),
|
||||
new Dictionary<string, byte[]>());
|
||||
var drawing = new DrawingValue(
|
||||
[new DrawRectangle(0, 0, 100, 60, "#1D4ED8", 1, "#EFF6FF"),
|
||||
new DrawString(5, 5, "Externer Inhalt", 11, Bold: true),
|
||||
new MoveTo(5, 25), new LineTo(95, 25, "#DC2626", 1.5f),
|
||||
new DrawLine(5, 35, 95, 50, "#059669", 1)], 60);
|
||||
|
||||
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
|
||||
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Grafik"] = drawing }));
|
||||
|
||||
Assert.Single(System.Text.RegularExpressions.Regex.Matches(
|
||||
System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlowDrawBox_PaginertHohenDeklarativenZeichenraum()
|
||||
{
|
||||
var template = new LoadedTemplate(new TemplateManifest
|
||||
{
|
||||
Placeholders = [new("Protokoll", PlaceholderType.Drawing, true)],
|
||||
}, new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 257 $Protokoll"),
|
||||
new Dictionary<string, byte[]>());
|
||||
var drawing = new DrawingValue(
|
||||
Enumerable.Range(0, 80).SelectMany(i => (DrawingCommand[])
|
||||
[new DrawString(0, i * 10, $"Zeile {i + 1}", 8), new DrawLine(0, i * 10 + 9, 160, i * 10 + 9, "#CBD5E1", .3f)])
|
||||
.ToList(), 800);
|
||||
|
||||
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
|
||||
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Protokoll"] = drawing }));
|
||||
|
||||
Assert.True(System.Text.RegularExpressions.Regex.Matches(
|
||||
System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b").Count >= 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlowDrawBox_CallbackErhaeltSeitenflaecheStateUndSteuertSeitenwechsel()
|
||||
{
|
||||
var template = new LoadedTemplate(new TemplateManifest
|
||||
{
|
||||
Placeholders = [new("Bericht", PlaceholderType.Drawing, true)],
|
||||
}, new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 257 $Bericht"),
|
||||
new Dictionary<string, byte[]>());
|
||||
var invocations = 0;
|
||||
var drawing = new PagedDrawingValue(context =>
|
||||
{
|
||||
invocations++;
|
||||
var item = context.State is int value ? value : 0;
|
||||
context.Canvas.DrawRectangle(0, 0, context.Width, 20, "#1D4ED8", .5f, "#EFF6FF");
|
||||
context.Canvas.DrawStringEx(0, 2, 14, context.Width, $"Untrennbarer Block {item + 1}",
|
||||
DrawingTextAlignment.AlignCenter, 11, "Arial", "#1E3A8A", bold: true);
|
||||
context.State = item + 1;
|
||||
return context.PageNumber == 3;
|
||||
}, InitialState: 0);
|
||||
|
||||
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
|
||||
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Bericht"] = drawing }));
|
||||
|
||||
Assert.Equal(3, invocations);
|
||||
Assert.Equal(3, System.Text.RegularExpressions.Regex.Matches(
|
||||
System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b").Count);
|
||||
}
|
||||
|
||||
[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 LayoutParser_LiestSeitentypenSlotsUndContentFlows()
|
||||
{
|
||||
var layout = new LayoutParser().Parse("""
|
||||
PAGE 210 297 mm
|
||||
#pragma format-version 3
|
||||
#pragma page-template first
|
||||
TEXT 20 20 "Briefkopf"
|
||||
#pragma flow-slot body x=20 y=80 w=170 h=190
|
||||
#pragma end-page-template
|
||||
#pragma page-template continuation
|
||||
#pragma flow-slot body x=20 y=25 w=170 h=245
|
||||
#pragma end-page-template
|
||||
#pragma content-flow body
|
||||
TEXTBOX $Text size=11 overflow=continue
|
||||
TEXT "Gruß" gap=8 keep-with-next=true
|
||||
TEXT $Name
|
||||
#pragma end-content-flow
|
||||
""");
|
||||
|
||||
Assert.Equal(3, layout.FormatVersion);
|
||||
Assert.Equal(2, layout.PageTemplates.Count);
|
||||
Assert.Equal(80, layout.PageTemplates[0].FlowSlots.Single().Y);
|
||||
Assert.Equal(3, layout.ContentFlows.Single().Elements.Count);
|
||||
Assert.IsType<TextBoxElement>(layout.ContentFlows.Single().Elements[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renderer_FliesstLangenTextAufFolgeseitenWeiter()
|
||||
{
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = "flow", Name = "Flow",
|
||||
Placeholders = [new("Text", PlaceholderType.Multiline, true), new("Name", PlaceholderType.Text, true)],
|
||||
};
|
||||
var layout = new LayoutParser().Parse("""
|
||||
PAGE 210 297 mm
|
||||
#pragma format-version 3
|
||||
#pragma page-template first
|
||||
TEXT 20 15 "Erste Seite" size=16
|
||||
#pragma flow-slot body x=20 y=55 w=170 h=215
|
||||
#pragma end-page-template
|
||||
#pragma page-template continuation
|
||||
TEXT 20 12 "Folgeseite" size=9
|
||||
#pragma flow-slot body x=20 y=25 w=170 h=245
|
||||
#pragma end-page-template
|
||||
#pragma content-flow body
|
||||
TEXTBOX $Text size=11 overflow=continue
|
||||
TEXT "Mit freundlichen Grüßen" gap=8 keep-with-next=true
|
||||
TEXT $Name gap=3
|
||||
#pragma end-content-flow
|
||||
""");
|
||||
var loaded = new LoadedTemplate(manifest, layout, new Dictionary<string, byte[]>());
|
||||
var longText = string.Join('\n', Enumerable.Repeat("Dies ist eine ausreichend lange Textzeile für den Seitenumbruch.", 180));
|
||||
|
||||
var pages = new QuestTemplateRenderer().RenderPagesToPng(loaded,
|
||||
new DictionaryProvider(new Dictionary<string, PlaceholderValue>
|
||||
{
|
||||
["Text"] = new MultilineValue(longText), ["Name"] = new TextValue("M. Mustermann"),
|
||||
}), 40);
|
||||
|
||||
Assert.True(pages.Count >= 3);
|
||||
Assert.All(pages, page => Assert.True(page.Length > 500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loader_BlockiertPathTraversal()
|
||||
{
|
||||
var path = Path.Combine(_directory, "unsafe.lavorlage");
|
||||
using (var archive = ZipFile.Open(path, ZipArchiveMode.Create))
|
||||
{
|
||||
Write(archive, "../manifest.json", "{}");
|
||||
Write(archive, "manifest.json", ManifestJson());
|
||||
Write(archive, "layout.tpl", "PAGE 210 297 mm");
|
||||
}
|
||||
|
||||
var exception = Assert.Throws<TemplateValidationException>(() => new TemplateLoader().LoadFromPackage(path));
|
||||
Assert.Contains(exception.Result.Issues, x => x.Message.Contains("Unsicherer Paketpfad"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loader_SammeltUnbekanntenPlatzhalterUndFehlendesBild()
|
||||
{
|
||||
var path = Path.Combine(_directory, "invalid.lavorlage");
|
||||
using (var archive = ZipFile.Open(path, ZipArchiveMode.Create))
|
||||
{
|
||||
Write(archive, "manifest.json", ManifestJson());
|
||||
Write(archive, "layout.tpl", "PAGE 210 297 mm\nBG fehlt.png\nTEXT 10 10 $Unbekannt");
|
||||
}
|
||||
|
||||
var exception = Assert.Throws<TemplateValidationException>(() => new TemplateLoader().LoadFromPackage(path));
|
||||
Assert.Equal(2, exception.Result.Issues.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_SammeltFehlendeUndFalscheDatentypen()
|
||||
{
|
||||
var loaded = new LoadedTemplate(new TemplateManifest
|
||||
{
|
||||
Placeholders = [new("Datum", PlaceholderType.Date, true), new("Text", PlaceholderType.Text, true)],
|
||||
}, new(210, 297, "mm", []), new Dictionary<string, byte[]>());
|
||||
|
||||
var result = new TemplateLoader().Validate(loaded,
|
||||
new Dictionary<string, PlaceholderType> { ["Datum"] = PlaceholderType.Text });
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(2, result.Issues.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Paket_RoundtripUndPdfSindGueltig()
|
||||
{
|
||||
var path = Path.Combine(_directory, "brief.lavorlage");
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = "brief", Name = "Brief",
|
||||
Placeholders = [new("Datum", PlaceholderType.Date, true), new("Text", PlaceholderType.Multiline, true)],
|
||||
};
|
||||
TemplatePackage.Create(path, manifest, """
|
||||
PAGE 210 297 mm
|
||||
TEXT 20 20 "Elternbrief" size=18 bold=true color=#1D4ED8
|
||||
TEXT 20 35 $Datum|dd.MM.yyyy size=10
|
||||
TEXTBOX 20 50 170 200 $Text size=11
|
||||
""", new Dictionary<string, byte[]>());
|
||||
var loaded = new TemplateLoader().LoadFromPackage(path);
|
||||
var pdf = new QuestTemplateRenderer().RenderToPdf(loaded, new DictionaryProvider(new Dictionary<string, PlaceholderValue>
|
||||
{
|
||||
["Datum"] = new DateValue(new DateOnly(2026, 8, 30)),
|
||||
["Text"] = new MultilineValue("Sehr geehrte Eltern,\n\ndies ist ein Testbrief."),
|
||||
}));
|
||||
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
|
||||
Assert.True(pdf.Length > 1_000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Paket_RoundtripErhaeltAuchNochNichtVerwendeteBildAssets()
|
||||
{
|
||||
var path = Path.Combine(_directory, "assets.lavorlage");
|
||||
var png = Convert.FromBase64String(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=");
|
||||
TemplatePackage.Create(path, new TemplateManifest { Id = "assets", Name = "Assets" },
|
||||
"PAGE 210 297 mm\nIMG logo.png 10 10 20 20",
|
||||
new Dictionary<string, byte[]> { ["logo.png"] = png, ["assets/siegel.png"] = png });
|
||||
|
||||
var loaded = new TemplateLoader().LoadFromPackage(path);
|
||||
|
||||
Assert.Equal(2, loaded.Assets.Count);
|
||||
Assert.Contains("assets/siegel.png", loaded.Assets.Keys);
|
||||
}
|
||||
|
||||
private static string ManifestJson() => """
|
||||
{ "schemaVersion": 1, "id": "test", "name": "Test",
|
||||
"pageSize": { "width": 210, "height": 297, "unit": "mm" },
|
||||
"layoutFile": "layout.tpl", "placeholders": [] }
|
||||
""";
|
||||
private static void Write(ZipArchive archive, string path, string content)
|
||||
{ using var writer = new StreamWriter(archive.CreateEntry(path).Open()); writer.Write(content); }
|
||||
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||
}
|
||||
|
||||
internal sealed class DictionaryProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
|
||||
{
|
||||
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Security.Cryptography;
|
||||
using QuestPDF.Drawing;
|
||||
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
/// <summary>
|
||||
/// Registers application-supplied TTF/OTF font bytes for TEXT and drawing commands without
|
||||
/// exposing QuestPDF types to the calling application.
|
||||
/// </summary>
|
||||
public static class DrawingFontRegistry
|
||||
{
|
||||
private static readonly object Gate = new();
|
||||
private static readonly HashSet<string> Registered = new(StringComparer.Ordinal);
|
||||
|
||||
public static void RegisterFont(string familyName, byte[] fontData)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(familyName) || familyName.Length > 80)
|
||||
throw new ArgumentException("Der Schriftname fehlt oder ist zu lang.", nameof(familyName));
|
||||
if (fontData.Length is 0 or > 20 * 1024 * 1024)
|
||||
throw new ArgumentException("Eine Schriftdatei muss zwischen 1 Byte und 20 MB groß sein.", nameof(fontData));
|
||||
var key = familyName + ":" + Convert.ToHexString(SHA256.HashData(fontData));
|
||||
lock (Gate)
|
||||
{
|
||||
if (!Registered.Add(key)) return;
|
||||
using var stream = new MemoryStream(fontData, writable: false);
|
||||
FontManager.RegisterFontWithCustomName(familyName, stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
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>();
|
||||
var pageTemplates = new List<PageTemplateDefinition>();
|
||||
var contentFlows = new List<ContentFlowDefinition>();
|
||||
string? currentPageName = null, currentFlowName = null;
|
||||
List<TemplateElement>? currentPageElements = null, currentFlowElements = null;
|
||||
List<FlowSlotDefinition>? currentSlots = null;
|
||||
var formatVersion = 1;
|
||||
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) continue;
|
||||
try
|
||||
{
|
||||
if (raw.StartsWith("#pragma", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var pragma = Tokenize(raw);
|
||||
Require(pragma, 2);
|
||||
switch (pragma[1].ToLowerInvariant())
|
||||
{
|
||||
case "format-version":
|
||||
Require(pragma, 3);
|
||||
if (!int.TryParse(pragma[2], out formatVersion) || formatVersion is < 1 or > 3)
|
||||
throw new FormatException("format-version muss zwischen 1 und 3 liegen.");
|
||||
break;
|
||||
case "page-template":
|
||||
Require(pragma, 3);
|
||||
if (currentPageName is not null || currentFlowName is not null)
|
||||
throw new FormatException("Verschachtelte Bereiche sind nicht erlaubt.");
|
||||
currentPageName = pragma[2]; currentPageElements = []; currentSlots = [];
|
||||
break;
|
||||
case "end-page-template":
|
||||
if (currentPageName is null || currentPageElements is null || currentSlots is null)
|
||||
throw new FormatException("Kein page-template ist geöffnet.");
|
||||
if (pageTemplates.Any(x => x.Name.Equals(currentPageName, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new FormatException($"page-template „{currentPageName}“ ist mehrfach definiert.");
|
||||
pageTemplates.Add(new(currentPageName, currentPageElements, currentSlots));
|
||||
currentPageName = null; currentPageElements = null; currentSlots = null;
|
||||
break;
|
||||
case "flow-slot":
|
||||
Require(pragma, 3);
|
||||
if (currentPageName is null || currentSlots is null)
|
||||
throw new FormatException("flow-slot muss innerhalb eines page-template stehen.");
|
||||
var slotAttributes = Attributes(pragma, 3);
|
||||
foreach (var required in new[] { "x", "y", "w", "h" })
|
||||
if (!slotAttributes.ContainsKey(required))
|
||||
throw new FormatException($"flow-slot benötigt {required}=…");
|
||||
var slot = new FlowSlotDefinition(lineNumber, pragma[2], Number(slotAttributes["x"]),
|
||||
Number(slotAttributes["y"]), Number(slotAttributes["w"]), Number(slotAttributes["h"]));
|
||||
if (slot.Width <= 0 || slot.Height <= 0) throw new FormatException("Flow-Slot muss eine positive Größe haben.");
|
||||
if (currentSlots.Any(x => x.Name.Equals(slot.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new FormatException($"flow-slot „{slot.Name}“ ist in dieser Seitenvorlage mehrfach definiert.");
|
||||
currentSlots.Add(slot);
|
||||
break;
|
||||
case "content-flow":
|
||||
Require(pragma, 3);
|
||||
if (currentPageName is not null || currentFlowName is not null)
|
||||
throw new FormatException("Verschachtelte Bereiche sind nicht erlaubt.");
|
||||
currentFlowName = pragma[2]; currentFlowElements = [];
|
||||
break;
|
||||
case "end-content-flow":
|
||||
if (currentFlowName is null || currentFlowElements is null)
|
||||
throw new FormatException("Kein content-flow ist geöffnet.");
|
||||
if (contentFlows.Any(x => x.Name.Equals(currentFlowName, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new FormatException($"content-flow „{currentFlowName}“ ist mehrfach definiert.");
|
||||
contentFlows.Add(new(currentFlowName, currentFlowElements));
|
||||
currentFlowName = null; currentFlowElements = null;
|
||||
break;
|
||||
default: throw new FormatException($"Unbekanntes Pragma „{pragma[1]}“.");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (raw.StartsWith('#')) continue;
|
||||
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.");
|
||||
|
||||
TemplateElement? parsedElement = null;
|
||||
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":
|
||||
if (currentFlowName is not null) throw new FormatException("BG ist in einem content-flow nicht erlaubt.");
|
||||
Require(tokens, 2); parsedElement = new BackgroundElement(lineNumber, tokens[1]); break;
|
||||
case "IMG":
|
||||
var flowImage = currentFlowName is not null;
|
||||
Require(tokens, flowImage ? 2 : 6);
|
||||
var imageAttributes = Attributes(tokens, flowImage ? 2 : 6);
|
||||
if (imageAttributes.TryGetValue("scale", out var scale)) Percentage(scale);
|
||||
parsedElement = flowImage
|
||||
? new ImageElement(lineNumber, tokens[1], 0, 0,
|
||||
OptionalNumber(imageAttributes, "w"), OptionalNumber(imageAttributes, "h"), imageAttributes)
|
||||
: new ImageElement(lineNumber, tokens[1], Number(tokens[2]), Number(tokens[3]),
|
||||
Number(tokens[4]), Number(tokens[5]), imageAttributes);
|
||||
break;
|
||||
case "TEXT":
|
||||
var flowText = currentFlowName is not null;
|
||||
Require(tokens, flowText ? 2 : 4);
|
||||
var textContent = tokens[flowText ? 1 : 3];
|
||||
var textRef = Reference(textContent);
|
||||
parsedElement = new TextElement(lineNumber, flowText ? 0 : Number(tokens[1]),
|
||||
flowText ? 0 : Number(tokens[2]), textContent, textRef.Name, textRef.Format,
|
||||
Attributes(tokens, flowText ? 2 : 4)); break;
|
||||
case "TEXTBOX":
|
||||
var flowBox = currentFlowName is not null;
|
||||
Require(tokens, flowBox ? 2 : 6);
|
||||
var boxContent = tokens[flowBox ? 1 : 5];
|
||||
var boxRef = Reference(boxContent);
|
||||
parsedElement = new TextBoxElement(lineNumber, flowBox ? 0 : Number(tokens[1]),
|
||||
flowBox ? 0 : Number(tokens[2]), flowBox ? 0 : Number(tokens[3]),
|
||||
flowBox ? 0 : Number(tokens[4]), boxContent, boxRef.Name, boxRef.Format,
|
||||
Attributes(tokens, flowBox ? 2 : 6)); break;
|
||||
case "FLOWBOX":
|
||||
var nestedFlowBox = currentFlowName is not null;
|
||||
Require(tokens, nestedFlowBox ? 2 : 6);
|
||||
var flowContent = tokens[nestedFlowBox ? 1 : 5];
|
||||
var flowRef = Reference(flowContent);
|
||||
parsedElement = new FlowBoxElement(lineNumber,
|
||||
nestedFlowBox ? 0 : Number(tokens[1]), nestedFlowBox ? 0 : Number(tokens[2]),
|
||||
nestedFlowBox ? 0 : Number(tokens[3]), nestedFlowBox ? 0 : Number(tokens[4]),
|
||||
flowContent, flowRef.Name, flowRef.Format, Attributes(tokens, nestedFlowBox ? 2 : 6));
|
||||
break;
|
||||
case "DRAWBOX":
|
||||
var flowDrawing = currentFlowName is not null;
|
||||
Require(tokens, flowDrawing ? 2 : 6);
|
||||
var drawingAttributes = Attributes(tokens, flowDrawing ? 2 : 6);
|
||||
parsedElement = new DrawBoxElement(lineNumber,
|
||||
flowDrawing ? 0 : Number(tokens[1]), flowDrawing ? 0 : Number(tokens[2]),
|
||||
flowDrawing ? RequiredPositiveNumber(drawingAttributes, "w") : Number(tokens[3]),
|
||||
flowDrawing ? RequiredPositiveNumber(drawingAttributes, "h") : Number(tokens[4]),
|
||||
RequiredReference(tokens[flowDrawing ? 1 : 5]), drawingAttributes);
|
||||
break;
|
||||
case "FLOWDRAWBOX":
|
||||
var nestedFlowDrawing = currentFlowName is not null;
|
||||
Require(tokens, nestedFlowDrawing ? 2 : 6);
|
||||
var flowDrawingAttributes = Attributes(tokens, nestedFlowDrawing ? 2 : 6);
|
||||
parsedElement = new FlowDrawBoxElement(lineNumber,
|
||||
nestedFlowDrawing ? 0 : Number(tokens[1]), nestedFlowDrawing ? 0 : Number(tokens[2]),
|
||||
nestedFlowDrawing ? RequiredPositiveNumber(flowDrawingAttributes, "w") : Number(tokens[3]),
|
||||
nestedFlowDrawing ? RequiredPositiveNumber(flowDrawingAttributes, "h") : Number(tokens[4]),
|
||||
RequiredReference(tokens[nestedFlowDrawing ? 1 : 5]), flowDrawingAttributes);
|
||||
break;
|
||||
case "TABLE":
|
||||
var flowTable = currentFlowName is not null;
|
||||
Require(tokens, flowTable ? 2 : 6);
|
||||
parsedElement = new TableElement(lineNumber, flowTable ? 0 : Number(tokens[1]),
|
||||
flowTable ? 0 : Number(tokens[2]), flowTable ? 0 : Number(tokens[3]),
|
||||
flowTable ? 0 : Number(tokens[4]), RequiredReference(tokens[flowTable ? 1 : 5]),
|
||||
Attributes(tokens, flowTable ? 2 : 6));
|
||||
break;
|
||||
case "CHART":
|
||||
var flowChart = currentFlowName is not null;
|
||||
Require(tokens, flowChart ? 2 : 6);
|
||||
var attributes = Attributes(tokens, flowChart ? 2 : 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.");
|
||||
parsedElement = new ChartElement(lineNumber, flowChart ? 0 : Number(tokens[1]),
|
||||
flowChart ? 0 : Number(tokens[2]), flowChart ? 0 : Number(tokens[3]),
|
||||
flowChart ? 0 : Number(tokens[4]), RequiredReference(tokens[flowChart ? 1 : 5]), chartType, attributes);
|
||||
break;
|
||||
default: throw new FormatException($"Unbekanntes Element „{tokens[0]}“.");
|
||||
}
|
||||
if (parsedElement is not null)
|
||||
{
|
||||
elements.Add(parsedElement);
|
||||
if (currentPageElements is not null) currentPageElements.Add(parsedElement);
|
||||
else if (currentFlowElements is not null) currentFlowElements.Add(parsedElement);
|
||||
else if (pageTemplates.Count > 0 || contentFlows.Count > 0 || formatVersion >= 3)
|
||||
throw new FormatException("Elemente müssen in page-template oder content-flow stehen.");
|
||||
}
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
issues.Add(new(ValidationSeverity.Error, $"Zeile {lineNumber}: {ex.Message}", lineNumber));
|
||||
}
|
||||
}
|
||||
|
||||
if (!pageSeen) issues.Add(new(ValidationSeverity.Error, "PAGE fehlt."));
|
||||
if (currentPageName is not null) issues.Add(new(ValidationSeverity.Error, $"page-template „{currentPageName}“ wurde nicht geschlossen."));
|
||||
if (currentFlowName is not null) issues.Add(new(ValidationSeverity.Error, $"content-flow „{currentFlowName}“ wurde nicht geschlossen."));
|
||||
if (pageTemplates.Count > 0 || contentFlows.Count > 0)
|
||||
{
|
||||
var firstTemplate = pageTemplates.FirstOrDefault(x => x.Name.Equals("first", StringComparison.OrdinalIgnoreCase));
|
||||
var continuationTemplate = pageTemplates.FirstOrDefault(x => x.Name.Equals("continuation", StringComparison.OrdinalIgnoreCase));
|
||||
if (firstTemplate is null)
|
||||
issues.Add(new(ValidationSeverity.Error, "Die Seitenvorlage „first“ fehlt."));
|
||||
foreach (var flow in contentFlows)
|
||||
{
|
||||
if (firstTemplate is not null && !firstTemplate.FlowSlots.Any(slot => slot.Name.Equals(flow.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
issues.Add(new(ValidationSeverity.Error, $"Für content-flow „{flow.Name}“ fehlt der gleichnamige flow-slot im Seitentyp „first“."));
|
||||
if (continuationTemplate is not null && !continuationTemplate.FlowSlots.Any(slot => slot.Name.Equals(flow.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
issues.Add(new(ValidationSeverity.Error, $"Für content-flow „{flow.Name}“ fehlt der gleichnamige flow-slot im Seitentyp „continuation“."));
|
||||
}
|
||||
foreach (var slot in pageTemplates.SelectMany(page => page.FlowSlots))
|
||||
if (slot.X < 0 || slot.Y < 0 || slot.X + slot.Width > width || slot.Y + slot.Height > height)
|
||||
issues.Add(new(ValidationSeverity.Error, $"Flow-Slot „{slot.Name}“ in Zeile {slot.Line} liegt außerhalb der Seite.", slot.Line));
|
||||
}
|
||||
if (issues.Count > 0) throw new TemplateValidationException(new(issues));
|
||||
return new(width, height, unit, elements)
|
||||
{
|
||||
FormatVersion = formatVersion,
|
||||
PageTemplates = pageTemplates,
|
||||
ContentFlows = contentFlows,
|
||||
};
|
||||
}
|
||||
|
||||
private static (string? Name, string? Format) Reference(string value)
|
||||
{
|
||||
if (SystemVariables.IsStandalone(value)) return (null, null);
|
||||
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.");
|
||||
|
||||
private static float OptionalNumber(IReadOnlyDictionary<string, string> attributes, string name) =>
|
||||
attributes.TryGetValue(name, out var value) ? Number(value) : 0;
|
||||
|
||||
private static float RequiredPositiveNumber(IReadOnlyDictionary<string, string> attributes, string name)
|
||||
{
|
||||
if (!attributes.TryGetValue(name, out var raw))
|
||||
throw new FormatException($"Das Element benötigt {name}=…");
|
||||
var value = Number(raw);
|
||||
return value > 0 ? value : throw new FormatException($"{name} muss positiv sein.");
|
||||
}
|
||||
|
||||
public static float Percentage(string value)
|
||||
{
|
||||
var normalized = value.EndsWith('%') ? value[..^1] : value;
|
||||
var result = Number(normalized);
|
||||
if (result <= 0 || result > 1000)
|
||||
throw new FormatException("scale muss zwischen 0 und 1000 Prozent liegen.");
|
||||
return result / 100f;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> Attributes(IReadOnlyList<string> tokens, int start)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = start; i < tokens.Count; i++)
|
||||
{
|
||||
var separator = tokens[i].IndexOf('=');
|
||||
if (separator <= 0 || separator == tokens[i].Length - 1)
|
||||
throw new FormatException($"Ungültiges Attribut „{tokens[i]}“.");
|
||||
result[tokens[i][..separator]] = tokens[i][(separator + 1)..];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void Require(IReadOnlyCollection<string> tokens, int minimum)
|
||||
{
|
||||
if (tokens.Count < minimum) throw new FormatException("Zu wenige Argumente.");
|
||||
}
|
||||
|
||||
internal static List<string> Tokenize(string line)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
var current = new StringBuilder();
|
||||
var quoted = false;
|
||||
var escaped = false;
|
||||
foreach (var character in line)
|
||||
{
|
||||
if (escaped) { current.Append(character); escaped = false; continue; }
|
||||
if (character == '\\' && quoted) { escaped = true; continue; }
|
||||
if (character == '"') { quoted = !quoted; continue; }
|
||||
if (char.IsWhiteSpace(character) && !quoted)
|
||||
{
|
||||
if (current.Length > 0) { tokens.Add(current.ToString()); current.Clear(); }
|
||||
}
|
||||
else current.Append(character);
|
||||
}
|
||||
if (quoted) throw new FormatException("Nicht abgeschlossenes Anführungszeichen.");
|
||||
if (current.Length > 0) tokens.Add(current.ToString());
|
||||
return tokens;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<PackageReference Include="QuestPDF" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,195 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
public enum PlaceholderType { Text, Multiline, Date, Number, Image, Table, Chart, Drawing }
|
||||
|
||||
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 abstract record DrawingCommand;
|
||||
public sealed record DrawString(float X, float Y, string Text, float FontSize = 11,
|
||||
string Color = "#000000", bool Bold = false, bool Italic = false, string FontFamily = "") : DrawingCommand;
|
||||
public enum DrawingTextAlignment { AlignLeft, AlignCenter, AlignRight }
|
||||
public sealed record DrawStringEx(float X, float Y, float Height, float Width, string Text,
|
||||
DrawingTextAlignment Alignment = DrawingTextAlignment.AlignLeft, float FontSize = 11,
|
||||
string FontFamily = "", string Color = "#000000", bool Bold = false, bool Italic = false)
|
||||
: DrawingCommand;
|
||||
public sealed record MoveTo(float X, float Y) : DrawingCommand;
|
||||
public sealed record LineTo(float X, float Y, string Color = "#000000", float StrokeWidth = 1) : DrawingCommand;
|
||||
public sealed record DrawLine(float X1, float Y1, float X2, float Y2,
|
||||
string Color = "#000000", float StrokeWidth = 1) : DrawingCommand;
|
||||
public sealed record DrawRectangle(float X, float Y, float Width, float Height,
|
||||
string StrokeColor = "#000000", float StrokeWidth = 1, string FillColor = "none") : DrawingCommand;
|
||||
public sealed record DrawImage(float X, float Y, float Width, float Height, byte[] Data, string MimeType) : DrawingCommand;
|
||||
/// <summary>Declarative drawing supplied by an external data provider. Coordinates use the template layout unit; font sizes use points.</summary>
|
||||
public sealed record DrawingValue(IReadOnlyList<DrawingCommand> Commands, float ContentHeight)
|
||||
: PlaceholderValue(PlaceholderType.Drawing);
|
||||
|
||||
/// <summary>A recorded drawing surface handed to an in-process external renderer.</summary>
|
||||
public sealed class DrawingCanvas
|
||||
{
|
||||
private readonly List<DrawingCommand> _commands = [];
|
||||
public IReadOnlyList<DrawingCommand> Commands => _commands;
|
||||
public void DrawString(float x, float y, string text, float fontSize = 11, string color = "#000000",
|
||||
bool bold = false, bool italic = false, string fontFamily = "") =>
|
||||
_commands.Add(new LehrerApp.Templating.DrawString(x, y, text, fontSize, color, bold, italic, fontFamily));
|
||||
public void DrawStringEx(float x, float y, float height, float width, string text,
|
||||
DrawingTextAlignment alignment = DrawingTextAlignment.AlignLeft, float fontSize = 11,
|
||||
string fontFamily = "", string color = "#000000", bool bold = false, bool italic = false) =>
|
||||
_commands.Add(new LehrerApp.Templating.DrawStringEx(x, y, height, width, text, alignment,
|
||||
fontSize, fontFamily, color, bold, italic));
|
||||
public void MoveTo(float x, float y) => _commands.Add(new LehrerApp.Templating.MoveTo(x, y));
|
||||
public void LineTo(float x, float y, string color = "#000000", float strokeWidth = 1) =>
|
||||
_commands.Add(new LehrerApp.Templating.LineTo(x, y, color, strokeWidth));
|
||||
public void DrawLine(float x1, float y1, float x2, float y2, string color = "#000000", float strokeWidth = 1) =>
|
||||
_commands.Add(new LehrerApp.Templating.DrawLine(x1, y1, x2, y2, color, strokeWidth));
|
||||
public void DrawRectangle(float x, float y, float width, float height, string strokeColor = "#000000",
|
||||
float strokeWidth = 1, string fillColor = "none") =>
|
||||
_commands.Add(new LehrerApp.Templating.DrawRectangle(x, y, width, height, strokeColor, strokeWidth, fillColor));
|
||||
public void DrawImage(float x, float y, float width, float height, byte[] data, string mimeType) =>
|
||||
_commands.Add(new LehrerApp.Templating.DrawImage(x, y, width, height, data, mimeType));
|
||||
}
|
||||
|
||||
public sealed class DrawingPageContext(float width, float height, int pageNumber, DrawingCanvas canvas, object? state)
|
||||
{
|
||||
public float Width { get; } = width;
|
||||
public float Height { get; } = height;
|
||||
public int PageNumber { get; } = pageNumber;
|
||||
public DrawingCanvas Canvas { get; } = canvas;
|
||||
public object? State { get; set; } = state;
|
||||
}
|
||||
|
||||
public delegate bool DrawingPageCallback(DrawingPageContext context);
|
||||
|
||||
/// <summary>
|
||||
/// In-process callback drawing. The callback returns true when finished or false to request another box.
|
||||
/// State is carried across callbacks. It is recorded before QuestPDF performs layout and is never executed by a package.
|
||||
/// </summary>
|
||||
public sealed record PagedDrawingValue(DrawingPageCallback DrawPage, object? InitialState = null,
|
||||
int MaxPages = 1_000) : PlaceholderValue(PlaceholderType.Drawing);
|
||||
|
||||
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,
|
||||
bool IsConstant = false, string? ConstantValue = null,
|
||||
bool Bold = false, bool Italic = false, bool Underline = 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 string? ContinuationLayoutFile { get; set; }
|
||||
public string MetadataFile { get; set; } = TemplateMetadataText.FileName;
|
||||
[JsonIgnore]
|
||||
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
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 FlowBoxElement(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 DrawBoxElement(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 FlowDrawBoxElement(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 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);
|
||||
|
||||
public sealed record FlowSlotDefinition(int Line, string Name, float X, float Y, float Width, float Height);
|
||||
public sealed record PageTemplateDefinition(string Name, IReadOnlyList<TemplateElement> Elements,
|
||||
IReadOnlyList<FlowSlotDefinition> FlowSlots);
|
||||
public sealed record ContentFlowDefinition(string Name, IReadOnlyList<TemplateElement> Elements);
|
||||
|
||||
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 int FormatVersion { get; init; } = 1;
|
||||
public IReadOnlyList<PageTemplateDefinition> PageTemplates { get; init; } = [];
|
||||
public IReadOnlyList<ContentFlowDefinition> ContentFlows { get; init; } = [];
|
||||
public bool UsesPageTemplates => PageTemplates.Count > 0 || ContentFlows.Count > 0;
|
||||
}
|
||||
|
||||
public sealed record LoadedTemplate(TemplateManifest Manifest, TemplateLayout Layout,
|
||||
IReadOnlyDictionary<string, byte[]> Assets, string SourceName = "",
|
||||
TemplateLayout? ContinuationLayout = null);
|
||||
|
||||
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,725 @@
|
||||
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)
|
||||
{
|
||||
return RenderPagesToPng(template, data, dpi).First();
|
||||
}
|
||||
|
||||
public IReadOnlyList<byte[]> RenderPagesToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
|
||||
{
|
||||
var settings = new ImageGenerationSettings { ImageFormat = ImageFormat.Png, RasterDpi = dpi };
|
||||
return BuildDocument(template, ValidateData(template, data)).GenerateImages(settings).ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, PlaceholderValue> ValidateData(LoadedTemplate template,
|
||||
ITemplateDataProvider provider)
|
||||
{
|
||||
var values = TemplateDataResolver.Resolve(template.Manifest, 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)
|
||||
{
|
||||
values = PreparePagedDrawings(template, values);
|
||||
return template.Layout.UsesPageTemplates
|
||||
? BuildFlowDocument(template, values)
|
||||
: BuildLegacyDocument(template, values);
|
||||
}
|
||||
|
||||
private static IDocument BuildLegacyDocument(LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||
{
|
||||
var flowBox = template.Layout.Elements.SingleOrDefault(x => x is FlowBoxElement or FlowDrawBoxElement);
|
||||
return 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 =>
|
||||
{
|
||||
if (flowBox is null)
|
||||
layers.PrimaryLayer().Width(UnitConverter.Points(template.Layout.Width, template.Layout.Unit))
|
||||
.Height(UnitConverter.Points(template.Layout.Height, template.Layout.Unit)).Background(Colors.White);
|
||||
else
|
||||
RenderLegacyFlowElement(layers.PrimaryLayer(), flowBox, template, values);
|
||||
|
||||
RenderStaticLayers(layers, template.Layout, template, values,
|
||||
template.ContinuationLayout is null ? null : static container => container.ShowOnce());
|
||||
if (template.ContinuationLayout is { } continuation)
|
||||
RenderStaticLayers(layers, continuation, template, values, static container => container.SkipOnce());
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, PlaceholderValue> PreparePagedDrawings(LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||
{
|
||||
var result = new Dictionary<string, PlaceholderValue>(values, StringComparer.Ordinal);
|
||||
var layouts = template.ContinuationLayout is null
|
||||
? new[] { template.Layout }
|
||||
: new[] { template.Layout, template.ContinuationLayout };
|
||||
var drawingElements = layouts.SelectMany(x => x.Elements)
|
||||
.Where(x => x is DrawBoxElement or FlowDrawBoxElement).ToList();
|
||||
foreach (var group in drawingElements.GroupBy(x => x switch
|
||||
{ DrawBoxElement box => box.Placeholder, FlowDrawBoxElement box => box.Placeholder, _ => "" }))
|
||||
{
|
||||
if (values.GetValueOrDefault(group.Key) is not PagedDrawingValue) continue;
|
||||
var signatures = group.Select(x => (x.GetType(), x.Width, x.Height)).Distinct().Count();
|
||||
if (signatures > 1)
|
||||
throw new InvalidDataException($"Der Callback-Platzhalter „{group.Key}“ wird in unterschiedlich großen oder unterschiedlichen Zeichenboxen verwendet.");
|
||||
}
|
||||
foreach (var element in drawingElements)
|
||||
{
|
||||
var placeholder = element switch
|
||||
{ DrawBoxElement box => box.Placeholder, FlowDrawBoxElement box => box.Placeholder, _ => null };
|
||||
if (placeholder is null || values.GetValueOrDefault(placeholder) is not PagedDrawingValue callback) continue;
|
||||
if (result.GetValueOrDefault(placeholder) is RecordedDrawingValue) continue;
|
||||
var pages = element is FlowDrawBoxElement
|
||||
? DrawingElementRenderer.RecordPages(callback, element.Width, element.Height, callback.MaxPages)
|
||||
: [DrawingElementRenderer.RecordSingle(callback, element.Width, element.Height)];
|
||||
result[placeholder] = new RecordedDrawingValue(pages);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void RenderStaticLayers(LayersDescriptor layers, TemplateLayout layout, LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values, Func<IContainer, IContainer>? visibility)
|
||||
{
|
||||
foreach (var element in layout.Elements.Where(x => x is not (FlowBoxElement or FlowDrawBoxElement)))
|
||||
{
|
||||
var current = element;
|
||||
layers.Layer().Element(container => RenderElement(
|
||||
visibility is null ? container : visibility(container), current, template, values));
|
||||
}
|
||||
}
|
||||
|
||||
private static void RenderLegacyFlowElement(IContainer container, TemplateElement element, LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||
{
|
||||
var unit = template.Layout.Unit;
|
||||
container
|
||||
.PaddingLeft(UnitConverter.Points(element.X, unit))
|
||||
.PaddingTop(UnitConverter.Points(element.Y, unit))
|
||||
.PaddingRight(UnitConverter.Points(Math.Max(0, template.Layout.Width - element.X - element.Width), unit))
|
||||
.PaddingBottom(UnitConverter.Points(Math.Max(0, template.Layout.Height - element.Y - element.Height), unit))
|
||||
.Element(content =>
|
||||
{
|
||||
if (element is FlowBoxElement box)
|
||||
RenderResolvedText(content, box.Content, box.Placeholder, box.Format,
|
||||
values, template.Manifest, box.Attributes);
|
||||
else if (element is FlowDrawBoxElement drawing
|
||||
&& values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue)
|
||||
DrawingElementRenderer.RenderFlow(content, drawingValue, drawing.Width, drawing.Height, unit);
|
||||
});
|
||||
}
|
||||
|
||||
private static IDocument BuildFlowDocument(LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||
{
|
||||
var firstPage = template.Layout.PageTemplates.First(x =>
|
||||
x.Name.Equals("first", StringComparison.OrdinalIgnoreCase));
|
||||
var continuationPage = template.Layout.PageTemplates.FirstOrDefault(x =>
|
||||
x.Name.Equals("continuation", StringComparison.OrdinalIgnoreCase)) ?? firstPage;
|
||||
var populatedFlows = template.Layout.ContentFlows.Where(x => x.Elements.Count > 0).ToList();
|
||||
if (populatedFlows.Count > 1)
|
||||
throw new InvalidDataException("Aktuell darf genau ein Content-Flow Inhalt enthalten. Weitere Flow-Slots können bereits gestaltet werden, parallele paginierende Flows folgen in einer späteren Formatstufe.");
|
||||
var primaryFlow = populatedFlows.FirstOrDefault() ?? template.Layout.ContentFlows.FirstOrDefault();
|
||||
|
||||
return Document.Create(document =>
|
||||
{
|
||||
document.Page(page =>
|
||||
{
|
||||
var pageWidth = UnitConverter.Points(template.Layout.Width, template.Layout.Unit);
|
||||
var pageHeight = UnitConverter.Points(template.Layout.Height, template.Layout.Unit);
|
||||
page.Size(pageWidth, pageHeight);
|
||||
page.Margin(0);
|
||||
ConfigurePageCanvas(page.Background(), firstPage, continuationPage, template, values);
|
||||
|
||||
if (primaryFlow is null)
|
||||
{
|
||||
page.Content().Height(1);
|
||||
return;
|
||||
}
|
||||
|
||||
var firstSlot = FindSlot(firstPage, primaryFlow.Name);
|
||||
var continuationSlot = FindSlot(continuationPage, primaryFlow.Name) ?? firstSlot;
|
||||
if (firstSlot is null)
|
||||
throw new InvalidDataException($"Für content-flow „{primaryFlow.Name}“ fehlt ein flow-slot auf der ersten Seite.");
|
||||
|
||||
if (Math.Abs(firstSlot.X - continuationSlot!.X) > 0.01f ||
|
||||
Math.Abs(firstSlot.Width - continuationSlot.Width) > 0.01f)
|
||||
throw new InvalidDataException($"Der primäre Flow „{primaryFlow.Name}“ muss vorerst auf erster und Folgeseite dieselbe X-Position und Breite besitzen.");
|
||||
|
||||
var left = UnitConverter.Points(firstSlot.X, template.Layout.Unit);
|
||||
var right = UnitConverter.Points(template.Layout.Width - firstSlot.X - firstSlot.Width, template.Layout.Unit);
|
||||
page.MarginLeft(left);
|
||||
page.MarginRight(right);
|
||||
ConfigureVariableVerticalSlot(page, firstSlot, continuationSlot, template.Layout);
|
||||
page.Content().Column(column => RenderFlowColumn(column, primaryFlow.Elements, template, values));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureVariableVerticalSlot(PageDescriptor page, FlowSlotDefinition first,
|
||||
FlowSlotDefinition continuation, TemplateLayout layout)
|
||||
{
|
||||
var firstTop = UnitConverter.Points(first.Y, layout.Unit);
|
||||
var continuationTop = UnitConverter.Points(continuation.Y, layout.Unit);
|
||||
var firstBottom = UnitConverter.Points(layout.Height - first.Y - first.Height, layout.Unit);
|
||||
var continuationBottom = UnitConverter.Points(layout.Height - continuation.Y - continuation.Height, layout.Unit);
|
||||
page.Header().Column(column =>
|
||||
{
|
||||
column.Item().ShowOnce().Height(firstTop);
|
||||
column.Item().SkipOnce().Height(continuationTop);
|
||||
});
|
||||
page.Footer().Column(column =>
|
||||
{
|
||||
column.Item().ShowOnce().Height(firstBottom);
|
||||
column.Item().SkipOnce().Height(continuationBottom);
|
||||
});
|
||||
}
|
||||
|
||||
private static FlowSlotDefinition? FindSlot(PageTemplateDefinition page, string name) =>
|
||||
page.FlowSlots.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static void ConfigurePageCanvas(IContainer canvas, PageTemplateDefinition first,
|
||||
PageTemplateDefinition continuation, LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||
{
|
||||
canvas.Layers(layers =>
|
||||
{
|
||||
layers.PrimaryLayer().Background(Colors.White);
|
||||
foreach (var element in first.Elements)
|
||||
{
|
||||
var current = element;
|
||||
layers.Layer().ShowOnce().Element(root => RenderElement(root, current, template, values));
|
||||
}
|
||||
foreach (var element in continuation.Elements)
|
||||
{
|
||||
var current = element;
|
||||
layers.Layer().SkipOnce().Element(root => RenderElement(root, current, template, values));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void RenderFlowColumn(ColumnDescriptor column, IReadOnlyList<TemplateElement> elements,
|
||||
LoadedTemplate template, IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||
{
|
||||
for (var index = 0; index < elements.Count; index++)
|
||||
{
|
||||
var element = elements[index];
|
||||
var gap = UnitConverter.Points(ParseFloat(element.Attributes, "gap", 0), template.Layout.Unit);
|
||||
var keepWithNext = ParseBool(element.Attributes, "keep-with-next") && index + 1 < elements.Count;
|
||||
if (keepWithNext)
|
||||
{
|
||||
var next = elements[++index];
|
||||
column.Item().PaddingTop(gap).PreventPageBreak().Column(group =>
|
||||
{
|
||||
group.Item().Element(container => RenderFlowElement(container, element, template, values));
|
||||
var nextGap = UnitConverter.Points(ParseFloat(next.Attributes, "gap", 0), template.Layout.Unit);
|
||||
group.Item().PaddingTop(nextGap).Element(container => RenderFlowElement(container, next, template, values));
|
||||
});
|
||||
}
|
||||
else
|
||||
column.Item().PaddingTop(gap).Element(container => RenderFlowElement(container, element, template, values));
|
||||
}
|
||||
}
|
||||
|
||||
private static void RenderFlowElement(IContainer container, TemplateElement element, LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||
{
|
||||
switch (element)
|
||||
{
|
||||
case TextElement text:
|
||||
RenderResolvedText(container, text.Content, text.Placeholder, text.Format,
|
||||
values, template.Manifest, text.Attributes);
|
||||
break;
|
||||
case TextBoxElement box:
|
||||
RenderResolvedText(container, box.Content, box.Placeholder, box.Format,
|
||||
values, template.Manifest, box.Attributes);
|
||||
break;
|
||||
case FlowBoxElement box:
|
||||
RenderResolvedText(container, box.Content, box.Placeholder, box.Format,
|
||||
values, template.Manifest, box.Attributes);
|
||||
break;
|
||||
case DrawBoxElement drawing when values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue:
|
||||
DrawingElementRenderer.RenderFixed(
|
||||
container.Width(UnitConverter.Points(drawing.Width, template.Layout.Unit))
|
||||
.Height(UnitConverter.Points(drawing.Height, template.Layout.Unit)),
|
||||
drawingValue, drawing.Width, drawing.Height, template.Layout.Unit);
|
||||
break;
|
||||
case FlowDrawBoxElement drawing when values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue:
|
||||
DrawingElementRenderer.RenderFlow(container, drawingValue,
|
||||
drawing.Width, drawing.Height, template.Layout.Unit);
|
||||
break;
|
||||
case ImageElement image:
|
||||
var imageContainer = container;
|
||||
if (image.Width > 0) imageContainer = imageContainer.Width(UnitConverter.Points(image.Width, template.Layout.Unit));
|
||||
if (image.Height > 0) imageContainer = imageContainer.Height(UnitConverter.Points(image.Height, template.Layout.Unit));
|
||||
imageContainer.Image(GetAsset(template, image.Path)).FitArea();
|
||||
break;
|
||||
case TableElement table when values.GetValueOrDefault(table.Placeholder) is TableValue tableValue:
|
||||
TableElementRenderer.Render(container, tableValue, table.Attributes);
|
||||
break;
|
||||
case ChartElement chart when values.GetValueOrDefault(chart.Placeholder) is ChartValue chartValue:
|
||||
var chartHeight = UnitConverter.Points(ParseFloat(chart.Attributes, "h", 50), template.Layout.Unit);
|
||||
ChartElementRenderer.Render(container.Height(chartHeight), chartValue, chart.ChartType, chart.Attributes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
RenderResolvedText(textContainer, text.Content, text.Placeholder, text.Format,
|
||||
values, template.Manifest, text.Attributes);
|
||||
break;
|
||||
case TextBoxElement box:
|
||||
RenderResolvedText(Position(root, box, unit).Shrink(), box.Content, box.Placeholder, box.Format,
|
||||
values, template.Manifest, box.Attributes);
|
||||
break;
|
||||
case FlowBoxElement box:
|
||||
RenderResolvedText(Position(root, box, unit).Shrink(), box.Content, box.Placeholder, box.Format,
|
||||
values, template.Manifest, box.Attributes);
|
||||
break;
|
||||
case DrawBoxElement drawing:
|
||||
if (values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue)
|
||||
DrawingElementRenderer.RenderFixed(Position(root, drawing, unit), drawingValue,
|
||||
drawing.Width, drawing.Height, unit);
|
||||
break;
|
||||
case FlowDrawBoxElement drawing:
|
||||
if (values.GetValueOrDefault(drawing.Placeholder) is { } flowDrawingValue)
|
||||
DrawingElementRenderer.RenderFixed(Position(root, drawing, unit), flowDrawingValue,
|
||||
drawing.Width, drawing.Height, unit);
|
||||
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() };
|
||||
if (SystemVariables.Contains(content))
|
||||
{
|
||||
aligned.Text(text =>
|
||||
{
|
||||
text.DefaultTextStyle(style => ApplyTextStyle(style, attributes));
|
||||
AppendSystemVariables(text, content);
|
||||
});
|
||||
return;
|
||||
}
|
||||
var descriptor = aligned.Text(content);
|
||||
descriptor.FontSize(ParseFloat(attributes, "size", 11));
|
||||
if (ParseBool(attributes, "bold")) descriptor.SemiBold();
|
||||
if (ParseBool(attributes, "italic")) descriptor.Italic();
|
||||
if (ParseBool(attributes, "underline")) descriptor.Underline();
|
||||
if (attributes.TryGetValue("color", out var color)) descriptor.FontColor(color);
|
||||
}
|
||||
|
||||
private static void RenderResolvedText(IContainer container, string content, string? placeholder, string? format,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values, TemplateManifest manifest,
|
||||
IReadOnlyDictionary<string, string> elementAttributes)
|
||||
{
|
||||
var attributes = TextAttributes(manifest, placeholder, elementAttributes);
|
||||
var definition = placeholder is null ? null
|
||||
: manifest.Placeholders.FirstOrDefault(x => x.Name.Equals(placeholder, StringComparison.Ordinal));
|
||||
if (definition is { IsConstant: true, Type: PlaceholderType.Text or PlaceholderType.Multiline })
|
||||
{
|
||||
RenderRichText(container, TemplateRichText.Parse(definition.ConstantValue ?? ""), values, attributes);
|
||||
return;
|
||||
}
|
||||
RenderText(container, ResolveContent(content, placeholder, format, values), attributes);
|
||||
}
|
||||
|
||||
private static void RenderRichText(IContainer container, IReadOnlyList<TemplateRichTextRun> runs,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values, IReadOnlyDictionary<string, string> attributes)
|
||||
{
|
||||
var aligned = attributes.GetValueOrDefault("align", "left").ToLowerInvariant() switch
|
||||
{ "center" => container.AlignCenter(), "right" => container.AlignRight(), _ => container.AlignLeft() };
|
||||
var fontSize = ParseFloat(attributes, "size", 11);
|
||||
var globalBold = ParseBool(attributes, "bold");
|
||||
var globalItalic = ParseBool(attributes, "italic");
|
||||
var globalUnderline = ParseBool(attributes, "underline");
|
||||
attributes.TryGetValue("color", out var color);
|
||||
aligned.Text(text =>
|
||||
{
|
||||
foreach (var run in runs)
|
||||
{
|
||||
var content = run.Placeholder is null ? run.Text
|
||||
: values.TryGetValue(run.Placeholder, out var value) ? Format(value, run.Format) : "";
|
||||
foreach (var span in AppendSystemVariables(text, content))
|
||||
{
|
||||
span.FontSize(fontSize);
|
||||
if (globalBold || run.Bold) span.SemiBold();
|
||||
if (globalItalic || run.Italic) span.Italic();
|
||||
if (globalUnderline || run.Underline) span.Underline();
|
||||
if (color is not null) span.FontColor(color);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static TextStyle ApplyTextStyle(TextStyle style, IReadOnlyDictionary<string, string> attributes)
|
||||
{
|
||||
style = style.FontSize(ParseFloat(attributes, "size", 11));
|
||||
if (ParseBool(attributes, "bold")) style = style.SemiBold();
|
||||
if (ParseBool(attributes, "italic")) style = style.Italic();
|
||||
if (ParseBool(attributes, "underline")) style = style.Underline();
|
||||
if (attributes.TryGetValue("color", out var color)) style = style.FontColor(color);
|
||||
return style;
|
||||
}
|
||||
|
||||
private static IEnumerable<TextSpanDescriptor> AppendSystemVariables(TextDescriptor text, string content)
|
||||
{
|
||||
foreach (var part in SystemVariables.Split(content))
|
||||
{
|
||||
yield return part.Name switch
|
||||
{
|
||||
SystemVariables.CurrentPage => text.CurrentPageNumber(),
|
||||
SystemVariables.MaximumPageNumber => text.TotalPages(),
|
||||
SystemVariables.Today => text.Span(DateTime.Today.ToString("d", CultureInfo.GetCultureInfo("de-DE"))),
|
||||
_ => text.Span(part.Text),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> TextAttributes(TemplateManifest manifest, string? placeholder,
|
||||
IReadOnlyDictionary<string, string> elementAttributes)
|
||||
{
|
||||
if (placeholder is null) return elementAttributes;
|
||||
var definition = manifest.Placeholders.FirstOrDefault(x => x.Name.Equals(placeholder, StringComparison.Ordinal));
|
||||
if (definition is null || (!definition.Bold && !definition.Italic && !definition.Underline)) return elementAttributes;
|
||||
var result = new Dictionary<string, string>(elementAttributes, StringComparer.OrdinalIgnoreCase);
|
||||
if (definition.Bold) result["bold"] = "true";
|
||||
if (definition.Italic) result["italic"] = "true";
|
||||
if (definition.Underline) result["underline"] = "true";
|
||||
return result;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
internal sealed record RecordedDrawingValue(IReadOnlyList<IReadOnlyList<DrawingCommand>> Pages)
|
||||
: PlaceholderValue(PlaceholderType.Drawing);
|
||||
|
||||
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 DrawingElementRenderer
|
||||
{
|
||||
public static void RenderFixed(IContainer container, PlaceholderValue value, float width, float height, string unit)
|
||||
{
|
||||
var drawing = value switch
|
||||
{
|
||||
DrawingValue direct => direct,
|
||||
RecordedDrawingValue recorded => new DrawingValue(recorded.Pages.FirstOrDefault() ?? [], height),
|
||||
_ => throw new InvalidDataException("DRAWBOX erwartet einen Drawing-Platzhalter."),
|
||||
};
|
||||
container.Svg(BuildSvg(drawing, width, height, 0, unit));
|
||||
}
|
||||
|
||||
public static void RenderFlow(IContainer container, PlaceholderValue value, float width, float pageHeight, string unit)
|
||||
{
|
||||
var pages = value switch
|
||||
{
|
||||
DrawingValue direct => Slice(direct, pageHeight),
|
||||
RecordedDrawingValue recorded => recorded.Pages
|
||||
.Select(commands => new DrawingValue(commands, pageHeight)).ToList(),
|
||||
_ => throw new InvalidDataException("FLOWDRAWBOX erwartet einen Drawing-Platzhalter."),
|
||||
};
|
||||
container.Column(column =>
|
||||
{
|
||||
for (var page = 0; page < pages.Count; page++)
|
||||
{
|
||||
if (page > 0) column.Item().PageBreak();
|
||||
var offset = value is DrawingValue ? page * pageHeight : 0;
|
||||
column.Item().Height(UnitConverter.Points(pageHeight, unit)).Svg(
|
||||
BuildSvg(pages[page], width, pageHeight, offset, unit));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static List<DrawingValue> Slice(DrawingValue value, float pageHeight)
|
||||
{
|
||||
var pageCount = Math.Max(1, (int)Math.Ceiling(Math.Max(0, value.ContentHeight) / pageHeight));
|
||||
return Enumerable.Repeat(value, pageCount).ToList();
|
||||
}
|
||||
|
||||
internal static List<IReadOnlyList<DrawingCommand>> RecordPages(PagedDrawingValue value,
|
||||
float width, float height, int maxPages)
|
||||
{
|
||||
if (maxPages is < 1 or > 10_000)
|
||||
throw new InvalidDataException("PagedDrawingValue.MaxPages muss zwischen 1 und 10000 liegen.");
|
||||
var pages = new List<IReadOnlyList<DrawingCommand>>();
|
||||
var state = value.InitialState;
|
||||
for (var pageNumber = 1; pageNumber <= maxPages; pageNumber++)
|
||||
{
|
||||
var canvas = new DrawingCanvas();
|
||||
var context = new DrawingPageContext(width, height, pageNumber, canvas, state);
|
||||
var finished = value.DrawPage(context);
|
||||
pages.Add(canvas.Commands.ToList());
|
||||
state = context.State;
|
||||
if (finished) return pages;
|
||||
}
|
||||
throw new InvalidDataException($"Der Zeichen-Callback war nach {maxPages} Seiten noch nicht beendet.");
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<DrawingCommand> RecordSingle(PagedDrawingValue value, float width, float height)
|
||||
{
|
||||
var canvas = new DrawingCanvas();
|
||||
value.DrawPage(new DrawingPageContext(width, height, 1, canvas, value.InitialState));
|
||||
return canvas.Commands.ToList();
|
||||
}
|
||||
|
||||
private static string BuildSvg(DrawingValue value, float width, float height, float verticalOffset, string unit)
|
||||
{
|
||||
if (!float.IsFinite(value.ContentHeight) || value.ContentHeight < 0)
|
||||
throw new InvalidDataException("DrawingValue.ContentHeight muss eine endliche, nichtnegative Zahl sein.");
|
||||
if (value.Commands.Count > 100_000)
|
||||
throw new InvalidDataException("DrawingValue enthält zu viele Zeichenbefehle.");
|
||||
var svg = new StringBuilder();
|
||||
svg.Append(CultureInfo.InvariantCulture,
|
||||
$"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 {verticalOffset} {width} {height}\" overflow=\"hidden\">");
|
||||
float? currentX = null, currentY = null;
|
||||
foreach (var command in value.Commands)
|
||||
{
|
||||
switch (command)
|
||||
{
|
||||
case MoveTo move:
|
||||
Validate(move.X, move.Y); currentX = move.X; currentY = move.Y;
|
||||
break;
|
||||
case LineTo line when currentX is not null && currentY is not null:
|
||||
Validate(line.X, line.Y, line.StrokeWidth); Positive(line.StrokeWidth);
|
||||
AppendLine(svg, currentX.Value, currentY.Value, line.X, line.Y, line.Color, line.StrokeWidth);
|
||||
currentX = line.X; currentY = line.Y;
|
||||
break;
|
||||
case LineTo:
|
||||
throw new InvalidDataException("LineTo benötigt ein vorheriges MoveTo.");
|
||||
case DrawLine line:
|
||||
Validate(line.X1, line.Y1, line.X2, line.Y2, line.StrokeWidth); Positive(line.StrokeWidth);
|
||||
AppendLine(svg, line.X1, line.Y1, line.X2, line.Y2, line.Color, line.StrokeWidth);
|
||||
break;
|
||||
case DrawRectangle rectangle:
|
||||
Validate(rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height, rectangle.StrokeWidth);
|
||||
Positive(rectangle.Width, rectangle.Height, rectangle.StrokeWidth);
|
||||
svg.Append(CultureInfo.InvariantCulture,
|
||||
$"<rect x=\"{rectangle.X}\" y=\"{rectangle.Y}\" width=\"{rectangle.Width}\" height=\"{rectangle.Height}\" stroke=\"{Attribute(rectangle.StrokeColor)}\" stroke-width=\"{rectangle.StrokeWidth}\" fill=\"{Attribute(rectangle.FillColor)}\"/>");
|
||||
break;
|
||||
case DrawString text:
|
||||
Validate(text.X, text.Y, text.FontSize);
|
||||
Positive(text.FontSize);
|
||||
var localFontSize = text.FontSize / UnitConverter.Points(1, unit);
|
||||
svg.Append(CultureInfo.InvariantCulture,
|
||||
$"<text x=\"{text.X}\" y=\"{text.Y + localFontSize}\" font-size=\"{localFontSize}\" fill=\"{Attribute(text.Color)}\" font-family=\"{FontFamily(text.FontFamily)}\" font-weight=\"{(text.Bold ? "600" : "400")}\" font-style=\"{(text.Italic ? "italic" : "normal")}\">{SecurityElement.Escape(text.Text)}</text>");
|
||||
break;
|
||||
case DrawStringEx text:
|
||||
Validate(text.X, text.Y, text.Height, text.Width, text.FontSize);
|
||||
Positive(text.Height, text.Width, text.FontSize);
|
||||
AppendTextBox(svg, text, unit);
|
||||
break;
|
||||
case DrawImage image:
|
||||
Validate(image.X, image.Y, image.Width, image.Height);
|
||||
Positive(image.Width, image.Height);
|
||||
if (image.MimeType is not ("image/png" or "image/jpeg"))
|
||||
throw new InvalidDataException("DrawImage unterstützt nur PNG und JPEG.");
|
||||
if (image.Data.Length > 20 * 1024 * 1024)
|
||||
throw new InvalidDataException("Ein DrawImage-Bild darf höchstens 20 MB groß sein.");
|
||||
svg.Append(CultureInfo.InvariantCulture,
|
||||
$"<image x=\"{image.X}\" y=\"{image.Y}\" width=\"{image.Width}\" height=\"{image.Height}\" href=\"data:{image.MimeType};base64,{Convert.ToBase64String(image.Data)}\" preserveAspectRatio=\"xMidYMid meet\"/>");
|
||||
break;
|
||||
default: throw new InvalidDataException($"Unbekannter Zeichenbefehl {command.GetType().Name}.");
|
||||
}
|
||||
}
|
||||
return svg.Append("</svg>").ToString();
|
||||
}
|
||||
|
||||
private static void AppendLine(StringBuilder svg, float x1, float y1, float x2, float y2,
|
||||
string color, float strokeWidth) => svg.Append(CultureInfo.InvariantCulture,
|
||||
$"<line x1=\"{x1}\" y1=\"{y1}\" x2=\"{x2}\" y2=\"{y2}\" stroke=\"{Attribute(color)}\" stroke-width=\"{strokeWidth}\"/>");
|
||||
|
||||
private static string Attribute(string value)
|
||||
{
|
||||
if (value != "none" && !System.Text.RegularExpressions.Regex.IsMatch(value,
|
||||
@"^(#[0-9a-fA-F]{3,8}|[a-zA-Z]{1,24})$"))
|
||||
throw new InvalidDataException($"Ungültiger Zeichenfarbwert „{value}“.");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string FontFamily(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "sans-serif";
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(value, @"^[\p{L}\p{N} _.,-]{1,80}$"))
|
||||
throw new InvalidDataException($"Ungültige Schriftfamilie „{value}“.");
|
||||
return SecurityElement.Escape(value) ?? "sans-serif";
|
||||
}
|
||||
|
||||
private static void AppendTextBox(StringBuilder svg, DrawStringEx text, string unit)
|
||||
{
|
||||
var fontSize = text.FontSize / UnitConverter.Points(1, unit);
|
||||
var (x, anchor) = text.Alignment switch
|
||||
{
|
||||
DrawingTextAlignment.AlignCenter => (text.X + text.Width / 2, "middle"),
|
||||
DrawingTextAlignment.AlignRight => (text.X + text.Width, "end"),
|
||||
_ => (text.X, "start"),
|
||||
};
|
||||
var clipId = "clip" + Guid.NewGuid().ToString("N");
|
||||
svg.Append(CultureInfo.InvariantCulture,
|
||||
$"<defs><clipPath id=\"{clipId}\"><rect x=\"{text.X}\" y=\"{text.Y}\" width=\"{text.Width}\" height=\"{text.Height}\"/></clipPath></defs>");
|
||||
svg.Append(CultureInfo.InvariantCulture,
|
||||
$"<text x=\"{x}\" y=\"{text.Y + fontSize}\" text-anchor=\"{anchor}\" clip-path=\"url(#{clipId})\" font-size=\"{fontSize}\" fill=\"{Attribute(text.Color)}\" font-family=\"{FontFamily(text.FontFamily)}\" font-weight=\"{(text.Bold ? "600" : "400")}\" font-style=\"{(text.Italic ? "italic" : "normal")}\">");
|
||||
var lines = text.Text.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
|
||||
for (var index = 0; index < lines.Length; index++)
|
||||
svg.Append(CultureInfo.InvariantCulture,
|
||||
$"<tspan x=\"{x}\" dy=\"{(index == 0 ? 0 : fontSize * 1.2f)}\">{SecurityElement.Escape(lines[index])}</tspan>");
|
||||
svg.Append("</text>");
|
||||
}
|
||||
|
||||
private static void Validate(params float[] values)
|
||||
{
|
||||
if (values.Any(x => !float.IsFinite(x))) throw new InvalidDataException("Zeichenkoordinaten müssen endlich sein.");
|
||||
}
|
||||
|
||||
private static void Positive(params float[] values)
|
||||
{
|
||||
if (values.Any(x => x < 0)) throw new InvalidDataException("Zeichengrößen dürfen nicht negativ sein.");
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ChartElementRenderer
|
||||
{
|
||||
public static void Render(IContainer container, ChartValue value, string chartType,
|
||||
IReadOnlyDictionary<string, string> attributes)
|
||||
{
|
||||
var svg = BuildSvg(value, chartType);
|
||||
container.Svg(svg);
|
||||
}
|
||||
|
||||
private static string BuildSvg(ChartValue value, string chartType)
|
||||
{
|
||||
var points = value.Series.SelectMany(x => x.Points).ToList();
|
||||
if (points.Count == 0) return "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 600 200\"/>";
|
||||
var max = Math.Max(1m, points.Max(x => x.Y));
|
||||
var colors = new[] { "#2563EB", "#DC2626", "#059669", "#7C3AED" };
|
||||
var svg = new StringBuilder("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 600 200\">");
|
||||
svg.Append("<line x1=\"40\" y1=\"170\" x2=\"590\" y2=\"170\" stroke=\"#94A3B8\"/><line x1=\"40\" y1=\"10\" x2=\"40\" y2=\"170\" stroke=\"#94A3B8\"/>");
|
||||
for (var seriesIndex = 0; seriesIndex < value.Series.Count; seriesIndex++)
|
||||
{
|
||||
var series = value.Series[seriesIndex];
|
||||
var color = colors[seriesIndex % colors.Length];
|
||||
var step = 530d / Math.Max(1, series.Points.Count);
|
||||
var coordinates = new List<string>();
|
||||
for (var i = 0; i < series.Points.Count; i++)
|
||||
{
|
||||
var x = 50 + i * step + step / 2; var y = 165 - (double)(series.Points[i].Y / max) * 145;
|
||||
if (chartType == "bar") svg.Append(CultureInfo.InvariantCulture,
|
||||
$"<rect x=\"{x - step * .28:F1}\" y=\"{y:F1}\" width=\"{step * .56:F1}\" height=\"{165 - y:F1}\" fill=\"{color}\" opacity=\".8\"/>");
|
||||
else coordinates.Add(FormattableString.Invariant($"{x:F1},{y:F1}"));
|
||||
svg.Append($"<text x=\"{x:F1}\" y=\"188\" font-size=\"10\" text-anchor=\"middle\" fill=\"#475569\">{SecurityElement.Escape(series.Points[i].X)}</text>");
|
||||
}
|
||||
if (chartType == "line") svg.Append($"<polyline points=\"{string.Join(' ', coordinates)}\" fill=\"none\" stroke=\"{color}\" stroke-width=\"3\"/>");
|
||||
}
|
||||
return svg.Append("</svg>").ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
# LehrerApp Templating
|
||||
|
||||
## Dynamische Zeichenflächen für externe Apps
|
||||
|
||||
Externe `ITemplateDataProvider` können einen Platzhalter vom Typ `Drawing` mit einem
|
||||
`DrawingValue` befüllen. Dabei wird kein QuestPDF-/Skia-Canvas nach außen gegeben. Die portable
|
||||
Form besteht stattdessen aus einer geprüften, serialisierbaren Befehlsliste:
|
||||
|
||||
```csharp
|
||||
var drawing = new DrawingValue(
|
||||
[
|
||||
new DrawRectangle(0, 0, 160, 24, "#2563EB", 0.8f, "#EFF6FF"),
|
||||
new DrawString(4, 4, "Dynamischer Bericht", 11, "#1E3A8A", Bold: true),
|
||||
new MoveTo(4, 19),
|
||||
new LineTo(156, 19, "#93C5FD", 0.6f),
|
||||
new DrawImage(120, 2, 30, 18, pngBytes, "image/png")
|
||||
],
|
||||
ContentHeight: 24);
|
||||
```
|
||||
|
||||
Koordinaten und Längen verwenden die Einheit des Layouts; `DrawString.FontSize` wird wie bei
|
||||
`TEXT` in Punkt angegeben. Unterstützt werden `DrawString`, `MoveTo`, `LineTo`, `DrawLine`,
|
||||
`DrawRectangle` und `DrawImage` (PNG/JPEG). Die Befehle gelangen über den normalen
|
||||
`ITemplateDataProvider`, beispielsweise als `values["ExternerBericht"] = drawing`.
|
||||
|
||||
`DrawStringEx(x, y, height, width, ...)` ergänzt eine geclippte Textbox mit `AlignLeft`,
|
||||
`AlignCenter` oder `AlignRight`. Farbe und Schriftfamilie können bei beiden Textbefehlen gesetzt
|
||||
werden. Ohne Schriftangabe wird `sans-serif` verwendet. Für portable Schriften registriert die
|
||||
integrierende App TTF-/OTF-Daten einmal vor dem Rendern:
|
||||
|
||||
```csharp
|
||||
DrawingFontRegistry.RegisterFont("MeineSchulschrift", fontBytes);
|
||||
canvas.DrawStringEx(0, 0, 12, context.Width, "Zentrierte Überschrift",
|
||||
DrawingTextAlignment.AlignCenter, 11, "MeineSchulschrift", "#1E3A8A", bold: true);
|
||||
```
|
||||
|
||||
Explizite Zeilenumbrüche werden berücksichtigt; Text außerhalb von `height`/`width` wird
|
||||
abgeschnitten. Eine automatische Worttrennung findet in dieser elementaren Zeichenfunktion nicht
|
||||
statt.
|
||||
|
||||
Im Layout stehen zwei Varianten zur Verfügung:
|
||||
|
||||
```text
|
||||
DRAWBOX 20 40 170 80 $ExternerBericht
|
||||
FLOWDRAWBOX 20 40 170 237 $LangesProtokoll
|
||||
```
|
||||
|
||||
`DRAWBOX` ist ein fester, geclippter Viewport. Inhalte außerhalb seiner Breite oder Höhe werden
|
||||
nicht angezeigt. `FLOWDRAWBOX` zerlegt den vertikalen Zeichenraum anhand von `ContentHeight` in
|
||||
gleich hohe Seitenfenster und setzt ihn auf Folgeseiten fort. Wie bei `FLOWBOX` darf ein Layout
|
||||
höchstens ein fließendes Element enthalten; bei einem eigenen Folgeseitenlayout müssen Typ,
|
||||
Position und Größe übereinstimmen.
|
||||
|
||||
Ein Vorlagenpaket kann keinen Callback und keinen Typ aus einer fremden Assembly einschleusen.
|
||||
Farben, Zahlen, Bildformate, Bildgröße und Gesamtzahl der portablen Befehle werden validiert.
|
||||
Damit bleibt die paketfähige Schnittstelle deterministisch. Ein `PagedDrawingValue` ist dagegen
|
||||
ein ausdrücklich vom vertrauenswürdigen In-Process-`ITemplateDataProvider` übergebener Delegate.
|
||||
|
||||
Für umfangreiche In-Process-Integrationen gibt es zusätzlich den klassischen seitenweisen
|
||||
Callback `PagedDrawingValue`. Er wird vor QuestPDFs Layout vollständig in deklarative Seitenlisten
|
||||
aufgezeichnet und daher nicht durch interne Layoutdurchläufe mehrfach ausgeführt:
|
||||
|
||||
```csharp
|
||||
var value = new PagedDrawingValue(context =>
|
||||
{
|
||||
var nextRow = context.State is int row ? row : 0;
|
||||
|
||||
// Nur vollständige Strukturen zeichnen, die noch in die zugewiesene Box passen.
|
||||
while (nextRow < rows.Count && PasstNochVollstaendig(rows[nextRow], context))
|
||||
ZeichneZeile(context.Canvas, rows[nextRow++]);
|
||||
|
||||
context.State = nextRow;
|
||||
return nextRow == rows.Count; // true = fertig, false = weitere FLOWDRAWBOX
|
||||
}, InitialState: 0);
|
||||
```
|
||||
|
||||
`DrawingPageContext` stellt `Width`, `Height`, `PageNumber`, `Canvas` und ein über alle Aufrufe
|
||||
weitergereichtes `State`-Objekt bereit. So kann der Zeichner Tabellenzeilen, Diagrammgruppen oder
|
||||
andere unteilbare Strukturen bewusst auf die nächste Seite verschieben. Ein `DRAWBOX`-Callback
|
||||
wird genau einmal aufgerufen; bei `FLOWDRAWBOX` fordert `false` eine weitere Seite an. `MaxPages`
|
||||
verhindert Endlosschleifen. Delegates funktionieren nur innerhalb desselben .NET-Prozesses;
|
||||
prozessübergreifend bleibt `DrawingValue` die Übergabeform.
|
||||
|
||||
## PDF-Import im TemplateDesigner
|
||||
|
||||
Der Menüpunkt **Einfügen → PDF als Vorlage importieren** rekonstruiert einseitige PDF-Vorlagen.
|
||||
Der präzisere Modus verwendet ein leeres Template zusammen mit einem ausgefüllten Beispiel und
|
||||
ermittelt variable Textbereiche über einen toleranten Geometrie-Diff. Mit nur einem PDF werden
|
||||
Datum, Zahlen und typische Adressbereiche lokal heuristisch vorerkannt.
|
||||
|
||||
PdfPig extrahiert Text, Bounding-Box, Schriftgröße und verfügbare Schriftmerkmale. Die optionale
|
||||
KI-Klassifikation erhält ausschließlich diese strukturierte Zwischenrepräsentation und darf nur
|
||||
Placeholder-Namen, Typ, Gruppierung und Konfidenz liefern. Koordinaten werden nicht an die KI
|
||||
delegiert. Das gerasterte PDF bleibt als Hintergrund erhalten; erkannte variable Bereiche werden
|
||||
deterministisch mit einem weißen Asset maskiert und anschließend als `TEXT` oder `TEXTBOX`
|
||||
eingefügt. Der Nutzer prüft alle Vorschläge im Importdialog und muss die Übernahme ausdrücklich
|
||||
bestätigen. Das Paket wird dabei noch nicht gespeichert.
|
||||
|
||||
Die serverseitige Klassifikation liegt in `ai-backend/pdf-template.php` und verwendet denselben
|
||||
Login-, Bearer-Token-, Guthaben- und Abrechnungsmechanismus wie die übrigen KI-Funktionen. Das
|
||||
Passwort wird vom eigenständigen Designer nicht gespeichert. Tabellen-/Chart-Erkennung und die
|
||||
automatische Rekonstruktion mehrseitiger Vorlagen sind bewusst nicht Teil von v1.
|
||||
|
||||
## Mehrseitiger Fließtext
|
||||
|
||||
`TEXTBOX` bleibt ein absolut positionierter Bereich mit fester Höhe. Für Texte unbekannter Länge
|
||||
steht `FLOWBOX` mit derselben Syntax zur Verfügung:
|
||||
|
||||
```text
|
||||
PAGE 210 297 mm
|
||||
FLOWBOX 20 45 170 232 $Klassenbucheintraege size=11
|
||||
```
|
||||
|
||||
Der Inhalt wird innerhalb dieses Bereichs umbrochen und bei Bedarf auf beliebig vielen Seiten
|
||||
fortgesetzt. Pro Layout ist höchstens eine `FLOWBOX` zulässig. Das optionale Manifestfeld
|
||||
`continuationLayoutFile` verweist auf ein zweites Layout im Paket, das ab Seite 2 verwendet wird.
|
||||
Damit können Folgeseiten beispielsweise einen kleineren Briefkopf oder einen eigenen Hintergrund
|
||||
haben. Haupt- und Folgeseitenlayout müssen dieselbe Seitengröße besitzen; ihre `FLOWBOX` muss aus
|
||||
technischen Gründen dieselbe Position und Größe haben. Ohne Folgeseitenlayout werden die statischen
|
||||
Elemente der ersten Seite auf jeder erzeugten Seite wiederholt.
|
||||
|
||||
## Systemvariablen
|
||||
|
||||
Textinhalte können drei vom Renderer bereitgestellte Variablen verwenden. Sie werden nicht im
|
||||
Manifest deklariert und nicht vom `ITemplateDataProvider` geliefert:
|
||||
|
||||
- `$$today` – aktuelles lokales Datum im deutschen Kurzformat
|
||||
- `$$curPage` – aktuelle Seitenzahl
|
||||
- `$$maxPageNum` – Gesamtzahl der Seiten
|
||||
|
||||
Sie können allein oder innerhalb eines Literals stehen, beispielsweise:
|
||||
|
||||
```text
|
||||
TEXT 20 10 "Stand: $$today" size=9
|
||||
TEXT 145 285 "Seite $$curPage von $$maxPageNum" size=9 align=right
|
||||
```
|
||||
|
||||
QuestPDF löst aktuelle und gesamte Seitenzahl während der Dokumenterzeugung auf. Dafür ist kein
|
||||
zusätzlicher Renderdurchlauf durch die Anwendung erforderlich. Die Variablen funktionieren auch in
|
||||
konstanten Rich-Text-Platzhaltern.
|
||||
|
||||
## Konstante Platzhalter und Hervorhebung
|
||||
|
||||
Ein Platzhalter kann seinen Wert vollständig im Vorlagenpaket tragen. `IsConstant=true` bewirkt,
|
||||
dass `ConstantValue` beim Rendern immer verwendet wird; ein gleichnamiger Wert aus dem externen
|
||||
`ITemplateDataProvider` wird bewusst ignoriert. Damit eignen sich Konstanten besonders für lange
|
||||
Textbausteine in `TEXTBOX`, rechtliche Hinweise oder wiederkehrende Fußtexte.
|
||||
|
||||
```csharp
|
||||
new PlaceholderDefinition(
|
||||
"Datenschutzhinweis",
|
||||
PlaceholderType.Multiline,
|
||||
IsConstant: true,
|
||||
ConstantValue: "Dieser längere Text wird im Paket gespeichert.",
|
||||
Bold: true,
|
||||
Italic: false,
|
||||
Underline: false);
|
||||
```
|
||||
|
||||
Konstante Werte werden für `Text`, `Multiline`, `Date` und `Number` unterstützt. Die Eigenschaften
|
||||
`Bold`, `Italic` und `Underline` wirken, wenn der Platzhalter direkt von einem `TEXT`- oder
|
||||
`TEXTBOX`-Element referenziert wird. In der Layout-DSL kann Unterstreichung außerdem direkt mit
|
||||
`underline=true` gesetzt werden.
|
||||
|
||||
Konstante Text- und Multiline-Werte unterstützen zusätzlich abschnittsweise Hervorhebung und
|
||||
eingebettete externe Platzhalter:
|
||||
|
||||
```text
|
||||
Sehr geehrte Familie [b]${Student.LastName}[/b],
|
||||
|
||||
bitte geben Sie die [u]unterschriebene Erklärung[/u] bis [i]Freitag[/i] zurück.
|
||||
```
|
||||
|
||||
Unterstützt werden `[b]…[/b]`, `[i]…[/i]` und `[u]…[/u]`, auch verschachtelt. Die Klammerform
|
||||
`${Name}` ist in Fließtext vorzuziehen; `${Datum|dd.MM.yyyy}` erlaubt zusätzlich ein Format.
|
||||
Eingebettete Platzhalter müssen im Manifest als externe Platzhalter deklariert sein. Ihre gelieferten
|
||||
Werte werden immer als reiner Text behandelt und können deshalb kein Markup einschleusen. Mit
|
||||
`\[`, `\$` und `\\` lassen sich die Steuerzeichen wörtlich ausgeben.
|
||||
|
||||
## Freie Paketmetadaten
|
||||
|
||||
Jedes neu gespeicherte `.lavorlage`-Paket enthält eine lesbare `metadata.txt`. Pro Zeile steht ein
|
||||
frei wählbares Schlüssel-Wert-Paar; leere Zeilen und mit `#` beginnende Kommentare werden beim
|
||||
Einlesen ignoriert:
|
||||
|
||||
```text
|
||||
language=de-DE
|
||||
report-type=parent-letter
|
||||
school-year=2026/27
|
||||
```
|
||||
|
||||
Werte dürfen ein weiteres `=` enthalten. Schlüssel sind ohne Beachtung der Groß-/Kleinschreibung
|
||||
eindeutig und Werte bleiben einzeilig. Der Loader stellt sie der Anwendung direkt über
|
||||
`loadedTemplate.Manifest.Metadata` zur Verfügung. Bestehende Pakete ohne `metadata.txt` werden
|
||||
weiterhin mit einer leeren Metadatensammlung geladen. Die empfohlenen Standardschlüssel stehen
|
||||
zusätzlich als `TemplateMetadataKeys.Language` und `TemplateMetadataKeys.ReportType` bereit.
|
||||
|
||||
## 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.
|
||||
|
||||
## Visueller Koordinateneditor
|
||||
|
||||
Die QuestPDF-Vorschau dient gleichzeitig als maßstabsgetreue Zeichenfläche. Der Designer bildet
|
||||
das tatsächlich sichtbare Seitenrechteck unabhängig von Zoom und freien Rändern auf die `PAGE`-
|
||||
Koordinaten ab:
|
||||
|
||||
```text
|
||||
dslX = (mausX - seitenrandLinks) / angezeigteSeitenbreite * pageWidth
|
||||
dslY = (mausY - seitenrandOben) / angezeigteSeitenhöhe * pageHeight
|
||||
```
|
||||
|
||||
Im Messmodus übernimmt ein Klick `x/y`; ein aufgezogener Bereich übernimmt zusätzlich Breite und
|
||||
Höhe ins Elementformular. Im Bearbeitungsmodus lassen sich vorhandene Elemente verschieben und -
|
||||
außer einzeiligem `TEXT` - am rechten unteren Anfasser skalieren. Rasterfang und Pfeiltasten sind
|
||||
für Feinkorrekturen verfügbar. Änderungen werden in die ursprüngliche DSL-Zeile zurückgeschrieben,
|
||||
wobei Inhalte, Platzhalter, Formatangaben und Attribute erhalten bleiben.
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
internal static class SystemVariables
|
||||
{
|
||||
internal const string CurrentPage = "curPage";
|
||||
internal const string MaximumPageNumber = "maxPageNum";
|
||||
internal const string Today = "today";
|
||||
|
||||
private static readonly string[] Names = [CurrentPage, MaximumPageNumber, Today];
|
||||
|
||||
internal static bool IsStandalone(string value) =>
|
||||
TryRead(value, 0, out var length) && length == value.Length;
|
||||
|
||||
internal static bool Contains(string value) => Names.Any(name =>
|
||||
value.Contains("$$" + name, StringComparison.Ordinal));
|
||||
|
||||
internal static bool TryRead(string source, int index, out int length)
|
||||
{
|
||||
length = 0;
|
||||
if (index < 0 || index + 2 > source.Length || source[index] != '$' || source[index + 1] != '$')
|
||||
return false;
|
||||
foreach (var name in Names)
|
||||
{
|
||||
var token = "$$" + name;
|
||||
if (!source.AsSpan(index).StartsWith(token, StringComparison.Ordinal)) continue;
|
||||
var end = index + token.Length;
|
||||
if (end < source.Length && (char.IsLetterOrDigit(source[end]) || source[end] is '_' or '-' or '.'))
|
||||
continue;
|
||||
length = token.Length;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static IEnumerable<(string Text, string? Name)> Split(string source)
|
||||
{
|
||||
var literalStart = 0;
|
||||
for (var index = 0; index < source.Length;)
|
||||
{
|
||||
if (!TryRead(source, index, out var length)) { index++; continue; }
|
||||
if (index > literalStart) yield return (source[literalStart..index], null);
|
||||
yield return (source.Substring(index, length), source.Substring(index + 2, length - 2));
|
||||
index += length;
|
||||
literalStart = index;
|
||||
}
|
||||
if (literalStart < source.Length) yield return (source[literalStart..], null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
public static class TemplateDataResolver
|
||||
{
|
||||
public static IReadOnlyList<string> ValidateConstants(TemplateManifest manifest)
|
||||
{
|
||||
var issues = new List<string>();
|
||||
var definitions = manifest.Placeholders.GroupBy(x => x.Name, StringComparer.Ordinal)
|
||||
.ToDictionary(x => x.Key, x => x.First(), StringComparer.Ordinal);
|
||||
foreach (var constant in manifest.Placeholders.Where(x => x.IsConstant))
|
||||
{
|
||||
try
|
||||
{
|
||||
ConstantValue(constant);
|
||||
if (constant.Type is not (PlaceholderType.Text or PlaceholderType.Multiline)) continue;
|
||||
foreach (var reference in TemplateRichText.UsedPlaceholders(constant.ConstantValue ?? ""))
|
||||
{
|
||||
if (!definitions.TryGetValue(reference, out var target))
|
||||
issues.Add($"Konstanter Text „{constant.Name}“ verwendet den nicht deklarierten Platzhalter „{reference}“.");
|
||||
else if (target.IsConstant)
|
||||
issues.Add($"Konstanter Text „{constant.Name}“ darf nur externe Platzhalter verwenden; „{reference}“ ist ebenfalls konstant.");
|
||||
else if (target.Type is not (PlaceholderType.Text or PlaceholderType.Multiline
|
||||
or PlaceholderType.Date or PlaceholderType.Number))
|
||||
issues.Add($"Eingebetteter Platzhalter „{reference}“ hat keinen textuell darstellbaren Typ.");
|
||||
}
|
||||
}
|
||||
catch (InvalidDataException ex) { issues.Add(ex.Message); }
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
public static IReadOnlyDictionary<string, PlaceholderValue> Resolve(TemplateManifest manifest,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> externalValues)
|
||||
{
|
||||
var result = new Dictionary<string, PlaceholderValue>(externalValues, StringComparer.Ordinal);
|
||||
foreach (var definition in manifest.Placeholders.Where(x => x.IsConstant))
|
||||
result[definition.Name] = ConstantValue(definition);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static PlaceholderValue ConstantValue(PlaceholderDefinition definition)
|
||||
{
|
||||
var raw = definition.ConstantValue ?? "";
|
||||
return definition.Type switch
|
||||
{
|
||||
PlaceholderType.Text => new TextValue(raw),
|
||||
PlaceholderType.Multiline => new MultilineValue(raw),
|
||||
PlaceholderType.Date => new DateValue(ParseDate(definition, raw)),
|
||||
PlaceholderType.Number => new NumberValue(ParseNumber(definition, raw)),
|
||||
_ => throw new InvalidDataException(
|
||||
$"Platzhalter „{definition.Name}“ vom Typ {definition.Type} kann keinen konstanten Textwert verwenden."),
|
||||
};
|
||||
}
|
||||
|
||||
private static DateOnly ParseDate(PlaceholderDefinition definition, string raw)
|
||||
{
|
||||
if (DateOnly.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
|
||||
|| DateOnly.TryParse(raw, CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out date)) return date;
|
||||
throw Error(definition, "Datum, z. B. 2026-08-30");
|
||||
}
|
||||
|
||||
private static decimal ParseNumber(PlaceholderDefinition definition, string raw)
|
||||
{
|
||||
if (decimal.TryParse(raw, NumberStyles.Number, CultureInfo.InvariantCulture, out var number)
|
||||
|| decimal.TryParse(raw, NumberStyles.Number, CultureInfo.GetCultureInfo("de-DE"), out number)) return number;
|
||||
throw Error(definition, "Zahl");
|
||||
}
|
||||
|
||||
private static InvalidDataException Error(PlaceholderDefinition definition, string expected) =>
|
||||
new($"Konstanter Wert für „{definition.Name}“ ist ungültig; erwartet wird: {expected}.");
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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 (!IsSafeRelativePath(manifest.MetadataFile)) throw Error("metadataFile enthält einen unsicheren Pfad.");
|
||||
if (manifest.ContinuationLayoutFile is not null && !IsSafeRelativePath(manifest.ContinuationLayoutFile))
|
||||
throw Error("continuationLayoutFile enthält einen unsicheren Pfad.");
|
||||
if (Normalize(manifest.MetadataFile).Equals(Normalize(manifest.LayoutFile), StringComparison.OrdinalIgnoreCase))
|
||||
throw Error("metadataFile und layoutFile dürfen nicht identisch sein.");
|
||||
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 metadataPath = Normalize(manifest.MetadataFile);
|
||||
manifest.Metadata = new(StringComparer.OrdinalIgnoreCase);
|
||||
if (entries.TryGetValue(metadataPath, out var metadataEntry))
|
||||
{
|
||||
if (metadataEntry.Length > 1024 * 1024) throw Error("metadata.txt überschreitet das Größenlimit.");
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(metadataEntry.Open());
|
||||
manifest.Metadata = TemplateMetadataText.Parse(reader.ReadToEnd());
|
||||
}
|
||||
catch (InvalidDataException ex) { throw Error($"{manifest.MetadataFile} ist ungültig: {ex.Message}"); }
|
||||
}
|
||||
var layout = new LayoutParser().Parse(layoutSource);
|
||||
TemplateLayout? continuationLayout = null;
|
||||
if (manifest.ContinuationLayoutFile is { } continuationPath)
|
||||
{
|
||||
if (!entries.TryGetValue(Normalize(continuationPath), out var continuationEntry))
|
||||
throw Error($"Folgeseiten-Layoutdatei „{continuationPath}“ fehlt.");
|
||||
using var reader = new StreamReader(continuationEntry.Open());
|
||||
continuationLayout = new LayoutParser().Parse(reader.ReadToEnd());
|
||||
if (continuationLayout.Width != layout.Width || continuationLayout.Height != layout.Height
|
||||
|| !continuationLayout.Unit.Equals(layout.Unit, StringComparison.OrdinalIgnoreCase))
|
||||
issues.Add(new(ValidationSeverity.Error, "Folgeseiten-Layout und Hauptlayout müssen dieselbe Seitengröße und Einheit verwenden."));
|
||||
}
|
||||
var allLayouts = continuationLayout is null ? new[] { layout } : new[] { layout, continuationLayout };
|
||||
var flowBoxes = FlowElements(layout).ToList();
|
||||
if (flowBoxes.Count > 1)
|
||||
issues.Add(new(ValidationSeverity.Error, "Ein Layout darf höchstens ein fließendes Element (FLOWBOX/FLOWDRAWBOX) enthalten."));
|
||||
if (continuationLayout is not null)
|
||||
{
|
||||
var continuationFlows = FlowElements(continuationLayout).ToList();
|
||||
if (flowBoxes.Count != 1 || continuationFlows.Count != 1)
|
||||
issues.Add(new(ValidationSeverity.Error, "Bei einem Folgeseiten-Layout müssen Haupt- und Folgeseite jeweils genau ein gleichartiges fließendes Element enthalten."));
|
||||
else if (flowBoxes[0].GetType() != continuationFlows[0].GetType())
|
||||
issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen denselben fließenden Elementtyp verwenden."));
|
||||
else if (flowBoxes[0].X != continuationFlows[0].X || flowBoxes[0].Y != continuationFlows[0].Y
|
||||
|| flowBoxes[0].Width != continuationFlows[0].Width || flowBoxes[0].Height != continuationFlows[0].Height)
|
||||
issues.Add(new(ValidationSeverity.Error, "Die FLOWBOX muss auf Haupt- und Folgeseiten dieselbe Position und Größe haben."));
|
||||
}
|
||||
var referencedAssets = allLayouts.SelectMany(x => x.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);
|
||||
var continuationLayoutPath = manifest.ContinuationLayoutFile is null ? null : Normalize(manifest.ContinuationLayoutFile);
|
||||
foreach (var (path, asset) in entries.Where(x =>
|
||||
!x.Key.Equals("manifest.json", StringComparison.OrdinalIgnoreCase)
|
||||
&& !x.Key.Equals(layoutPath, StringComparison.OrdinalIgnoreCase)
|
||||
&& !x.Key.Equals(continuationLayoutPath, StringComparison.OrdinalIgnoreCase)
|
||||
&& !x.Key.Equals(metadataPath, 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 allLayouts.SelectMany(UsedPlaceholders).Distinct(StringComparer.Ordinal).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."));
|
||||
foreach (var issue in TemplateDataResolver.ValidateConstants(manifest))
|
||||
issues.Add(new(ValidationSeverity.Error, issue));
|
||||
if (issues.Count > 0) throw new TemplateValidationException(new(issues));
|
||||
return new(manifest, layout, assets, sourceName, continuationLayout);
|
||||
}
|
||||
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 (placeholder.IsConstant) continue;
|
||||
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,
|
||||
FlowBoxElement { Placeholder: { } p } => p,
|
||||
DrawBoxElement d => d.Placeholder,
|
||||
FlowDrawBoxElement d => d.Placeholder,
|
||||
TableElement t => t.Placeholder,
|
||||
ChartElement c => c.Placeholder,
|
||||
_ => null,
|
||||
}).Where(x => x is not null).Cast<string>().Distinct(StringComparer.Ordinal);
|
||||
private static IEnumerable<TemplateElement> FlowElements(TemplateLayout layout) =>
|
||||
layout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement);
|
||||
|
||||
internal static bool IsSafeRelativePath(string path) => !string.IsNullOrWhiteSpace(path)
|
||||
&& !Path.IsPathRooted(path) && !path.Split('/', '\\').Any(part => part == ".." || part.Length == 0);
|
||||
internal static string Normalize(string path) => path.Replace('\\', '/');
|
||||
private static string? AssetPath(TemplateElement element) => element switch
|
||||
{ BackgroundElement bg => bg.Path, ImageElement img => img.Path, _ => null };
|
||||
private static TemplateValidationException Error(string message) =>
|
||||
new(new([new(ValidationSeverity.Error, message)]));
|
||||
|
||||
private static JsonSerializerOptions CreateJsonOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true };
|
||||
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ImageDimensions
|
||||
{
|
||||
public static bool TryRead(byte[] data, out int width, out int height)
|
||||
{
|
||||
width = height = 0;
|
||||
if (data.Length >= 24 && data.AsSpan(0, 8).SequenceEqual(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }))
|
||||
{
|
||||
width = ReadBigEndian(data, 16); height = ReadBigEndian(data, 20); return width > 0 && height > 0;
|
||||
}
|
||||
if (data.Length >= 4 && data[0] == 0xFF && data[1] == 0xD8)
|
||||
{
|
||||
var offset = 2;
|
||||
while (offset + 9 < data.Length)
|
||||
{
|
||||
if (data[offset] != 0xFF) { offset++; continue; }
|
||||
var marker = data[offset + 1];
|
||||
if (marker is >= 0xC0 and <= 0xC3)
|
||||
{ height = (data[offset + 5] << 8) | data[offset + 6]; width = (data[offset + 7] << 8) | data[offset + 8]; return true; }
|
||||
if (offset + 3 >= data.Length) break;
|
||||
var length = (data[offset + 2] << 8) | data[offset + 3];
|
||||
if (length < 2) break; offset += length + 2;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
private static int ReadBigEndian(byte[] data, int offset) =>
|
||||
(data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3];
|
||||
}
|
||||
|
||||
public static class ImageInspector
|
||||
{
|
||||
public static ImageMetadata Inspect(byte[] data) => ImageDimensions.TryRead(data, out var width, out var height)
|
||||
? new(width, height)
|
||||
: throw new InvalidDataException("Das Asset ist kein unterstütztes PNG/JPEG-Bild.");
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
public static class TemplateMetadataKeys
|
||||
{
|
||||
public const string Language = "language";
|
||||
public const string ReportType = "report-type";
|
||||
}
|
||||
|
||||
public static class TemplateMetadataText
|
||||
{
|
||||
public const string FileName = "metadata.txt";
|
||||
|
||||
public static Dictionary<string, string> Parse(string source)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var lines = source.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
|
||||
for (var index = 0; index < lines.Length; index++)
|
||||
{
|
||||
var line = lines[index].Trim();
|
||||
if (line.Length == 0 || line.StartsWith('#')) continue;
|
||||
var separator = line.IndexOf('=');
|
||||
if (separator < 1)
|
||||
throw new InvalidDataException($"Metadatenzeile {index + 1} muss Schlüssel=Wert enthalten.");
|
||||
var key = line[..separator].Trim();
|
||||
var value = line[(separator + 1)..].Trim();
|
||||
Validate(key, value);
|
||||
if (!result.TryAdd(key, value))
|
||||
throw new InvalidDataException($"Metadatenschlüssel „{key}“ ist mehrfach vorhanden.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static string Serialize(IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
if (metadata.Count == 0) return "";
|
||||
var lines = new List<string>(metadata.Count);
|
||||
foreach (var (key, value) in metadata.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
Validate(key, value);
|
||||
lines.Add($"{key.Trim()}={value.Trim()}");
|
||||
}
|
||||
return string.Join('\n', lines) + "\n";
|
||||
}
|
||||
|
||||
private static void Validate(string key, string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) throw new InvalidDataException("Ein Metadatenschlüssel darf nicht leer sein.");
|
||||
if (key.Contains('=') || key.Contains('\n') || key.Contains('\r'))
|
||||
throw new InvalidDataException($"Metadatenschlüssel „{key}“ enthält ein unzulässiges Zeichen.");
|
||||
if (value.Contains('\n') || value.Contains('\r'))
|
||||
throw new InvalidDataException($"Metadatenwert für „{key}“ darf nur eine Zeile umfassen.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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, string? continuationLayoutSource = null)
|
||||
{
|
||||
if (!TemplateLoader.IsSafeRelativePath(manifest.MetadataFile))
|
||||
throw new InvalidDataException("metadataFile enthält einen unsicheren Pfad.");
|
||||
if (TemplateLoader.Normalize(manifest.MetadataFile).Equals(
|
||||
TemplateLoader.Normalize(manifest.LayoutFile), StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException("metadataFile und layoutFile dürfen nicht identisch sein.");
|
||||
var reservedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{ "manifest.json", TemplateLoader.Normalize(manifest.LayoutFile), TemplateLoader.Normalize(manifest.MetadataFile) };
|
||||
if (manifest.ContinuationLayoutFile is { } continuationPath)
|
||||
{
|
||||
if (!TemplateLoader.IsSafeRelativePath(continuationPath))
|
||||
throw new InvalidDataException("continuationLayoutFile enthält einen unsicheren Pfad.");
|
||||
reservedPaths.Add(TemplateLoader.Normalize(continuationPath));
|
||||
if (continuationLayoutSource is null)
|
||||
throw new InvalidDataException("Das Folgeseiten-Layout fehlt.");
|
||||
}
|
||||
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);
|
||||
if (manifest.ContinuationLayoutFile is { } continuationFile)
|
||||
WriteText(archive, continuationFile, continuationLayoutSource!);
|
||||
WriteText(archive, manifest.MetadataFile, TemplateMetadataText.Serialize(manifest.Metadata));
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
if (!TemplateLoader.IsSafeRelativePath(asset.Key))
|
||||
throw new InvalidDataException($"Unsicherer Assetpfad „{asset.Key}“.");
|
||||
if (reservedPaths.Contains(TemplateLoader.Normalize(asset.Key)))
|
||||
throw new InvalidDataException($"Assetpfad „{asset.Key}“ ist für Paketdaten reserviert.");
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Text;
|
||||
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
public sealed record TemplateRichTextRun(string Text, string? Placeholder, string? Format,
|
||||
bool Bold, bool Italic, bool Underline)
|
||||
{
|
||||
public bool IsPlaceholder => Placeholder is not null;
|
||||
}
|
||||
|
||||
public static class TemplateRichText
|
||||
{
|
||||
public static IReadOnlyList<TemplateRichTextRun> Parse(string source)
|
||||
{
|
||||
var runs = new List<TemplateRichTextRun>();
|
||||
var literal = new StringBuilder();
|
||||
var styles = new Stack<char>();
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (literal.Length == 0) return;
|
||||
AddRun(runs, new(literal.ToString(), null, null,
|
||||
styles.Contains('b'), styles.Contains('i'), styles.Contains('u')));
|
||||
literal.Clear();
|
||||
}
|
||||
|
||||
for (var index = 0; index < source.Length;)
|
||||
{
|
||||
if (source[index] == '\\' && index + 1 < source.Length
|
||||
&& source[index + 1] is '\\' or '[' or '$')
|
||||
{ literal.Append(source[index + 1]); index += 2; continue; }
|
||||
|
||||
if (source[index] == '$' && SystemVariables.TryRead(source, index, out var systemLength))
|
||||
{
|
||||
literal.Append(source, index, systemLength);
|
||||
index += systemLength;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryTag(source, index, out var tag, out var closing, out var tagLength))
|
||||
{
|
||||
Flush();
|
||||
if (closing)
|
||||
{
|
||||
if (styles.Count == 0 || styles.Peek() != tag)
|
||||
throw new InvalidDataException($"Rich-Text-Tag [/{tag}] ist nicht passend geöffnet.");
|
||||
styles.Pop();
|
||||
}
|
||||
else styles.Push(tag);
|
||||
index += tagLength; continue;
|
||||
}
|
||||
|
||||
if (source[index] == '$' && TryPlaceholder(source, index, out var name, out var format, out var length))
|
||||
{
|
||||
Flush();
|
||||
AddRun(runs, new("", name, format,
|
||||
styles.Contains('b'), styles.Contains('i'), styles.Contains('u')));
|
||||
index += length; continue;
|
||||
}
|
||||
|
||||
literal.Append(source[index++]);
|
||||
}
|
||||
|
||||
Flush();
|
||||
if (styles.Count > 0) throw new InvalidDataException($"Rich-Text-Tag [{styles.Peek()}] wurde nicht geschlossen.");
|
||||
return runs;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> UsedPlaceholders(string source) => Parse(source)
|
||||
.Where(x => x.Placeholder is not null).Select(x => x.Placeholder!).Distinct(StringComparer.Ordinal).ToList();
|
||||
|
||||
private static bool TryTag(string source, int index, out char tag, out bool closing, out int length)
|
||||
{
|
||||
tag = default; closing = false; length = 0;
|
||||
if (index + 2 < source.Length && source[index] == '[' && source[index + 2] == ']'
|
||||
&& source[index + 1] is 'b' or 'i' or 'u')
|
||||
{ tag = source[index + 1]; length = 3; return true; }
|
||||
if (index + 3 < source.Length && source[index] == '[' && source[index + 1] == '/'
|
||||
&& source[index + 3] == ']' && source[index + 2] is 'b' or 'i' or 'u')
|
||||
{ tag = source[index + 2]; closing = true; length = 4; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryPlaceholder(string source, int index, out string name, out string? format, out int length)
|
||||
{
|
||||
name = ""; format = null; length = 0;
|
||||
if (index + 1 >= source.Length) return false;
|
||||
if (source[index + 1] == '{')
|
||||
{
|
||||
var end = source.IndexOf('}', index + 2);
|
||||
if (end < 0) throw new InvalidDataException("Platzhalter mit '${' wurde nicht mit '}' geschlossen.");
|
||||
var content = source[(index + 2)..end];
|
||||
var parts = content.Split('|', 2);
|
||||
name = parts[0].Trim(); format = parts.Length == 2 ? parts[1] : null;
|
||||
if (name.Length == 0) throw new InvalidDataException("Ein eingebetteter Platzhaltername darf nicht leer sein.");
|
||||
length = end - index + 1; return true;
|
||||
}
|
||||
if (!IsNameStart(source[index + 1])) return false;
|
||||
var cursor = index + 2;
|
||||
while (cursor < source.Length && IsNamePart(source[cursor])) cursor++;
|
||||
name = source[(index + 1)..cursor]; length = cursor - index; return true;
|
||||
}
|
||||
|
||||
private static bool IsNameStart(char value) => char.IsLetter(value) || value == '_';
|
||||
private static bool IsNamePart(char value) => char.IsLetterOrDigit(value) || value is '_' or '.' or '-';
|
||||
|
||||
private static void AddRun(List<TemplateRichTextRun> runs, TemplateRichTextRun run)
|
||||
{
|
||||
if (!run.IsPlaceholder && runs.LastOrDefault() is { IsPlaceholder: false } previous
|
||||
&& previous.Bold == run.Bold && previous.Italic == run.Italic && previous.Underline == run.Underline)
|
||||
runs[^1] = previous with { Text = previous.Text + run.Text };
|
||||
else runs.Add(run);
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,25 @@ public class GradingServiceTests
|
||||
Assert.Equal(0, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("3+", 2.6667)]
|
||||
[InlineData("2-", 2.3333)]
|
||||
[InlineData("1+", 0.6667)]
|
||||
[InlineData("6-", 6.3333)]
|
||||
public void WeightedAverage_NotenMitTendenz_VerschiebtUmEinDrittel(string grade, double expected)
|
||||
{
|
||||
var result = _sut.WeightedAverage([(grade, 1.0)]);
|
||||
Assert.Equal(expected, result, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeightedAverage_NotenMitTendenzGemischtMitGanzenNoten_GewichtetKorrekt()
|
||||
{
|
||||
// (3+ [2,667] + 2 [2,0]) / 2 = 2,333
|
||||
var result = _sut.WeightedAverage([("3+", 1.0), ("2", 1.0)]);
|
||||
Assert.Equal(2.3333, result, precision: 4);
|
||||
}
|
||||
|
||||
// ── RoundToGrade ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -40,6 +40,7 @@ public sealed class GroupRolloverServiceTests
|
||||
Assert.True(target.IsDifferentiated);
|
||||
Assert.Null(target.WebUntisLessonId);
|
||||
Assert.False(source.IsActive);
|
||||
Assert.Equal(source.Id, target.PreviousGroupId);
|
||||
|
||||
var copied = Assert.Single(memberships.GetByGroup(target.Id));
|
||||
Assert.NotEqual(oldMembership.Id, copied.Id);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Tests;
|
||||
|
||||
/// Tests für die Zielnoten-Frage (Nutzer-Feedback): "Was brauche ich noch, um Note X zu
|
||||
/// erreichen?" — Umkehrung von GradingService.CalculateReportGrade.
|
||||
public sealed class ReportGradeTargetCalculatorTests
|
||||
{
|
||||
private static readonly GradingScheme Scheme = new()
|
||||
{
|
||||
ExamsPercent = 50, ParticipationPercent = 30, OtherPercent = 20,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void SolveRequiredAverage_LoestNachDemGesuchtenBereichAuf()
|
||||
{
|
||||
// Ziel 2,0; Mitarbeit 3,0 (30%), Sonstige 2,0 (20%) bekannt -> Klausuren (50%) gesucht.
|
||||
// 2,0 * 100 = x*50 + 3,0*30 + 2,0*20 => 200 = 50x + 90 + 40 => x = 1,4
|
||||
var result = ReportGradeTargetCalculator.SolveRequiredAverage(
|
||||
2.0, GradeBucketKind.Exams, examsAverage: null, participationAverage: 3.0, otherAverage: 2.0,
|
||||
Scheme, GradingSystem.Grades1To6);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(1.4, result!.Value.RequiredAverage, precision: 4);
|
||||
Assert.True(result.Value.IsAchievable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SolveRequiredAverage_IgnoriertBereicheOhneBekanntenDurchschnitt()
|
||||
{
|
||||
// Nur Klausuren (50%) bekannt, Mitarbeit gesucht (30%), Sonstige ohne Daten (fällt raus).
|
||||
// Ziel 2,0 nur aus Klausuren(50%, Ø 2,0) + Mitarbeit(30%, gesucht) => Gesamtprozent 80.
|
||||
// 2,0*80 = 2,0*50 + x*30 => 160 = 100 + 30x => x = 2,0
|
||||
var result = ReportGradeTargetCalculator.SolveRequiredAverage(
|
||||
2.0, GradeBucketKind.Participation, examsAverage: 2.0, participationAverage: null, otherAverage: null,
|
||||
Scheme, GradingSystem.Grades1To6);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2.0, result!.Value.RequiredAverage, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SolveRequiredAverage_AusserhalbDesGueltigenBereichs_IstNichtErreichbar()
|
||||
{
|
||||
// Mitarbeit bereits sehr schlecht (5,5) -> selbst eine Traumnote in Klausuren reicht nicht für 1,5.
|
||||
var result = ReportGradeTargetCalculator.SolveRequiredAverage(
|
||||
1.5, GradeBucketKind.Exams, examsAverage: null, participationAverage: 5.5, otherAverage: null,
|
||||
Scheme, GradingSystem.Grades1To6);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result!.Value.IsAchievable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SolveRequiredAverage_BereichOhneGewichtungsanteil_GibtNullZurueck()
|
||||
{
|
||||
var scheme = new GradingScheme { ExamsPercent = 100, ParticipationPercent = 0, OtherPercent = 0 };
|
||||
|
||||
var result = ReportGradeTargetCalculator.SolveRequiredAverage(
|
||||
2.0, GradeBucketKind.Participation, examsAverage: 2.0, participationAverage: null, otherAverage: null,
|
||||
scheme, GradingSystem.Grades1To6);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SolveRequiredAverage_FunktioniertAufDerPunkteskala()
|
||||
{
|
||||
// Punktesystem: höher ist besser. Ziel 12 Punkte, Mitarbeit 10 (30%), Sonstige 8 (20%)
|
||||
// bekannt -> Klausuren (50%) gesucht. 12*100 = x*50+10*30+8*20 => 1200=50x+300+160 => x=14,8
|
||||
var result = ReportGradeTargetCalculator.SolveRequiredAverage(
|
||||
12.0, GradeBucketKind.Exams, examsAverage: null, participationAverage: 10.0, otherAverage: 8.0,
|
||||
Scheme, GradingSystem.Points0To15);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(14.8, result!.Value.RequiredAverage, precision: 4);
|
||||
Assert.True(result.Value.IsAchievable);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Sync.Tests", "Leh
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Api.Tests", "LehrerApp.Api.Tests\LehrerApp.Api.Tests.csproj", "{E8152216-11F1-427E-B189-D8CEC9A71C33}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Templating", "LehrerApp.Templating\LehrerApp.Templating.csproj", "{B2000001-0000-0000-0000-000000000001}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.TemplateDesigner", "LehrerApp.TemplateDesigner\LehrerApp.TemplateDesigner.csproj", "{B2000002-0000-0000-0000-000000000002}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Templating.Tests", "LehrerApp.Templating.Tests\LehrerApp.Templating.Tests.csproj", "{B2000003-0000-0000-0000-000000000003}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.TemplateDesigner.Tests", "LehrerApp.TemplateDesigner.Tests\LehrerApp.TemplateDesigner.Tests.csproj", "{B2000004-0000-0000-0000-000000000004}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -34,6 +42,22 @@ Global
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{B2000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2000002-0000-0000-0000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2000002-0000-0000-0000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2000002-0000-0000-0000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2000002-0000-0000-0000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2000003-0000-0000-0000-000000000003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2000003-0000-0000-0000-000000000003}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2000003-0000-0000-0000-000000000003}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2000003-0000-0000-0000-000000000003}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2000004-0000-0000-0000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2000004-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2000004-0000-0000-0000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2000004-0000-0000-0000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user