Compare commits
2
Commits
f56caced32
...
98c5f9eba0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98c5f9eba0 | ||
|
|
dd92128464 |
No files matched your search
@@ -0,0 +1,61 @@
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// <summary>Eine Spalte der Punktetabelle auf dem Klausur-Deckblatt. Bonusaufgaben tragen keine
|
||||
/// Nummer; <see cref="AchievedPoints"/> ist null, solange nichts eingetragen werden soll.</summary>
|
||||
public sealed record ExamCoverTaskColumn(string Number, double MaxPoints, bool IsBonus, double? AchievedPoints);
|
||||
|
||||
public static class ExamCoverSheetService
|
||||
{
|
||||
/// <summary>
|
||||
/// Spalten für genau die Fassung, die der Schüler geschrieben hat. Nummeriert wird innerhalb
|
||||
/// dieser Fassung (wie auf dem gedruckten Aufgabenblatt), Bonusaufgaben zählen nicht mit.
|
||||
/// <paramref name="points"/> folgt wie <see cref="ExamResult.Points"/> der Reihenfolge aller
|
||||
/// Aufgaben, nicht nur der dieser Fassung.
|
||||
/// </summary>
|
||||
public static List<ExamCoverTaskColumn> Columns(IReadOnlyList<ExamTask> orderedTasks, ExamVariant variant,
|
||||
IReadOnlyList<double>? points)
|
||||
{
|
||||
var columns = new List<ExamCoverTaskColumn>();
|
||||
var number = 0;
|
||||
for (var index = 0; index < orderedTasks.Count; index++)
|
||||
{
|
||||
var task = orderedTasks[index];
|
||||
if (!ExamScoringService.AppliesTo(task, variant)) continue;
|
||||
double? achieved = points is null ? null : index < points.Count ? points[index] : 0;
|
||||
columns.Add(new(task.IsBonus ? "" : (++number).ToString(), task.MaxPoints, task.IsBonus, achieved));
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
/// <summary>Ein Ergebnis gilt als korrigiert, sobald Punkte oder eine Note eingetragen sind.</summary>
|
||||
public static bool IsCorrected(ExamResult? result) =>
|
||||
result is { Absent: false } && (result.Points.Count > 0 || !string.IsNullOrWhiteSpace(result.Grade));
|
||||
}
|
||||
|
||||
/// <summary>Wer eine Arbeit mitschreibt — eine Regel für Punkteeingabe und Deckblätter.</summary>
|
||||
public static class ExamParticipantService
|
||||
{
|
||||
/// <summary>Bei einer Nachschreibarbeit: Schüler, die die erste Arbeit bereits mit Note
|
||||
/// geschrieben haben. Sonst leer.</summary>
|
||||
public static HashSet<Guid> WroteOriginal(Exam exam, Func<Guid, List<ExamResult>> resultsByExam)
|
||||
{
|
||||
var wroteOriginal = new HashSet<Guid>();
|
||||
if (exam.IsMakeUp && exam.RepeatOfExamId is { } originalId)
|
||||
foreach (var r in resultsByExam(originalId).Where(r => !r.Absent && r.Grade is not null))
|
||||
wroteOriginal.Add(r.StudentId);
|
||||
return wroteOriginal;
|
||||
}
|
||||
|
||||
public static bool Includes(Exam exam, GroupMembership? membership, bool hasResult, bool wroteOriginal)
|
||||
{
|
||||
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) return false;
|
||||
// Niveau-Klausur: nur Schüler mit passendem Niveau. Klausuren ohne Niveau-Zuordnung gelten
|
||||
// weiterhin für die ganze Gruppe.
|
||||
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) return false;
|
||||
// Nachschreibarbeit: wer die erste Arbeit schon mit Note geschrieben hat, taucht nicht auf —
|
||||
// außer es gibt hier bereits ein Ergebnis (nichts verstecken, was schon eingetragen wurde).
|
||||
return hasResult || !wroteOriginal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class ExamCoverSheetTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-cover-tests-{Guid.NewGuid():N}");
|
||||
private readonly LearningGroup _group = new() { Name = "9 Ch G", SchoolYear = "2025/26", GradeLevel = 9 };
|
||||
private readonly Student _anna = new() { FirstName = "Anna", LastName = "Adler" };
|
||||
private readonly Student _ben = new() { FirstName = "Ben", LastName = "Bauer" };
|
||||
private readonly Exam _exam;
|
||||
private readonly FakeResults _results = new();
|
||||
|
||||
private const string Layout = """
|
||||
PAGE 210 297 mm
|
||||
#pragma format-version 3
|
||||
#pragma page-template first
|
||||
TEXT 20 20 $Student.Name size=12
|
||||
#pragma flow-slot body x=20 y=40 w=170 h=230
|
||||
#pragma end-page-template
|
||||
#pragma content-flow body
|
||||
TEXT $Exam.Title size=14
|
||||
FLOWDRAWBOX $Exam.PointsTable w=170 h=120 fit=content gap=10
|
||||
TEXT "Gesamtpunkte: $Result.Points von $Exam.MaxPoints Punkte" size=11 gap=8
|
||||
TEXT "Note: $Result.Grade" size=14 gap=4
|
||||
#pragma end-content-flow
|
||||
""";
|
||||
|
||||
public ExamCoverSheetTests()
|
||||
{
|
||||
Directory.CreateDirectory(_directory);
|
||||
_exam = new Exam
|
||||
{
|
||||
GroupId = _group.Id, Title = "Atombau und PSE", Date = new DateOnly(2025, 12, 18), ExamNumber = 1,
|
||||
Tasks =
|
||||
[
|
||||
new() { Nr = 1, MaxPoints = 5 }, new() { Nr = 2, MaxPoints = 12 },
|
||||
new() { Nr = 3, MaxPoints = 3, IsBonus = true },
|
||||
],
|
||||
GradingKey = [new() { Grade = "1", MinPercent = 90 }, new() { Grade = "6", MinPercent = 0 }],
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PointsTable_BrichtBeiFesterSpaltenbreiteInWeitereBloeckeUm()
|
||||
{
|
||||
var eight = Enumerable.Range(1, 8).Select(n => new ExamCoverTaskColumn(n.ToString(), n, false, null)).ToList();
|
||||
|
||||
var oneBlock = ExamPointsTableDrawingBuilder.Build(eight.Take(6).ToList(), 170);
|
||||
var twoBlocks = ExamPointsTableDrawingBuilder.Build(eight, 170);
|
||||
|
||||
Assert.Equal(6, ExamPointsTableDrawingBuilder.ColumnsPerBlock(170));
|
||||
Assert.True(twoBlocks.ContentHeight > 2 * oneBlock.ContentHeight);
|
||||
// Spalten bleiben gleich breit - der zweite Block verkleinert nichts.
|
||||
Assert.Equal(Widths(oneBlock), Widths(twoBlocks));
|
||||
Assert.All(twoBlocks.Commands.OfType<DrawRectangle>(), r => Assert.True(r.X + r.Width <= 170));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PointsTable_Bonusaufgabe_OhneNummerMitPlusVorDerPunktzahl()
|
||||
{
|
||||
var drawing = ExamPointsTableDrawingBuilder.Build(
|
||||
[
|
||||
new("1", 5, false, 4.5), new("", 3, true, 1),
|
||||
]);
|
||||
|
||||
var texts = drawing.Commands.OfType<DrawStringEx>().Select(x => x.Text).ToList();
|
||||
Assert.Equal(["Aufgabe", "Max. Punktzahl", "Punktzahl", "Nr. 1", "5", "4,5", "+3", "1"], texts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PointsTable_VorlageInPunkt_SkaliertDenMillimeterEntwurf()
|
||||
{
|
||||
List<ExamCoverTaskColumn> columns = [new("1", 5, false, null)];
|
||||
var scale = 72f / 25.4f;
|
||||
|
||||
var millimeters = ExamPointsTableDrawingBuilder.Build(columns, 170);
|
||||
var points = ExamPointsTableDrawingBuilder.Build(columns, 170 * scale, scale);
|
||||
|
||||
Assert.Equal(millimeters.ContentHeight * scale, points.ContentHeight, 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dialog_ZeigtNurDeckblattVorlagen_AusBeidenBibliotheken()
|
||||
{
|
||||
var letters = Store(); var worksheets = Store();
|
||||
Import(letters, "brief", "Brief", null);
|
||||
Import(letters, "deckblatt-a", "Deckblatt A", TemplateReportTypes.ExamCover);
|
||||
Import(worksheets, "deckblatt-b", "Deckblatt B", TemplateReportTypes.ExamCover);
|
||||
|
||||
var vm = Build([letters, worksheets]);
|
||||
|
||||
Assert.Equal(["Deckblatt A", "Deckblatt B"], vm.Templates.Select(x => x.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dialog_VorDerKorrektur_BlankoVorausgewaehlt_NachBeginnAusgefuellt()
|
||||
{
|
||||
Assert.False(Build([Store()]).FillResults);
|
||||
|
||||
_results.Add(new ExamResult { ExamId = _exam.Id, StudentId = _anna.Id, Points = [4, 10, 0], TotalPoints = 14 });
|
||||
|
||||
Assert.True(Build([Store()]).FillResults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dialog_AbwesenderSchueler_IstStandardmaessigAbgewaehlt()
|
||||
{
|
||||
_results.Add(new ExamResult { ExamId = _exam.Id, StudentId = _ben.Id, Absent = true });
|
||||
|
||||
var vm = Build([Store()]);
|
||||
|
||||
Assert.True(vm.Students.Single(s => s.Model.Id == _anna.Id).IsIncluded);
|
||||
Assert.False(vm.Students.Single(s => s.Model.Id == _ben.Id).IsIncluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_SchreibtEinSammelPdfMitEinerSeiteJeSchueler()
|
||||
{
|
||||
_results.Add(new ExamResult
|
||||
{ ExamId = _exam.Id, StudentId = _anna.Id, Points = [4.5, 12, 1], TotalPoints = 17.5 });
|
||||
var store = Store();
|
||||
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
|
||||
var vm = Build([store]);
|
||||
var output = Path.Combine(_directory, "deckblaetter.pdf");
|
||||
|
||||
Assert.True(vm.Generate(output));
|
||||
|
||||
using var document = UglyToad.PdfPig.PdfDocument.Open(output);
|
||||
Assert.Equal(2, document.NumberOfPages);
|
||||
var anna = Normalize(document.GetPage(1).Text);
|
||||
var ben = Normalize(document.GetPage(2).Text);
|
||||
Assert.Contains(Normalize(_anna.FullName), anna);
|
||||
Assert.Contains(Normalize(_ben.FullName), ben);
|
||||
Assert.Contains("AtombauundPSE", anna);
|
||||
// Bonusaufgabe zählt nicht zur erreichbaren Punktzahl; erreichte Bonuspunkte schon.
|
||||
Assert.Contains("Gesamtpunkte:17,5von17Punkte", anna);
|
||||
Assert.Contains("Note:1", anna);
|
||||
Assert.Contains("+3", anna);
|
||||
// Ben ist noch nicht korrigiert: Linien zum Ausfüllen statt Punkten und Note.
|
||||
Assert.Contains($"Gesamtpunkte:{ExamCoverPlaceholderBuilder.BlankPoints}von17Punkte", ben);
|
||||
Assert.Contains("1 davon ohne Punkte und Note", vm.StatusMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_OhnePunkteEintragen_BleibtAuchBeiKorrigiertenBlanko()
|
||||
{
|
||||
_results.Add(new ExamResult
|
||||
{ ExamId = _exam.Id, StudentId = _anna.Id, Points = [4.5, 12, 1], TotalPoints = 17.5 });
|
||||
var store = Store();
|
||||
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
|
||||
var vm = Build([store]);
|
||||
vm.FillResults = false;
|
||||
var output = Path.Combine(_directory, "blanko.pdf");
|
||||
|
||||
Assert.True(vm.Generate(output));
|
||||
|
||||
using var document = UglyToad.PdfPig.PdfDocument.Open(output);
|
||||
Assert.DoesNotContain("17,5", Normalize(document.GetPage(1).Text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_ZuVieleAufgabenFuerEineSeite_WarntImStatus()
|
||||
{
|
||||
_exam.Tasks = Enumerable.Range(1, 80).Select(n => new ExamTask { Nr = n, MaxPoints = 1 }).ToList();
|
||||
var store = Store();
|
||||
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
|
||||
var vm = Build([store]);
|
||||
|
||||
Assert.True(vm.Generate(Path.Combine(_directory, "lang.pdf")));
|
||||
|
||||
Assert.Contains("passen nicht auf eine Seite", vm.StatusMessage);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(GroupType.Course, Niveau.E, "E-Kurs")]
|
||||
[InlineData(GroupType.Course, Niveau.G, "G-Kurs")]
|
||||
[InlineData(GroupType.Course, Niveau.Foerder, "")]
|
||||
[InlineData(GroupType.Course, null, "")]
|
||||
[InlineData(GroupType.Class, Niveau.G, "")]
|
||||
public void CourseLabel_NurImKursMitEOderGNiveau(GroupType type, Niveau? niveau, string expected) =>
|
||||
Assert.Equal(expected, ExamCoverPlaceholderBuilder.CourseLabel(new LearningGroup { Type = type }, niveau));
|
||||
|
||||
[Fact]
|
||||
public void Platzhalter_KursAusKlausurOderMitgliedschaft_KuerzelAusDemDialog()
|
||||
{
|
||||
_group.Type = GroupType.Course;
|
||||
var store = Store();
|
||||
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
|
||||
var template = store.Load(store.GetTemplates().Single());
|
||||
|
||||
Dictionary<string, PlaceholderValue> Values(Niveau? studentNiveau) => ExamCoverPlaceholderBuilder.Build(
|
||||
_exam, _group, "Chemie", _anna, null, false, new GradingService(), template, new DateOnly(2026, 1, 1),
|
||||
studentNiveau, " MON HED ");
|
||||
|
||||
Assert.Equal(new TextValue("E-Kurs"), Values(Niveau.E)["Exam.Course"]);
|
||||
Assert.Equal(new TextValue("MON HED"), Values(null)["Teacher.Abbreviation"]);
|
||||
_exam.Niveau = Niveau.G;
|
||||
Assert.Equal(new TextValue("G-Kurs"), Values(Niveau.E)["Exam.Course"]);
|
||||
}
|
||||
|
||||
// Der Vorlagen-Designer bietet genau diesen Katalog an - liefert die Hauptapp einen Namen
|
||||
// nicht oder mit anderem Typ, scheitert die Vorlage erst beim Drucken.
|
||||
[Fact]
|
||||
public void Platzhalter_DeckenDenKatalogDesDesignersMitGleichenTypenAb()
|
||||
{
|
||||
var store = Store();
|
||||
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
|
||||
var template = store.Load(store.GetTemplates().Single());
|
||||
|
||||
var values = ExamCoverPlaceholderBuilder.Build(_exam, _group, "Chemie", _anna, null, false,
|
||||
new GradingService(), template, new DateOnly(2026, 1, 1));
|
||||
|
||||
Assert.All(ExamCoverPlaceholders.All, known =>
|
||||
{
|
||||
Assert.True(values.ContainsKey(known.Name), $"{known.Name} fehlt");
|
||||
Assert.Equal(known.Type, values[known.Name].Type);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TeacherSettings_SpeichertDasKuerzel()
|
||||
{
|
||||
new TeacherSettingsService(_directory).SaveAbbreviation(" HED ");
|
||||
|
||||
Assert.Equal("HED", new TeacherSettingsService(_directory).LoadAbbreviation());
|
||||
}
|
||||
|
||||
private static string Normalize(string text) => text.Replace(" ", "");
|
||||
private static List<float> Widths(DrawingValue drawing) =>
|
||||
drawing.Commands.OfType<DrawRectangle>().Select(r => r.Width).Distinct().Order().ToList();
|
||||
|
||||
private ExamCoverSheetDialogViewModel Build(IReadOnlyList<TemplateStore> stores) =>
|
||||
new(_exam, _group, "Chemie", new FakeStudents([_anna, _ben]), new FakeMemberships([]), _results,
|
||||
new GradingService(), stores, new QuestTemplateRenderer());
|
||||
|
||||
private TemplateStore Store() => new(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
|
||||
|
||||
private void Import(TemplateStore store, string id, string name, string? reportType)
|
||||
{
|
||||
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = id, Name = name,
|
||||
Placeholders =
|
||||
[
|
||||
new("Student.Name", PlaceholderType.Text), new("Exam.Title", PlaceholderType.Text),
|
||||
new("Exam.MaxPoints", PlaceholderType.Text), new("Result.Points", PlaceholderType.Text),
|
||||
new("Result.Grade", PlaceholderType.Text),
|
||||
new(ExamPointsTableDrawingBuilder.PlaceholderName, PlaceholderType.Drawing),
|
||||
],
|
||||
};
|
||||
if (reportType is not null) manifest.Metadata[TemplateMetadataKeys.ReportType] = reportType;
|
||||
TemplatePackage.Create(source, manifest, Layout, new Dictionary<string, byte[]>());
|
||||
store.Import(source);
|
||||
}
|
||||
|
||||
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="PdfPig" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -122,6 +122,7 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<PdfExportService>();
|
||||
services.AddSingleton<ITemplateLoader, TemplateLoader>();
|
||||
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
|
||||
services.AddSingleton<ITemplateBatchRenderer, QuestTemplateRenderer>();
|
||||
services.AddSingleton(_ => new TemplateStore(appData));
|
||||
services.AddSingleton(_ => new WorksheetTemplateStore(new TemplateStore(appData, subfolder: "worksheet-template-packages")));
|
||||
|
||||
@@ -211,6 +212,7 @@ public static class AppBootstrapper
|
||||
services.AddSingleton(_ => new WindowSettingsService(appData));
|
||||
services.AddSingleton(_ => new ExamGradingSettingsService(appData));
|
||||
services.AddSingleton(_ => new AppearanceSettingsService(appData));
|
||||
services.AddSingleton(_ => new TeacherSettingsService(appData));
|
||||
services.AddSingleton<PlanningExchangeService>();
|
||||
|
||||
// ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ──────
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Globalization;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>Platzhalterwerte für Klausur-Deckblätter (Vorlagen mit
|
||||
/// <c>report-type=exam-cover</c>): die Standardwerte der Elternbriefe (Student.*, Group.Name, …)
|
||||
/// plus Exam.*, Result.* und die Punktetabelle. Alle Punktwerte gelten für die Fassung, die der
|
||||
/// Schüler geschrieben hat.</summary>
|
||||
public static class ExamCoverPlaceholderBuilder
|
||||
{
|
||||
/// <summary>Stehen statt Punkten bzw. Note auf dem Blatt, solange nichts eingetragen wird —
|
||||
/// als Linie zum Ausfüllen von Hand.</summary>
|
||||
public const string BlankPoints = "_________", BlankGrade = "_____";
|
||||
|
||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||
|
||||
/// <param name="result">Ergebnis des Schülers oder null. Punkte und Note werden nur
|
||||
/// eingetragen, wenn <paramref name="fillResults"/> gesetzt und das Ergebnis korrigiert ist.</param>
|
||||
/// <param name="studentNiveau">Niveau des Schülers in dieser Lerngruppe, falls die Arbeit
|
||||
/// selbst keinem Niveau zugeordnet ist.</param>
|
||||
public static Dictionary<string, PlaceholderValue> Build(Exam exam, LearningGroup group, string subjectName,
|
||||
Student student, ExamResult? result, bool fillResults, GradingService grading, LoadedTemplate template,
|
||||
DateOnly today, Niveau? studentNiveau = null, string teacherAbbreviation = "")
|
||||
{
|
||||
var tasks = exam.Tasks.OrderBy(t => t.Nr).ToList();
|
||||
var variant = result?.Variant ?? ExamVariant.A;
|
||||
var maximum = ExamScoringService.Maximum(tasks, variant);
|
||||
var filled = fillResults && ExamCoverSheetService.IsCorrected(result);
|
||||
|
||||
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact: null, group, today, "", "");
|
||||
values["Group.GradeLevel"] = new TextValue(group.GradeLevel > 0 ? group.GradeLevel.ToString() : "");
|
||||
values["Subject.Name"] = new TextValue(subjectName);
|
||||
values["Exam.Title"] = new TextValue(exam.Title);
|
||||
values["Exam.Date"] = new DateValue(exam.Date);
|
||||
values["Exam.Number"] = new TextValue(exam.ExamNumber?.ToString() ?? "");
|
||||
values["Exam.Kind"] = new TextValue(exam.Kind.Label());
|
||||
values["Exam.Niveau"] = new TextValue(exam.Niveau.HasValue ? NiveauDisplay.ToName(exam.Niveau) : "");
|
||||
values["Exam.Course"] = new TextValue(CourseLabel(group, exam.Niveau ?? studentNiveau));
|
||||
values["Teacher.Abbreviation"] = new TextValue(teacherAbbreviation.Trim());
|
||||
values["Exam.Variant"] = new TextValue(tasks.Any(t => t.Variant is not null) ? variant.ToString() : "");
|
||||
values["Exam.MaxPoints"] = new TextValue(Format(maximum));
|
||||
values["Exam.ApprovalDate"] = new TextValue(Format(exam.ApprovalGrantedAt));
|
||||
values["Exam.AnnouncedDate"] = new TextValue(Format(exam.AnnouncedAt));
|
||||
values["Exam.ReturnDate"] = new TextValue(Format(exam.ReturnedAt));
|
||||
values["Result.Points"] = new TextValue(filled ? Format(result!.TotalPoints) : BlankPoints);
|
||||
values["Result.Percent"] = new TextValue(filled && maximum > 0
|
||||
? (result!.TotalPoints / maximum).ToString("0 %", German) : "");
|
||||
values["Result.Grade"] = new TextValue(filled
|
||||
? result!.Grade ?? grading.CalculateGrade(result.TotalPoints, maximum, exam.GradingKey) : BlankGrade);
|
||||
|
||||
// Ohne Box in der Vorlage gibt es nichts zu zeichnen (die Tabelle ist optional).
|
||||
values[ExamPointsTableDrawingBuilder.PlaceholderName] =
|
||||
LetterPlaceholderBuilder.FindDeclaredDrawingBox(template, ExamPointsTableDrawingBuilder.PlaceholderName) is { } box
|
||||
? ExamPointsTableDrawingBuilder.Build(
|
||||
ExamCoverSheetService.Columns(tasks, variant, filled ? result!.Points : null),
|
||||
box.Width, box.MillimeterScale)
|
||||
: new DrawingValue([], 0);
|
||||
return values;
|
||||
}
|
||||
|
||||
/// <summary>"E-Kurs" / "G-Kurs" für die äußere Differenzierung; leer im Klassenverband und
|
||||
/// ohne E-/G-Zuordnung.</summary>
|
||||
public static string CourseLabel(LearningGroup group, Niveau? niveau) =>
|
||||
group.Type == GroupType.Class ? "" : niveau switch
|
||||
{
|
||||
Niveau.E => "E-Kurs",
|
||||
Niveau.G => "G-Kurs",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
private static string Format(double value) => value.ToString("0.##", German);
|
||||
private static string Format(DateOnly? date) => date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "";
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Avalonia.Controls;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.Views.Groups;
|
||||
using LehrerApp.Templating;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>Öffnet den Deckblatt-Dialog zu einer Klausur — ein Einstieg für die Klausuren-
|
||||
/// Hauptseite und den Klausuren-Tab der Lerngruppe.</summary>
|
||||
public static class ExamCoverSheetDialogs
|
||||
{
|
||||
public static async Task ShowAsync(Control source, Exam exam)
|
||||
{
|
||||
var group = App.Services.GetRequiredService<IGroupRepository>().GetById(exam.GroupId);
|
||||
if (group is null || TopLevel.GetTopLevel(source) is not Window owner) return;
|
||||
var subject = group.SubjectId is { } subjectId
|
||||
? App.Services.GetRequiredService<ISubjectRepository>().GetById(subjectId)?.Name ?? ""
|
||||
: "";
|
||||
var vm = new ExamCoverSheetDialogViewModel(exam, group, subject,
|
||||
App.Services.GetRequiredService<IStudentRepository>(),
|
||||
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||
App.Services.GetRequiredService<IExamResultRepository>(),
|
||||
App.Services.GetRequiredService<GradingService>(),
|
||||
[App.Services.GetRequiredService<TemplateStore>(), App.Services.GetRequiredService<WorksheetTemplateStore>().Store],
|
||||
App.Services.GetRequiredService<ITemplateBatchRenderer>(),
|
||||
App.Services.GetRequiredService<TeacherSettingsService>().LoadAbbreviation());
|
||||
await new ExamCoverSheetDialog { DataContext = vm }.ShowDialog(owner);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>Brücke von den Klausurdaten zur Punktetabelle des Deckblatts. Gezeichnet wird in
|
||||
/// <see cref="ExamPointsTableDrawing"/> (Vorlagenbibliothek), damit der Vorlagen-Designer dieselbe
|
||||
/// Tabelle in der Vorschau zeigt.</summary>
|
||||
public static class ExamPointsTableDrawingBuilder
|
||||
{
|
||||
public const string PlaceholderName = ExamPointsTableDrawing.PlaceholderName;
|
||||
|
||||
public static DrawingValue Build(IReadOnlyList<ExamCoverTaskColumn> columns, float contentWidth = 170,
|
||||
float millimeterScale = 1) =>
|
||||
ExamPointsTableDrawing.Build(
|
||||
columns.Select(c => new ExamPointsTableColumn(c.Number, c.MaxPoints, c.IsBonus, c.AchievedPoints)).ToList(),
|
||||
contentWidth, millimeterScale);
|
||||
|
||||
public static int ColumnsPerBlock(float contentWidth, float millimeterScale = 1) =>
|
||||
ExamPointsTableDrawing.ColumnsPerBlock(contentWidth, millimeterScale);
|
||||
}
|
||||
@@ -51,34 +51,8 @@ public static class LetterPlaceholderBuilder
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>Tatsächliche DRAWBOX/FLOWDRAWBOX-Größe eines Platzhalters (in der Maßeinheit der
|
||||
/// Vorlage) plus dem Umrechnungsfaktor "1mm in dieser Maßeinheit". DrawingValue-Builder wie
|
||||
/// <see cref="StudentAttendanceCalendarDrawingBuilder"/> entwerfen ihr Raster in Millimetern;
|
||||
/// ohne MillimeterScale bliebe das bei einer in "pt" (statt "mm") deklarierten Vorlage viel zu
|
||||
/// klein (1 "mm-Einheit" würde als 1pt ≈ 0.35mm gerendert). IsFixed unterscheidet DRAWBOX (feste,
|
||||
/// nicht umbrechende Box - Inhalt, der die Höhe sprengt, wird von QuestTemplateRenderer
|
||||
/// stillschweigend am unteren Rand abgeschnitten statt umzubrechen) von FLOWDRAWBOX (fließt bei
|
||||
/// Bedarf automatisch auf weitere Seiten, siehe DrawingElementRenderer.Slice) - nur bei
|
||||
/// IsFixed=true darf/muss ein Builder seine Höhe an Height anpassen.</summary>
|
||||
public readonly record struct DeclaredDrawingBox(float Width, float Height, float MillimeterScale, bool IsFixed);
|
||||
|
||||
/// <summary>Siehe <see cref="DrawingBoxLocator"/> — dort liegt die Suche, damit auch der
|
||||
/// Vorlagen-Designer sie nutzen kann.</summary>
|
||||
public static DeclaredDrawingBox? FindDeclaredDrawingBox(LoadedTemplate template, string placeholderName) =>
|
||||
FindDeclaredDrawingBox(template.Layout, placeholderName) ??
|
||||
(template.ContinuationLayout is not null ? FindDeclaredDrawingBox(template.ContinuationLayout, placeholderName) : null);
|
||||
|
||||
private static DeclaredDrawingBox? FindDeclaredDrawingBox(TemplateLayout layout, string placeholderName)
|
||||
{
|
||||
var box = layout.Elements.Concat(layout.PageTemplates.SelectMany(p => p.Elements))
|
||||
.Concat(layout.ContentFlows.SelectMany(f => f.Elements))
|
||||
.Select(e => e switch
|
||||
{
|
||||
DrawBoxElement draw when draw.Placeholder == placeholderName => ((float Width, float Height, bool IsFixed)?)(draw.Width, draw.Height, true),
|
||||
FlowDrawBoxElement flow when flow.Placeholder == placeholderName => (flow.Width, flow.Height, false),
|
||||
_ => null,
|
||||
})
|
||||
.FirstOrDefault(b => b is not null);
|
||||
if (box is null) return null;
|
||||
var millimeterScale = UnitConverter.Points(1, "mm") / UnitConverter.Points(1, layout.Unit);
|
||||
return new DeclaredDrawingBox(box.Value.Width, box.Value.Height, millimeterScale, box.Value.IsFixed);
|
||||
}
|
||||
DrawingBoxLocator.Find(template, placeholderName);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ public class LetterTemplateTools(
|
||||
public List<LetterTemplateDto> ListLetterTemplates()
|
||||
{
|
||||
var result = new List<LetterTemplateDto>();
|
||||
foreach (var installed in templates.GetTemplates())
|
||||
foreach (var installed in templates.GetTemplates().Where(x => !x.IsExamCover))
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
internal sealed class TeacherSettingsConfig
|
||||
{
|
||||
public string Abbreviation { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Angaben zur Lehrkraft, die auf Formularen erscheinen (bisher nur das Kürzel fürs
|
||||
/// Klausur-Deckblatt) — aus der Konfiguration statt fest in der Vorlage, damit eine weitergegebene
|
||||
/// Vorlage nicht das Kürzel ihres Erstellers trägt.</summary>
|
||||
public sealed class TeacherSettingsService
|
||||
{
|
||||
private readonly string _configPath;
|
||||
|
||||
public TeacherSettingsService(string appDataPath) =>
|
||||
_configPath = Path.Combine(appDataPath, "teachersettings.json");
|
||||
|
||||
public string LoadAbbreviation()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_configPath))
|
||||
return JsonSerializer.Deserialize<TeacherSettingsConfig>(File.ReadAllText(_configPath))
|
||||
?.Abbreviation ?? "";
|
||||
}
|
||||
catch { /* beschädigte Konfiguration -> leer */ }
|
||||
return "";
|
||||
}
|
||||
|
||||
public void SaveAbbreviation(string abbreviation)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(_configPath,
|
||||
JsonSerializer.Serialize(new TeacherSettingsConfig { Abbreviation = abbreviation.Trim() }));
|
||||
}
|
||||
catch { /* Einstellung ist Komfort — Fehler beim Schreiben nicht an die UI durchreichen */ }
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ public partial class ExamsOverviewViewModel : ObservableObject
|
||||
public Func<Exam, Task>? OnGradeExam { get; set; }
|
||||
public Func<Exam, Task>? OnEvaluateExam { get; set; }
|
||||
public Func<Exam, Task>? OnRevealExam { get; set; }
|
||||
public Func<Exam, Task>? OnCoverSheets { get; set; }
|
||||
public Action<Guid>? OnNavigateToGroup { get; set; }
|
||||
public Action? OnNavigateToGroups { get; set; }
|
||||
public bool HasNoExams => Rows.Count == 0;
|
||||
@@ -177,6 +178,13 @@ public partial class ExamsOverviewViewModel : ObservableObject
|
||||
await OnRevealExam(SelectedRow.Exam);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CoverSheetsSelected()
|
||||
{
|
||||
if (SelectedRow is null || OnCoverSheets is null) return;
|
||||
await OnCoverSheets(SelectedRow.Exam);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void GoToGroup()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Templating;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
/// <summary>Erzeugt zu einer Klausur einen Satz individueller Deckblätter als ein Sammel-PDF.
|
||||
/// Zur Auswahl stehen nur Vorlagen mit <c>report-type=exam-cover</c>, egal in welcher der beiden
|
||||
/// Vorlagenbibliotheken sie importiert wurden. Mit <see cref="FillResults"/> trägt dieselbe
|
||||
/// Vorlage Punkte und Note ein oder bleibt als Blanko-Deckblatt zum Ausfüllen von Hand.</summary>
|
||||
public partial class ExamCoverSheetDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly Exam _exam;
|
||||
private readonly LearningGroup _group;
|
||||
private readonly string _subjectName;
|
||||
private readonly GradingService _grading;
|
||||
private readonly TemplateStore _loader;
|
||||
private readonly ITemplateBatchRenderer _renderer;
|
||||
|
||||
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
|
||||
[ObservableProperty] private bool _fillResults;
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
/// <summary>Vorbelegt aus den Einstellungen, hier nur für diesen Druck änderbar (z. B. zwei
|
||||
/// Kürzel bei einem gemeinsam unterrichteten Kurs) — die Einstellung bleibt unberührt.</summary>
|
||||
[ObservableProperty] private string _teacherAbbreviation;
|
||||
|
||||
public string ExamLabel => $"{_exam.Title} · {_group.Name} · {_exam.Date:dd.MM.yyyy}";
|
||||
public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
|
||||
public ObservableCollection<ExamCoverStudentChoice> Students { get; } = [];
|
||||
public bool HasNoTemplates => Templates.Count == 0;
|
||||
public string SuggestedFileName => SanitizeFileName($"Deckblätter_{_group.Name}_{_exam.Title}.pdf");
|
||||
|
||||
public ExamCoverSheetDialogViewModel(Exam exam, LearningGroup group, string subjectName,
|
||||
IStudentRepository students, IGroupMembershipRepository memberships, IExamResultRepository results,
|
||||
GradingService grading, IReadOnlyList<TemplateStore> templateStores, ITemplateBatchRenderer renderer,
|
||||
string teacherAbbreviation = "")
|
||||
{
|
||||
_teacherAbbreviation = teacherAbbreviation;
|
||||
_exam = exam; _group = group; _subjectName = subjectName; _grading = grading; _renderer = renderer;
|
||||
_loader = templateStores[0];
|
||||
foreach (var template in templateStores.SelectMany(x => x.GetTemplates()).Where(x => x.IsExamCover)
|
||||
.DistinctBy(x => x.Id).OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase))
|
||||
Templates.Add(new(template));
|
||||
SelectedTemplate = Templates.FirstOrDefault();
|
||||
|
||||
var membershipList = memberships.GetByGroup(group.Id);
|
||||
var existing = results.GetByExam(exam.Id).ToDictionary(r => r.StudentId);
|
||||
var wroteOriginal = ExamParticipantService.WroteOriginal(exam, results.GetByExam);
|
||||
foreach (var student in students.GetByGroup(group.Id).OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
|
||||
{
|
||||
var membership = membershipList.FirstOrDefault(m => m.StudentId == student.Id);
|
||||
existing.TryGetValue(student.Id, out var result);
|
||||
if (!ExamParticipantService.Includes(exam, membership, result is not null, wroteOriginal.Contains(student.Id)))
|
||||
continue;
|
||||
Students.Add(new(student, result, membership?.Niveau));
|
||||
}
|
||||
// Vor der Korrektur ist das Blanko-Deckblatt der Normalfall, danach das ausgefüllte.
|
||||
FillResults = Students.Any(s => s.IsCorrected);
|
||||
}
|
||||
|
||||
/// <summary>Schreibt das Sammel-PDF und meldet den Erfolg.</summary>
|
||||
public bool Generate(string outputPath)
|
||||
{
|
||||
StatusMessage = "";
|
||||
if (SelectedTemplate is null) return false;
|
||||
var included = Students.Where(s => s.IsIncluded).ToList();
|
||||
if (included.Count == 0) { StatusMessage = "Kein Schüler ausgewählt."; return false; }
|
||||
try
|
||||
{
|
||||
var loaded = _loader.Load(SelectedTemplate.Model);
|
||||
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||
var data = included.Select(s => (ITemplateDataProvider)new LetterDataProvider(
|
||||
ExamCoverPlaceholderBuilder.Build(_exam, _group, _subjectName, s.Model, s.Result, FillResults,
|
||||
_grading, loaded, today, s.Niveau, TeacherAbbreviation))).ToList();
|
||||
var result = _renderer.RenderBatchToPdf(loaded, data);
|
||||
File.WriteAllBytes(outputPath, result.Pdf);
|
||||
|
||||
var message = $"{included.Count} Deckblätter erzeugt.";
|
||||
var blank = FillResults ? included.Count(s => !s.IsCorrected) : 0;
|
||||
if (blank > 0) message += $" {blank} davon ohne Punkte und Note (noch nicht korrigiert).";
|
||||
var overflowing = result.PageCounts.Count(pages => pages > 1);
|
||||
if (overflowing > 0)
|
||||
message += $" Achtung: {overflowing} Deckblätter passen nicht auf eine Seite — die Punktetabelle braucht mehr Platz, als die Vorlage lässt.";
|
||||
StatusMessage = message;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
|
||||
{ StatusMessage = $"Deckblätter konnten nicht erzeugt werden: {ex.Message}"; return false; }
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string value)
|
||||
{ foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; }
|
||||
}
|
||||
|
||||
public partial class ExamCoverStudentChoice : ObservableObject
|
||||
{
|
||||
public Student Model { get; }
|
||||
public ExamResult? Result { get; }
|
||||
public Niveau? Niveau { get; }
|
||||
public string FullName => Model.FullName;
|
||||
public bool IsCorrected => ExamCoverSheetService.IsCorrected(Result);
|
||||
public string Hint => Result is { Absent: true } ? "abwesend" : "";
|
||||
[ObservableProperty] private bool _isIncluded;
|
||||
|
||||
public ExamCoverStudentChoice(Student model, ExamResult? result, Niveau? niveau = null)
|
||||
{
|
||||
Model = model; Result = result; Niveau = niveau;
|
||||
// Wer gefehlt hat, bekommt kein Deckblatt — lässt sich im Dialog wieder anhaken.
|
||||
_isIncluded = result is not { Absent: true };
|
||||
}
|
||||
}
|
||||
@@ -54,25 +54,14 @@ public partial class ExamGradingDialogViewModel : ObservableObject
|
||||
var membershipList = memberships.GetByGroup(groupId);
|
||||
var existing = results.GetByExam(exam.Id).ToDictionary(r => r.StudentId);
|
||||
|
||||
// Nachschreibarbeit: nur Schüler, die die erste Arbeit nicht mitgeschrieben haben. Wer dort
|
||||
// schon eine Note hat, taucht nicht auf — außer es gibt hier bereits ein Ergebnis (nichts
|
||||
// verstecken, was schon eingetragen wurde).
|
||||
var wroteOriginal = new HashSet<Guid>();
|
||||
if (exam.IsMakeUp && exam.RepeatOfExamId is { } originalId)
|
||||
foreach (var r in results.GetByExam(originalId).Where(r => !r.Absent && r.Grade is not null))
|
||||
wroteOriginal.Add(r.StudentId);
|
||||
var wroteOriginal = ExamParticipantService.WroteOriginal(exam, results.GetByExam);
|
||||
|
||||
foreach (var s in enrolled.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
|
||||
{
|
||||
var membership = membershipList.FirstOrDefault(e => e.StudentId == s.Id);
|
||||
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) continue;
|
||||
|
||||
// Niveau-Klausur: nur Schüler mit passendem Niveau zeigen. Klausuren ohne
|
||||
// Niveau-Zuordnung gelten weiterhin für die ganze Gruppe.
|
||||
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) continue;
|
||||
|
||||
existing.TryGetValue(s.Id, out var result);
|
||||
if (result is null && wroteOriginal.Contains(s.Id)) continue;
|
||||
if (!ExamParticipantService.Includes(exam, membership, result is not null, wroteOriginal.Contains(s.Id)))
|
||||
continue;
|
||||
var row = new ExamResultRow(s.Id, s.FullName, Tasks, result, _exam.GradingKey,
|
||||
ExamScoringService.Maximum(Tasks, ExamVariant.A), _grading);
|
||||
row.OnChanged = SaveRow;
|
||||
|
||||
@@ -235,6 +235,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
public Func<Exam, Task>? OnGradeExam { get; set; }
|
||||
public Func<Exam, Task>? OnEvaluateExam { get; set; }
|
||||
public Func<Exam, Task>? OnRevealExam { get; set; }
|
||||
public Func<Exam, Task>? OnExamCoverSheets { get; set; }
|
||||
public Func<Task<bool>>? OnConfirmReactivate { get; set; }
|
||||
|
||||
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
|
||||
@@ -421,6 +422,15 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
await OnRevealExam(exam);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
|
||||
private async Task ExamCoverSheets()
|
||||
{
|
||||
if (SelectedExam is null || OnExamCoverSheets is null) return;
|
||||
var exam = _exams.GetById(SelectedExam.Id);
|
||||
if (exam is null) return;
|
||||
await OnExamCoverSheets(exam);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
|
||||
private async Task DuplicateExam()
|
||||
{
|
||||
@@ -496,6 +506,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
GradeExamCommand.NotifyCanExecuteChanged();
|
||||
EvaluateExamCommand.NotifyCanExecuteChanged();
|
||||
RevealExamCommand.NotifyCanExecuteChanged();
|
||||
ExamCoverSheetsCommand.NotifyCanExecuteChanged();
|
||||
DuplicateExamCommand.NotifyCanExecuteChanged();
|
||||
DeleteExamCommand.NotifyCanExecuteChanged();
|
||||
AdvanceExamStatusCommand.NotifyCanExecuteChanged();
|
||||
@@ -526,6 +537,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
GradeExamCommand.NotifyCanExecuteChanged();
|
||||
EvaluateExamCommand.NotifyCanExecuteChanged();
|
||||
RevealExamCommand.NotifyCanExecuteChanged();
|
||||
ExamCoverSheetsCommand.NotifyCanExecuteChanged();
|
||||
DuplicateExamCommand.NotifyCanExecuteChanged();
|
||||
DeleteExamCommand.NotifyCanExecuteChanged();
|
||||
AdvanceExamStatusCommand.NotifyCanExecuteChanged();
|
||||
|
||||
@@ -33,7 +33,7 @@ public partial class PersonalizeWorksheetDialogViewModel : ObservableObject
|
||||
IStudentRepository students, WorksheetTemplateStore templates, ITemplateRenderer renderer)
|
||||
{
|
||||
_group = group; _templates = templates; _renderer = renderer;
|
||||
foreach (var template in templates.Store.GetTemplates()) Templates.Add(new(template));
|
||||
foreach (var template in templates.Store.GetTemplates().Where(x => !x.IsExamCover)) Templates.Add(new(template));
|
||||
SelectedTemplate = Templates.FirstOrDefault();
|
||||
foreach (var id in studentIds)
|
||||
if (students.GetById(id) is { } student) Students.Add(new(student));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Templating;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
@@ -7,6 +8,13 @@ namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
private readonly TeacherSettingsService? _teacherSettings;
|
||||
|
||||
/// <summary>Eigenes Kürzel für Formulare (Platzhalter <c>Teacher.Abbreviation</c> auf dem
|
||||
/// Klausur-Deckblatt). Wird sofort gespeichert; im Deckblatt-Dialog je Druck überschreibbar.</summary>
|
||||
[ObservableProperty] private string _teacherAbbreviation = "";
|
||||
partial void OnTeacherAbbreviationChanged(string value) => _teacherSettings?.SaveAbbreviation(value);
|
||||
|
||||
[ObservableProperty] private string _worksheetTemplateStatus = "";
|
||||
public ObservableCollection<LetterTemplateListItem> WorksheetTemplateList { get; } = [];
|
||||
|
||||
|
||||
@@ -121,8 +121,11 @@ public partial class SettingsViewModel : ObservableObject
|
||||
AppearanceSettingsService appearance, TrashViewModel trashTab,
|
||||
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
|
||||
UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null,
|
||||
SchoolWeatherService? schoolWeather = null, WebUntisIntegrationService? untisIntegration = null)
|
||||
SchoolWeatherService? schoolWeather = null, WebUntisIntegrationService? untisIntegration = null,
|
||||
TeacherSettingsService? teacherSettings = null)
|
||||
{
|
||||
_teacherSettings = teacherSettings;
|
||||
_teacherAbbreviation = teacherSettings?.LoadAbbreviation() ?? "";
|
||||
_logger = logger;
|
||||
_syncKeyRecovery = syncKeyRecovery;
|
||||
SyncKeyWasRegenerated = syncKeyStatus.KeyWasRegenerated;
|
||||
|
||||
@@ -60,7 +60,7 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
||||
_attendanceCalendarFactory = attendanceCalendarFactory;
|
||||
_absenceDayListFactory = absenceDayListFactory;
|
||||
_attendanceDataRefresher = attendanceDataRefresher;
|
||||
foreach (var template in templates.GetTemplates()) Templates.Add(new(template));
|
||||
foreach (var template in templates.GetTemplates().Where(x => !x.IsExamCover)) 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))
|
||||
if (groups.GetById(membership.GroupId) is { } group) Groups.Add(new(group));
|
||||
@@ -157,9 +157,9 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
||||
/// Platzhalter, damit der Kalender/die Fehltagesliste die real verfügbare Fläche ausfüllen statt
|
||||
/// eine feste Breite zu raten (siehe LetterPlaceholderBuilder.FindDeclaredDrawingBox). Fällt auf
|
||||
/// 170mm zurück, wenn keine Vorlage gewählt ist oder die Box nicht gefunden wird.</summary>
|
||||
private LetterPlaceholderBuilder.DeclaredDrawingBox DeclaredDrawingBox(string placeholderName)
|
||||
private DeclaredDrawingBox DeclaredDrawingBox(string placeholderName)
|
||||
{
|
||||
var fallback = new LetterPlaceholderBuilder.DeclaredDrawingBox(170, float.PositiveInfinity, 1, false);
|
||||
var fallback = new DeclaredDrawingBox(170, float.PositiveInfinity, 1, false);
|
||||
if (SelectedTemplate is null) return fallback;
|
||||
try { return LetterPlaceholderBuilder.FindDeclaredDrawingBox(_templates.Load(SelectedTemplate.Model), placeholderName) ?? fallback; }
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { return fallback; }
|
||||
|
||||
@@ -177,6 +177,8 @@
|
||||
<Button Content="Einzelansicht" Command="{Binding RevealSelectedCommand}"
|
||||
IsVisible="{Binding ShowGradeSummary}"
|
||||
ToolTip.Tip="Zeigt das Ergebnis eines einzelnen Schülers – zunächst nur den Namen, erst nach „Aufdecken“ Note und Punkte."/>
|
||||
<Button Content="Deckblätter…" Command="{Binding CoverSheetsSelectedCommand}"
|
||||
ToolTip.Tip="Individuelle Deckblätter für diese Arbeit als ein PDF erzeugen."/>
|
||||
<Button Content="Zum Kurs →" Command="{Binding GoToGroupCommand}" HorizontalAlignment="Right"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
@@ -2,6 +2,7 @@ using Avalonia.Controls;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.ViewModels.Exams;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
@@ -24,6 +25,7 @@ public partial class ExamsOverviewView : UserControl
|
||||
vm.OnGradeExam = ShowGradeExamDialog;
|
||||
vm.OnEvaluateExam = ShowEvaluateExamDialog;
|
||||
vm.OnRevealExam = ShowRevealExamDialog;
|
||||
vm.OnCoverSheets = exam => ExamCoverSheetDialogs.ShowAsync(this, exam);
|
||||
vm.OnNavigateToGroup = groupId => App.Services.GetRequiredService<MainWindowViewModel>()
|
||||
.NavigateToGroupDetail(groupId, GroupDetailKlausurenTabIndex);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.ExamCoverSheetDialog"
|
||||
x:DataType="vm:ExamCoverSheetDialogViewModel"
|
||||
Title="Deckblätter drucken"
|
||||
Width="460" Height="640"
|
||||
CanResize="False" WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto,Auto" Margin="24">
|
||||
|
||||
<StackPanel Grid.Row="0" Spacing="4" Margin="0,0,0,14">
|
||||
<TextBlock Text="Deckblätter drucken" Classes="dialogtitle"/>
|
||||
<TextBlock Text="{Binding ExamLabel}" FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="1" Spacing="10" Margin="0,0,0,14">
|
||||
<TextBlock Text="Vorlage" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding Templates}" SelectedItem="{Binding SelectedTemplate}"
|
||||
HorizontalAlignment="Stretch" DisplayMemberBinding="{Binding Name}"/>
|
||||
<TextBlock Text="Noch keine Deckblatt-Vorlage importiert — in den Einstellungen unter „Briefvorlagen“ eine .lavorlage-Datei mit dem Metadatum report-type=exam-cover hinzufügen."
|
||||
Classes="emptyhint" TextWrapping="Wrap"
|
||||
IsVisible="{Binding HasNoTemplates}"/>
|
||||
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
|
||||
<TextBlock Grid.Column="0" Text="Kürzel" VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding TeacherAbbreviation}" PlaceholderText="z. B. MON HED"
|
||||
ToolTip.Tip="Vorbelegt mit dem eigenen Kürzel aus den Einstellungen. Änderungen gelten nur für diesen Druck."/>
|
||||
</Grid>
|
||||
<CheckBox Content="Punkte und Note eintragen" IsChecked="{Binding FillResults}"
|
||||
ToolTip.Tip="Aus: Blanko-Deckblatt mit Namen und leerer Punktetabelle zum Ausfüllen von Hand."/>
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer Grid.Row="2">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Für diese Schüler erzeugen" FontSize="12" FontWeight="SemiBold" Opacity="0.7" Margin="0,0,0,4"/>
|
||||
<ItemsControl ItemsSource="{Binding Students}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ExamCoverStudentChoice">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,3">
|
||||
<CheckBox Grid.Column="0" Content="{Binding FullName}" IsChecked="{Binding IsIncluded}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Hint}" FontSize="11" Opacity="0.6" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" FontSize="12" Margin="0,10,0,0" TextWrapping="Wrap"
|
||||
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
|
||||
<Grid Grid.Row="4" ColumnDefinitions="*,8,*" Margin="0,16,0,0">
|
||||
<Button Grid.Column="0" Content="Schließen" HorizontalAlignment="Stretch" Click="OnClose"/>
|
||||
<Button Grid.Column="2" Content="PDF erzeugen…" HorizontalAlignment="Stretch" Click="OnGenerate"
|
||||
IsEnabled="{Binding !HasNoTemplates}"/>
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,29 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Groups;
|
||||
|
||||
public partial class ExamCoverSheetDialog : Window
|
||||
{
|
||||
public ExamCoverSheetDialog() => InitializeComponent();
|
||||
|
||||
private async void OnGenerate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not ExamCoverSheetDialogViewModel vm) return;
|
||||
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Deckblätter speichern",
|
||||
SuggestedFileName = vm.SuggestedFileName,
|
||||
DefaultExtension = "pdf",
|
||||
FileTypeChoices = [new FilePickerFileType("PDF") { Patterns = ["*.pdf"] }],
|
||||
});
|
||||
if (file is null) return;
|
||||
var path = file.Path.LocalPath;
|
||||
if (vm.Generate(path))
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||
}
|
||||
|
||||
private void OnClose(object? sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
@@ -120,6 +120,8 @@
|
||||
<Button Content="Auswertung" Command="{Binding EvaluateExamCommand}"/>
|
||||
<Button Content="Einzelansicht" Command="{Binding RevealExamCommand}"
|
||||
ToolTip.Tip="Zeigt das Ergebnis eines einzelnen Schülers – zunächst nur den Namen, erst nach „Aufdecken“ Note und Punkte."/>
|
||||
<Button Content="Deckblätter…" Command="{Binding ExamCoverSheetsCommand}"
|
||||
ToolTip.Tip="Individuelle Deckblätter für diese Arbeit als ein PDF erzeugen."/>
|
||||
<Button Content="Bearbeiten" Command="{Binding EditExamCommand}"/>
|
||||
<Button Content="Duplizieren" Command="{Binding DuplicateExamCommand}"/>
|
||||
<SplitButton Content="Status ▸" Command="{Binding AdvanceExamStatusCommand}">
|
||||
@@ -167,6 +169,7 @@
|
||||
<MenuItem Header="Punkte eingeben" Command="{Binding GradeExamCommand}"/>
|
||||
<MenuItem Header="Auswertung" Command="{Binding EvaluateExamCommand}"/>
|
||||
<MenuItem Header="Einzelansicht" Command="{Binding RevealExamCommand}"/>
|
||||
<MenuItem Header="Deckblätter…" Command="{Binding ExamCoverSheetsCommand}"/>
|
||||
<MenuItem Header="Bearbeiten" Command="{Binding EditExamCommand}"/>
|
||||
<MenuItem Header="Duplizieren" Command="{Binding DuplicateExamCommand}"/>
|
||||
<MenuItem Header="Status">
|
||||
|
||||
@@ -38,6 +38,7 @@ public partial class GroupDetailView : UserControl
|
||||
vm.OnGradeExam = ShowGradeExamDialog;
|
||||
vm.OnEvaluateExam = ShowEvaluateExamDialog;
|
||||
vm.OnRevealExam = ShowRevealExamDialog;
|
||||
vm.OnExamCoverSheets = exam => ExamCoverSheetDialogs.ShowAsync(this, exam);
|
||||
vm.OnConfirmReactivate = ShowReactivateConfirmDialog;
|
||||
vm.OverviewTab.OnNavigateToWorkload = () =>
|
||||
App.Services.GetRequiredService<MainWindowViewModel>().NavigateToWorkload();
|
||||
|
||||
@@ -515,6 +515,15 @@
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Separator Margin="0,8"/>
|
||||
<TextBlock Text="Klausur-Deckblätter" FontSize="15" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Vorlagen mit dem Metadatum report-type=exam-cover erscheinen nur unter „Deckblätter…“ bei einer Klausur, egal in welche der beiden Listen oben sie importiert wurden. Das Kürzel füllt den Platzhalter Teacher.Abbreviation und lässt sich beim Erzeugen noch ändern, etwa für einen gemeinsam unterrichteten Kurs."
|
||||
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<TextBlock Text="Eigenes Kürzel" VerticalAlignment="Center"/>
|
||||
<TextBox Text="{Binding TeacherAbbreviation}" Width="120" PlaceholderText="z. B. HED"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using LehrerApp.TemplateDesigner;
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.TemplateDesigner.Tests;
|
||||
|
||||
public sealed class ExamCoverDesignerTests
|
||||
{
|
||||
[Fact]
|
||||
public void SetUpExamCover_SetztVorlagentypUndErgaenztFehlendePlatzhalter()
|
||||
{
|
||||
var viewModel = new DesignerViewModel();
|
||||
viewModel.Reset();
|
||||
viewModel.Placeholders.Add(new("Exam.Title", PlaceholderType.Text, true, "Mein Titel"));
|
||||
|
||||
viewModel.SetUpExamCover();
|
||||
viewModel.SetUpExamCover();
|
||||
|
||||
Assert.True(viewModel.IsExamCover);
|
||||
Assert.Single(viewModel.MetadataItems, x => x.Name == TemplateMetadataKeys.ReportType);
|
||||
Assert.Equal(ExamCoverPlaceholders.All.Count, viewModel.Placeholders.Count);
|
||||
// Vorhandene Platzhalter bleiben samt Beispielwert erhalten.
|
||||
Assert.Equal("Mein Titel", viewModel.Placeholders.Single(x => x.Name == "Exam.Title").Sample);
|
||||
Assert.Equal("G-Kurs", viewModel.Placeholders.Single(x => x.Name == "Exam.Course").Sample);
|
||||
Assert.Equal(PlaceholderType.Date, viewModel.Placeholders.Single(x => x.Name == "Exam.Date").Type);
|
||||
Assert.Equal(TemplateReportTypes.ExamCover,
|
||||
viewModel.BuildLoaded().Manifest.Metadata[TemplateMetadataKeys.ReportType]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetUpExamCover_AbweichenderTyp_WirdGemeldetStattUeberschrieben()
|
||||
{
|
||||
var viewModel = new DesignerViewModel();
|
||||
viewModel.Reset();
|
||||
viewModel.Placeholders.Add(new("Exam.Date", PlaceholderType.Text, false, "18.12."));
|
||||
|
||||
viewModel.SetUpExamCover();
|
||||
|
||||
Assert.Equal(PlaceholderType.Text, viewModel.Placeholders.Single(x => x.Name == "Exam.Date").Type);
|
||||
Assert.Contains("Exam.Date", viewModel.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Punktetabelle_WirdAlsFlowDrawBoxMitFitContentInDenContentFlowVorbereitet()
|
||||
{
|
||||
var viewModel = new DesignerViewModel();
|
||||
viewModel.Reset();
|
||||
viewModel.SelectedSpecialContent = viewModel.SpecialContents.Single(x =>
|
||||
x.PlaceholderName == ExamPointsTableDrawing.PlaceholderName);
|
||||
|
||||
viewModel.PrepareSelectedSpecialContent();
|
||||
viewModel.AddElement();
|
||||
|
||||
Assert.Contains("FLOWDRAWBOX $Exam.PointsTable w=165 h=150 fit=content", viewModel.LayoutSource);
|
||||
var layout = viewModel.BuildLoaded().Layout;
|
||||
Assert.IsType<FlowDrawBoxElement>(layout.ContentFlows.Single().Elements.Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorschau_ZeigtDieEchtePunktetabelleUndSprechendeBeispielwerte()
|
||||
{
|
||||
var viewModel = new DesignerViewModel();
|
||||
viewModel.Reset();
|
||||
viewModel.SetUpExamCover();
|
||||
viewModel.LayoutSource = """
|
||||
PAGE 210 297 mm
|
||||
#pragma format-version 3
|
||||
#pragma page-template first
|
||||
TEXT 20 20 "Fach: $Subject.Name $Exam.Course $Teacher.Abbreviation" size=12
|
||||
#pragma flow-slot body x=20 y=40 w=170 h=230
|
||||
#pragma end-page-template
|
||||
#pragma content-flow body
|
||||
FLOWDRAWBOX $Exam.PointsTable w=100 h=150 fit=content
|
||||
TEXT "Gesamtpunkte: $Result.Points von $Exam.MaxPoints Punkte" size=11 gap=8
|
||||
#pragma end-content-flow
|
||||
""";
|
||||
|
||||
using var document = UglyToad.PdfPig.PdfDocument.Open(viewModel.RenderCurrentPdf());
|
||||
var text = document.GetPage(1).Text.Replace(" ", "");
|
||||
|
||||
Assert.Contains("Fach:ChemieG-KursHED", text);
|
||||
Assert.Contains("Gesamtpunkte:52,5von62Punkte", text);
|
||||
Assert.Contains("+3", text);
|
||||
// 100 mm Boxbreite: drei Aufgaben je Block, also drei Blöcke für neun Spalten.
|
||||
Assert.Equal(3, System.Text.RegularExpressions.Regex.Matches(text, "Max.Punktzahl").Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Laden_EinerDeckblattVorlage_VerwendetSprechendeBeispielwerte()
|
||||
{
|
||||
var source = new DesignerViewModel();
|
||||
source.Reset();
|
||||
source.SetUpExamCover();
|
||||
var loaded = source.BuildLoaded();
|
||||
|
||||
var viewModel = new DesignerViewModel();
|
||||
viewModel.Load(loaded, source.LayoutSource);
|
||||
|
||||
Assert.True(viewModel.IsExamCover);
|
||||
Assert.Equal("Atombau und PSE", viewModel.Placeholders.Single(x => x.Name == "Exam.Title").Sample);
|
||||
}
|
||||
}
|
||||
@@ -169,8 +169,51 @@ public partial class DesignerViewModel : ObservableObject
|
||||
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 byte[] RenderCurrentPdf()
|
||||
{
|
||||
var loaded = BuildLoaded();
|
||||
return new QuestTemplateRenderer().RenderToPdf(loaded, FitSampleData(loaded, BuildDataProvider()));
|
||||
}
|
||||
|
||||
/// <summary>Beispieldaten, deren Aussehen von der Vorlage abhängt: die Punktetabelle des
|
||||
/// Klausur-Deckblatts richtet ihre Blockbreite nach der im Layout deklarierten Box, genau wie
|
||||
/// später in der Hauptapp.</summary>
|
||||
private static ITemplateDataProvider FitSampleData(LoadedTemplate template, ITemplateDataProvider provider)
|
||||
{
|
||||
var values = provider.GetValues();
|
||||
if (values.GetValueOrDefault(ExamPointsTableDrawing.PlaceholderName) is not DrawingValue) return provider;
|
||||
return new DesignerDataProvider(new Dictionary<string, PlaceholderValue>(values, StringComparer.Ordinal)
|
||||
{ [ExamPointsTableDrawing.PlaceholderName] = ExamCoverPlaceholders.SampleTable(template) });
|
||||
}
|
||||
|
||||
public bool IsExamCover => MetadataItems.Any(x =>
|
||||
x.Name.Trim().Equals(TemplateMetadataKeys.ReportType, StringComparison.OrdinalIgnoreCase)
|
||||
&& x.Value.Trim().Equals(TemplateReportTypes.ExamCover, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>Macht das Projekt zur Klausur-Deckblatt-Vorlage: setzt den Vorlagentyp und legt
|
||||
/// alle Platzhalter an, die die Hauptapp dafür befüllt (mit Beispielwerten für die Vorschau).
|
||||
/// Vorhandene Platzhalter und das Layout bleiben unangetastet.</summary>
|
||||
public void SetUpExamCover()
|
||||
{
|
||||
var reportType = MetadataItems.FirstOrDefault(x =>
|
||||
x.Name.Trim().Equals(TemplateMetadataKeys.ReportType, StringComparison.OrdinalIgnoreCase));
|
||||
if (reportType is null) MetadataItems.Add(new(TemplateMetadataKeys.ReportType, TemplateReportTypes.ExamCover));
|
||||
else reportType.Value = TemplateReportTypes.ExamCover;
|
||||
|
||||
var conflicts = new List<string>();
|
||||
var added = 0;
|
||||
foreach (var known in ExamCoverPlaceholders.All)
|
||||
{
|
||||
var existing = Placeholders.FirstOrDefault(p => p.Name.Trim() == known.Name);
|
||||
if (existing is null) { Placeholders.Add(new(known.Name, known.Type, false, known.Sample)); added++; }
|
||||
else if (existing.Type != known.Type) conflicts.Add(known.Name);
|
||||
}
|
||||
CanExport = false;
|
||||
if (conflicts.Count > 0)
|
||||
SetStatus($"Als Klausur-Deckblatt eingerichtet, {added} Platzhalter ergänzt. Achtung, abweichender Typ bei: {string.Join(", ", conflicts)} — die Hauptapp liefert dafür einen anderen Typ.", true);
|
||||
else
|
||||
SetStatus($"Als Klausur-Deckblatt eingerichtet, {added} Platzhalter ergänzt. Die Punktetabelle steht unter „LehrerApp-Sonderinhalte“.", false);
|
||||
}
|
||||
|
||||
public void ApplyPdfImport(PdfImportResult import)
|
||||
{
|
||||
@@ -226,7 +269,8 @@ public partial class DesignerViewModel : ObservableObject
|
||||
NewContent = "$" + special.PlaceholderName;
|
||||
NewWidth = special.RecommendedWidth;
|
||||
NewHeight = special.RecommendedHeight;
|
||||
NewAttributes = "";
|
||||
NewAttributes = special.RecommendedAttributes;
|
||||
if (special.InContentFlow) NewElementScope = "Content-Flow";
|
||||
CanExport = false;
|
||||
SetStatus($"„{special.DisplayName}“ vorbereitet. Position und Größe prüfen, dann ins Layout übernehmen.", false);
|
||||
}
|
||||
@@ -255,7 +299,7 @@ public partial class DesignerViewModel : ObservableObject
|
||||
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.ConstantValue ?? "" : DesignerPlaceholder.SampleFor(placeholder.Name, placeholder.Type),
|
||||
placeholder.IsConstant, placeholder.Bold, placeholder.Italic, placeholder.Underline));
|
||||
SelectedPlaceholder = Placeholders.FirstOrDefault();
|
||||
Assets.Clear(); AssetItems.Clear();
|
||||
@@ -305,14 +349,13 @@ public partial class DesignerViewModel : ObservableObject
|
||||
|
||||
public void PreviewTemplate(LoadedTemplate template)
|
||||
{
|
||||
var provider = BuildSampleDataProvider(template.Manifest);
|
||||
RenderPreviewPages(template, provider);
|
||||
RenderPreviewPages(template, BuildSampleDataProvider(template.Manifest));
|
||||
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);
|
||||
var pages = new QuestTemplateRenderer().RenderPagesToPng(template, FitSampleData(template, provider));
|
||||
ClearPreviewPages();
|
||||
for (var index = 0; index < pages.Count; index++)
|
||||
{
|
||||
@@ -331,7 +374,8 @@ public partial class DesignerViewModel : ObservableObject
|
||||
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());
|
||||
var bytes = new QuestTemplateRenderer().RenderFirstPageToPng(canvasTemplate,
|
||||
FitSampleData(canvasTemplate, BuildDataProvider()));
|
||||
using var stream = new MemoryStream(bytes);
|
||||
_pageTemplatePreview?.Dispose(); _pageTemplatePreview = new Bitmap(stream);
|
||||
SelectedPreviewPage = null; PreviewImage = _pageTemplatePreview;
|
||||
@@ -385,7 +429,7 @@ public partial class DesignerViewModel : ObservableObject
|
||||
|
||||
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(),
|
||||
x => new DesignerPlaceholder(x.Name, x.Type, x.Required, DesignerPlaceholder.SampleFor(x.Name, x.Type)).ToValue(),
|
||||
StringComparer.Ordinal));
|
||||
|
||||
public void AddElement()
|
||||
@@ -739,7 +783,8 @@ public sealed record DesignerPreviewPage(int Number, Bitmap Image)
|
||||
}
|
||||
|
||||
public sealed record SpecialContentItem(string DisplayName, string PlaceholderName, string Description,
|
||||
string RecommendedElementType, string RecommendedWidth, string RecommendedHeight);
|
||||
string RecommendedElementType, string RecommendedWidth, string RecommendedHeight,
|
||||
string RecommendedAttributes = "", bool InContentFlow = false);
|
||||
|
||||
public static class SpecialContentCatalog
|
||||
{
|
||||
@@ -751,6 +796,9 @@ public static class SpecialContentCatalog
|
||||
new("Fehltage nach Datum", "Student.AbsenceDays",
|
||||
"Chronologische Liste mit Datum, Fehlzeit oder ganzem Fehltag sowie Entschuldigungsstatus.",
|
||||
"FLOWDRAWBOX", "170", "140"),
|
||||
new("Punktetabelle (Klausur-Deckblatt)", ExamPointsTableDrawing.PlaceholderName,
|
||||
"Aufgabe / Max. Punktzahl / Punktzahl mit festen Spaltenbreiten; bei vielen Aufgaben folgt ein weiterer Block, und die Elemente darunter im Content-Flow wandern mit. Die Breite bestimmt, wie viele Aufgaben nebeneinander passen (165 = sechs).",
|
||||
"FLOWDRAWBOX", "165", "150", "fit=content", InContentFlow: true),
|
||||
];
|
||||
|
||||
public static DrawingValue SampleDrawing(string placeholderName) => placeholderName switch
|
||||
@@ -776,6 +824,7 @@ public static class SpecialContentCatalog
|
||||
new DrawString(42, 27, "Ganzer Fehltag", 7, "#374151"),
|
||||
new DrawString(116, 27, "Entschuldigt", 7, "#2E7D32"),
|
||||
], 38),
|
||||
ExamPointsTableDrawing.PlaceholderName => ExamPointsTableDrawing.Build(ExamCoverPlaceholders.SampleColumns, 165),
|
||||
_ => DesignerPlaceholder.GenericSampleDrawing(),
|
||||
};
|
||||
}
|
||||
@@ -837,6 +886,11 @@ public partial class DesignerPlaceholder : ObservableObject
|
||||
PlaceholderType.Drawing => SpecialContentCatalog.SampleDrawing(Name),
|
||||
_ => new TextValue(Sample),
|
||||
};
|
||||
/// <summary>Bekannte LehrerApp-Platzhalter (Klausur-Deckblatt) bekommen einen sprechenden
|
||||
/// Beispielwert statt des allgemeinen je Typ.</summary>
|
||||
public static string SampleFor(string name, PlaceholderType type) =>
|
||||
ExamCoverPlaceholders.Find(name.Trim()) is { } known && known.Type == type && type != PlaceholderType.Drawing
|
||||
? known.Sample : SampleFor(type);
|
||||
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",
|
||||
|
||||
@@ -85,6 +85,8 @@
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<Button Content="Markierten Metadateneintrag entfernen" HorizontalAlignment="Left" Click="OnRemoveMetadata"/>
|
||||
<Button Content="Als Klausur-Deckblatt einrichten" HorizontalAlignment="Left" Click="OnSetUpExamCover"
|
||||
ToolTip.Tip="Setzt report-type=exam-cover und legt alle Platzhalter an, die die LehrerApp für Deckblätter befüllt (Exam.*, Result.*, …) — mit Beispielwerten für die Vorschau."/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
@@ -38,6 +38,7 @@ public partial class MainWindow : Window
|
||||
if (_viewModel.SelectedMetadata is not { } selected) return;
|
||||
_viewModel.MetadataItems.Remove(selected); _viewModel.SelectedMetadata = null; _viewModel.CanExport = false;
|
||||
}
|
||||
private void OnSetUpExamCover(object? sender, RoutedEventArgs e) => _viewModel.SetUpExamCover();
|
||||
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]");
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Templating.Tests;
|
||||
|
||||
public sealed class ExamCoverTemplatingTests : IDisposable
|
||||
{
|
||||
private readonly string _appData = Path.Combine(Path.GetTempPath(), $"lavorlage-cover-{Guid.NewGuid():N}");
|
||||
|
||||
private const string FlowLayout = """
|
||||
PAGE 210 297 mm
|
||||
#pragma format-version 3
|
||||
#pragma page-template first
|
||||
#pragma flow-slot body x=20 y=20 w=170 h=250
|
||||
#pragma end-page-template
|
||||
#pragma content-flow body
|
||||
FLOWDRAWBOX $Tabelle w=170 h=100 {0}
|
||||
TEXT "DANACH" size=11
|
||||
#pragma end-content-flow
|
||||
""";
|
||||
|
||||
private static readonly DrawingValue ShortDrawing = new(
|
||||
[new DrawStringEx(0, 0, 12, 170, "TABELLE", DrawingTextAlignment.AlignLeft, 10)], 20);
|
||||
|
||||
[Fact]
|
||||
public void FlowDrawBox_FitContent_NachfolgendesElementRuecktAnDenInhalt()
|
||||
{
|
||||
var fixedHeight = TextTop(Render("", ShortDrawing), "DANACH");
|
||||
var fitted = TextTop(Render("fit=content", ShortDrawing), "DANACH");
|
||||
|
||||
// PDF-Koordinaten wachsen nach oben: 100 mm Box gegen 20 mm Inhalt = 80 mm höher.
|
||||
Assert.Equal(80 * 72 / 25.4, fitted - fixedHeight, 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlowDrawBox_FitContent_LangerInhaltFliesstWeiterhinUeberVolleAusschnitte()
|
||||
{
|
||||
var drawing = new DrawingValue(
|
||||
[
|
||||
new DrawStringEx(0, 0, 12, 170, "OBEN", DrawingTextAlignment.AlignLeft, 10),
|
||||
new DrawStringEx(0, 105, 12, 170, "UNTEN", DrawingTextAlignment.AlignLeft, 10),
|
||||
], 120);
|
||||
|
||||
using var document = UglyToad.PdfPig.PdfDocument.Open(Render("fit=content", drawing));
|
||||
var text = document.GetPage(1).Text;
|
||||
|
||||
Assert.Contains("OBEN", text);
|
||||
Assert.Contains("UNTEN", text);
|
||||
Assert.Contains("DANACH", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlowDrawBox_UnbekannterFitWert_IstEinLayoutfehler() =>
|
||||
Assert.Throws<TemplateValidationException>(() =>
|
||||
new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 100 $Tabelle fit=page"));
|
||||
|
||||
[Fact]
|
||||
public void RenderBatchToPdf_FuegtDatensaetzeZusammenUndMeldetSeitenzahlen()
|
||||
{
|
||||
var template = Template("fit=content");
|
||||
var tall = new DrawingValue(
|
||||
[new DrawStringEx(0, 290, 12, 170, "LANG", DrawingTextAlignment.AlignLeft, 10)], 300);
|
||||
|
||||
var result = new QuestTemplateRenderer().RenderBatchToPdf(template,
|
||||
[
|
||||
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Tabelle"] = ShortDrawing }),
|
||||
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Tabelle"] = tall }),
|
||||
]);
|
||||
|
||||
using var document = UglyToad.PdfPig.PdfDocument.Open(result.Pdf);
|
||||
Assert.Equal([1, 2], result.PageCounts);
|
||||
Assert.Equal(3, document.NumberOfPages);
|
||||
Assert.Contains("TABELLE", document.GetPage(1).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TemplateStore_LiestVorlagentypAusDenMetadaten()
|
||||
{
|
||||
var store = new TemplateStore(_appData);
|
||||
var cover = Path.Combine(_appData, "cover.lavorlage");
|
||||
var letter = Path.Combine(_appData, "letter.lavorlage");
|
||||
TemplatePackage.Create(cover, new TemplateManifest
|
||||
{
|
||||
Id = "deckblatt", Name = "Deckblatt",
|
||||
Metadata = new(StringComparer.OrdinalIgnoreCase) { [TemplateMetadataKeys.ReportType] = TemplateReportTypes.ExamCover },
|
||||
}, "PAGE 210 297 mm\nTEXT 20 20 \"Text\"", new Dictionary<string, byte[]>());
|
||||
TemplatePackage.Create(letter, new TemplateManifest { Id = "brief", Name = "Brief" },
|
||||
"PAGE 210 297 mm\nTEXT 20 20 \"Text\"", new Dictionary<string, byte[]>());
|
||||
|
||||
Assert.True(store.Import(cover).IsExamCover);
|
||||
store.Import(letter);
|
||||
|
||||
Assert.Equal(["deckblatt"], store.GetTemplates().Where(x => x.IsExamCover).Select(x => x.Id));
|
||||
}
|
||||
|
||||
private static LoadedTemplate Template(string attributes) => new(new TemplateManifest
|
||||
{
|
||||
Placeholders = [new("Tabelle", PlaceholderType.Drawing, true)],
|
||||
}, new LayoutParser().Parse(FlowLayout.Replace("{0}", attributes)), new Dictionary<string, byte[]>());
|
||||
|
||||
private static byte[] Render(string attributes, DrawingValue drawing) =>
|
||||
new QuestTemplateRenderer().RenderToPdf(Template(attributes),
|
||||
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Tabelle"] = drawing }));
|
||||
|
||||
private static double TextTop(byte[] pdf, string word)
|
||||
{
|
||||
using var document = UglyToad.PdfPig.PdfDocument.Open(pdf);
|
||||
return document.GetPage(1).GetWords().Single(x => x.Text == word).BoundingBox.Top;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_appData)) Directory.Delete(_appData, recursive: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
/// <summary>Eine Spalte der Punktetabelle auf dem Klausur-Deckblatt. Bonusaufgaben tragen keine
|
||||
/// Nummer; <see cref="AchievedPoints"/> ist null, solange nichts eingetragen werden soll.</summary>
|
||||
public sealed record ExamPointsTableColumn(string Number, double MaxPoints, bool IsBonus, double? AchievedPoints);
|
||||
|
||||
/// <summary>Zeichnet die Punktetabelle des Klausur-Deckblatts (Aufgabe / Max. Punktzahl /
|
||||
/// Punktzahl). Die Spalten haben eine feste Breite: passen nicht alle Aufgaben nebeneinander in die
|
||||
/// Box der Vorlage, folgt darunter ein weiterer Tabellenblock, statt alles zu verkleinern. Gedacht
|
||||
/// für eine <c>FLOWDRAWBOX … fit=content</c>, damit nachfolgende Elemente mitwandern. Liegt in der
|
||||
/// Vorlagenbibliothek, damit Hauptapp und Vorlagen-Designer exakt dieselbe Tabelle zeichnen.</summary>
|
||||
public static class ExamPointsTableDrawing
|
||||
{
|
||||
public const string PlaceholderName = "Exam.PointsTable";
|
||||
|
||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||
|
||||
// Referenzentwurf in Millimetern, abgemessen an der Schulvorlage.
|
||||
private const float LabelWidth = 38f, ColumnWidth = 20.5f;
|
||||
private const float HeaderHeight = 5.5f, MaximumHeight = 8.5f, PointsHeight = 11.5f;
|
||||
private const float BlockGap = 4f, Padding = 1.5f, Stroke = 0.2f, HeavyStroke = 0.45f;
|
||||
private const float FontSize = 10f;
|
||||
|
||||
/// <param name="contentWidth">Breite der deklarierten Box in der Maßeinheit der Vorlage.</param>
|
||||
/// <param name="millimeterScale">1 mm in der Maßeinheit der Vorlage (siehe
|
||||
/// <see cref="DeclaredDrawingBox.MillimeterScale"/>).</param>
|
||||
public static DrawingValue Build(IReadOnlyList<ExamPointsTableColumn> columns, float contentWidth = 170,
|
||||
float millimeterScale = 1)
|
||||
{
|
||||
if (columns.Count == 0) return new DrawingValue([], 0);
|
||||
var k = millimeterScale;
|
||||
var perBlock = ColumnsPerBlock(contentWidth, millimeterScale);
|
||||
// Halbe Strichstärke Abstand zum Rand, sonst schneidet die Box die äußeren Linien an.
|
||||
var origin = HeavyStroke / 2 * k;
|
||||
var blockHeight = (HeaderHeight + MaximumHeight + PointsHeight) * k;
|
||||
var commands = new List<DrawingCommand>();
|
||||
var y = origin;
|
||||
for (var start = 0; start < columns.Count; start += perBlock)
|
||||
{
|
||||
if (start > 0) y += BlockGap * k;
|
||||
var block = columns.Skip(start).Take(perBlock).ToList();
|
||||
var maximumY = y + HeaderHeight * k;
|
||||
var pointsY = maximumY + MaximumHeight * k;
|
||||
|
||||
Cell(commands, origin, y, LabelWidth * k, HeaderHeight * k, "Aufgabe", DrawingTextAlignment.AlignRight, false, k);
|
||||
Cell(commands, origin, maximumY, LabelWidth * k, MaximumHeight * k, "Max. Punktzahl", DrawingTextAlignment.AlignRight, false, k);
|
||||
Cell(commands, origin, pointsY, LabelWidth * k, PointsHeight * k, "Punktzahl", DrawingTextAlignment.AlignRight, true, k);
|
||||
for (var index = 0; index < block.Count; index++)
|
||||
{
|
||||
var column = block[index];
|
||||
var x = origin + (LabelWidth + index * ColumnWidth) * k;
|
||||
Cell(commands, x, y, ColumnWidth * k, HeaderHeight * k,
|
||||
column.IsBonus ? "" : $"Nr. {column.Number}", DrawingTextAlignment.AlignCenter, false, k);
|
||||
Cell(commands, x, maximumY, ColumnWidth * k, MaximumHeight * k,
|
||||
(column.IsBonus ? "+" : "") + Format(column.MaxPoints), DrawingTextAlignment.AlignCenter, false, k);
|
||||
Cell(commands, x, pointsY, ColumnWidth * k, PointsHeight * k,
|
||||
column.AchievedPoints is { } achieved ? Format(achieved) : "", DrawingTextAlignment.AlignCenter, false, k);
|
||||
}
|
||||
commands.Add(new DrawLine(origin, pointsY, origin + (LabelWidth + block.Count * ColumnWidth) * k, pointsY,
|
||||
StrokeWidth: HeavyStroke * k));
|
||||
y += blockHeight;
|
||||
}
|
||||
return new DrawingValue(commands, y + origin);
|
||||
}
|
||||
|
||||
/// <summary>Wie viele Aufgaben bei fester Spaltenbreite nebeneinander in die Box passen.</summary>
|
||||
public static int ColumnsPerBlock(float contentWidth, float millimeterScale = 1) =>
|
||||
Math.Max(1, (int)Math.Floor((contentWidth / millimeterScale - HeavyStroke - LabelWidth + 0.01f) / ColumnWidth));
|
||||
|
||||
private static void Cell(List<DrawingCommand> commands, float x, float y, float width, float height,
|
||||
string text, DrawingTextAlignment alignment, bool bold, float k)
|
||||
{
|
||||
commands.Add(new DrawRectangle(x, y, width, height, StrokeWidth: Stroke * k));
|
||||
if (text.Length == 0) return;
|
||||
// DrawStringEx setzt die Grundlinie auf Y + Schriftgröße; so sitzt die Zeile optisch mittig.
|
||||
var fontHeight = FontSize * UnitConverter.Points(1, "pt") / UnitConverter.Points(1, "mm") * k;
|
||||
var textY = y + height / 2 - fontHeight * 0.64f;
|
||||
commands.Add(new DrawStringEx(x + Padding * k, textY, y + height - textY, width - 2 * Padding * k, text,
|
||||
alignment, FontSize, Bold: bold));
|
||||
}
|
||||
|
||||
private static string Format(double value) => value.ToString("0.##", German);
|
||||
}
|
||||
|
||||
/// <summary>Ein Platzhalter, den die Hauptapp für Klausur-Deckblätter befüllt, mit einem
|
||||
/// Beispielwert für die Vorschau im Vorlagen-Designer.</summary>
|
||||
public sealed record ExamCoverPlaceholder(string Name, PlaceholderType Type, string Sample, string Description);
|
||||
|
||||
/// <summary>Katalog der Platzhalter für Vorlagen mit <c>report-type=exam-cover</c>. Die Hauptapp
|
||||
/// (<c>ExamCoverPlaceholderBuilder</c>) muss genau diese Namen und Typen liefern; ein Test dort
|
||||
/// hält beide Seiten zusammen.</summary>
|
||||
public static class ExamCoverPlaceholders
|
||||
{
|
||||
public static IReadOnlyList<ExamCoverPlaceholder> All { get; } =
|
||||
[
|
||||
new("Student.Name", PlaceholderType.Text, "Beispiel, Erika", "Name des Schülers (Nachname, Vorname)"),
|
||||
new("Student.FirstName", PlaceholderType.Text, "Erika", "Vorname des Schülers"),
|
||||
new("Student.LastName", PlaceholderType.Text, "Beispiel", "Nachname des Schülers"),
|
||||
new("Group.Name", PlaceholderType.Text, "9 Ch G", "Name der Lerngruppe"),
|
||||
new("Group.GradeLevel", PlaceholderType.Text, "9", "Jahrgangsstufe der Lerngruppe"),
|
||||
new("Subject.Name", PlaceholderType.Text, "Chemie", "Fach der Lerngruppe"),
|
||||
new("SchoolYear", PlaceholderType.Text, "2025/26", "Schuljahr der Lerngruppe"),
|
||||
new("Teacher.Abbreviation", PlaceholderType.Text, "HED", "Kürzel der Lehrkraft (Einstellungen, je Druck änderbar)"),
|
||||
new("Exam.Title", PlaceholderType.Text, "Atombau und PSE", "Titel der Arbeit"),
|
||||
new("Exam.Date", PlaceholderType.Date, "2025-12-18", "Datum der Arbeit"),
|
||||
new("Exam.Number", PlaceholderType.Text, "1", "Nummer der Arbeit"),
|
||||
new("Exam.Kind", PlaceholderType.Text, "Klausur", "Art der Arbeit"),
|
||||
new("Exam.Niveau", PlaceholderType.Text, "G-Niveau", "Niveau der Arbeit"),
|
||||
new("Exam.Course", PlaceholderType.Text, "G-Kurs", "E-Kurs / G-Kurs, leer im Klassenverband"),
|
||||
new("Exam.Variant", PlaceholderType.Text, "A", "Geschriebene Fassung, leer ohne Fassungen"),
|
||||
new("Exam.MaxPoints", PlaceholderType.Text, "62", "Erreichbare Punktzahl ohne Bonusaufgaben"),
|
||||
new("Exam.ApprovalDate", PlaceholderType.Text, "08.12.2025", "Datum der Genehmigung"),
|
||||
new("Exam.AnnouncedDate", PlaceholderType.Text, "04.12.2025", "Datum der Ankündigung"),
|
||||
new("Exam.ReturnDate", PlaceholderType.Text, "08.01.2026", "Datum der Rückgabe"),
|
||||
new("Result.Points", PlaceholderType.Text, "52,5", "Erreichte Punkte, als Blanko eine Linie"),
|
||||
new("Result.Percent", PlaceholderType.Text, "85 %", "Erreichter Anteil, als Blanko leer"),
|
||||
new("Result.Grade", PlaceholderType.Text, "2", "Note, als Blanko eine Linie"),
|
||||
new(ExamPointsTableDrawing.PlaceholderName, PlaceholderType.Drawing, "",
|
||||
"Punktetabelle (Aufgabe / Max. Punktzahl / Punktzahl)"),
|
||||
];
|
||||
|
||||
public static ExamCoverPlaceholder? Find(string name) =>
|
||||
All.FirstOrDefault(x => x.Name.Equals(name, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>Acht Aufgaben und eine Bonusaufgabe: genug für einen zweiten Tabellenblock, damit
|
||||
/// die Vorschau zeigt, wie die Elemente darunter mitwandern.</summary>
|
||||
public static IReadOnlyList<ExamPointsTableColumn> SampleColumns { get; } =
|
||||
[
|
||||
new("1", 5, false, 4), new("2", 12, false, 10.5), new("3", 6, false, 6), new("4", 4, false, 3),
|
||||
new("5", 9, false, 7), new("6", 9, false, 8), new("7", 3, false, 2), new("8", 14, false, 11),
|
||||
new("", 3, true, 1),
|
||||
];
|
||||
|
||||
/// <summary>Beispieltabelle in der Größe der Box, die die Vorlage für
|
||||
/// <see cref="ExamPointsTableDrawing.PlaceholderName"/> deklariert.</summary>
|
||||
public static DrawingValue SampleTable(LoadedTemplate template) =>
|
||||
DrawingBoxLocator.Find(template, ExamPointsTableDrawing.PlaceholderName) is { } box
|
||||
? ExamPointsTableDrawing.Build(SampleColumns, box.Width, box.MillimeterScale)
|
||||
: ExamPointsTableDrawing.Build(SampleColumns);
|
||||
}
|
||||
|
||||
/// <summary>Tatsächliche DRAWBOX/FLOWDRAWBOX-Größe eines Platzhalters (in der Maßeinheit der
|
||||
/// Vorlage) plus dem Umrechnungsfaktor "1mm in dieser Maßeinheit". Drawing-Builder entwerfen ihr
|
||||
/// Raster in Millimetern; ohne MillimeterScale bliebe das bei einer in "pt" (statt "mm")
|
||||
/// deklarierten Vorlage viel zu klein (1 "mm-Einheit" würde als 1pt ≈ 0.35mm gerendert). IsFixed
|
||||
/// unterscheidet DRAWBOX (feste, nicht umbrechende Box - Inhalt, der die Höhe sprengt, wird von
|
||||
/// QuestTemplateRenderer stillschweigend am unteren Rand abgeschnitten statt umzubrechen) von
|
||||
/// FLOWDRAWBOX (fließt bei Bedarf automatisch auf weitere Seiten, siehe
|
||||
/// DrawingElementRenderer.Slice) - nur bei IsFixed=true darf/muss ein Builder seine Höhe an Height
|
||||
/// anpassen.</summary>
|
||||
public readonly record struct DeclaredDrawingBox(float Width, float Height, float MillimeterScale, bool IsFixed);
|
||||
|
||||
public static class DrawingBoxLocator
|
||||
{
|
||||
public static DeclaredDrawingBox? Find(LoadedTemplate template, string placeholderName) =>
|
||||
Find(template.Layout, placeholderName) ??
|
||||
(template.ContinuationLayout is not null ? Find(template.ContinuationLayout, placeholderName) : null);
|
||||
|
||||
private static DeclaredDrawingBox? Find(TemplateLayout layout, string placeholderName)
|
||||
{
|
||||
var box = layout.Elements.Concat(layout.PageTemplates.SelectMany(p => p.Elements))
|
||||
.Concat(layout.ContentFlows.SelectMany(f => f.Elements))
|
||||
.Select(e => e switch
|
||||
{
|
||||
DrawBoxElement draw when draw.Placeholder == placeholderName => ((float Width, float Height, bool IsFixed)?)(draw.Width, draw.Height, true),
|
||||
FlowDrawBoxElement flow when flow.Placeholder == placeholderName => (flow.Width, flow.Height, false),
|
||||
_ => null,
|
||||
})
|
||||
.FirstOrDefault(b => b is not null);
|
||||
if (box is null) return null;
|
||||
var millimeterScale = UnitConverter.Points(1, "mm") / UnitConverter.Points(1, layout.Unit);
|
||||
return new DeclaredDrawingBox(box.Value.Width, box.Value.Height, millimeterScale, box.Value.IsFixed);
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,9 @@ public sealed class LayoutParser
|
||||
var nestedFlowDrawing = currentFlowName is not null;
|
||||
Require(tokens, nestedFlowDrawing ? 2 : 6);
|
||||
var flowDrawingAttributes = Attributes(tokens, nestedFlowDrawing ? 2 : 6);
|
||||
if (flowDrawingAttributes.TryGetValue("fit", out var fit)
|
||||
&& !fit.Equals("content", StringComparison.OrdinalIgnoreCase))
|
||||
throw new FormatException("FLOWDRAWBOX fit muss content sein.");
|
||||
parsedElement = new FlowDrawBoxElement(lineNumber,
|
||||
nestedFlowDrawing ? 0 : Number(tokens[1]), nestedFlowDrawing ? 0 : Number(tokens[2]),
|
||||
nestedFlowDrawing ? RequiredPositiveNumber(flowDrawingAttributes, "w") : Number(tokens[3]),
|
||||
|
||||
@@ -186,6 +186,15 @@ public interface ITemplateRenderer
|
||||
byte[] RenderToPdf(LoadedTemplate template, ITemplateDataProvider data);
|
||||
}
|
||||
|
||||
/// <summary>Ergebnis einer Stapelerzeugung: ein Sammel-PDF und die Seitenzahl je Datensatz.</summary>
|
||||
public sealed record BatchRenderResult(byte[] Pdf, IReadOnlyList<int> PageCounts);
|
||||
|
||||
public interface ITemplateBatchRenderer
|
||||
{
|
||||
/// <summary>Rendert dieselbe Vorlage je Datensatz und fügt alles zu einem PDF zusammen.</summary>
|
||||
BatchRenderResult RenderBatchToPdf(LoadedTemplate template, IReadOnlyList<ITemplateDataProvider> data);
|
||||
}
|
||||
|
||||
public interface ITemplatePreviewRenderer
|
||||
{
|
||||
byte[] RenderFirstPageToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120);
|
||||
|
||||
@@ -7,13 +7,26 @@ using QuestPDF.Infrastructure;
|
||||
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewRenderer
|
||||
public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewRenderer, ITemplateBatchRenderer
|
||||
{
|
||||
static QuestTemplateRenderer() => QuestPDF.Settings.License = LicenseType.Community;
|
||||
|
||||
public byte[] RenderToPdf(LoadedTemplate template, ITemplateDataProvider data) =>
|
||||
BuildDocument(template, ValidateData(template, data)).GeneratePdf();
|
||||
|
||||
public BatchRenderResult RenderBatchToPdf(LoadedTemplate template, IReadOnlyList<ITemplateDataProvider> data)
|
||||
{
|
||||
if (data.Count == 0) throw new ArgumentException("Mindestens ein Datensatz ist erforderlich.", nameof(data));
|
||||
var documents = data.Select(x => BuildDocument(template, ValidateData(template, x))).ToList();
|
||||
// Seitenzahl je Datensatz getrennt ermitteln: das zusammengeführte Dokument verrät nicht
|
||||
// mehr, welcher Datensatz unerwartet auf eine Folgeseite übergelaufen ist.
|
||||
var pageCounts = documents.Select(x => PdfPageCounter.Count(x.GeneratePdf())).ToList();
|
||||
var pdf = documents.Count == 1
|
||||
? documents[0].GeneratePdf()
|
||||
: Document.Merge(documents).UseOriginalPageNumbers().GeneratePdf();
|
||||
return new(pdf, pageCounts);
|
||||
}
|
||||
|
||||
public byte[] RenderFirstPageToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
|
||||
{
|
||||
return RenderPagesToPng(template, data, dpi).First();
|
||||
@@ -126,7 +139,8 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
||||
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);
|
||||
DrawingElementRenderer.RenderFlow(content, drawingValue, drawing.Width, drawing.Height, unit,
|
||||
FitsContent(drawing));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -242,6 +256,11 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
||||
}
|
||||
}
|
||||
|
||||
// fit=content (Opt-in): die FLOWDRAWBOX belegt nur die Höhe ihres tatsächlichen Inhalts statt
|
||||
// immer die volle deklarierte Höhe - nachfolgende Flow-Elemente rücken direkt an.
|
||||
private static bool FitsContent(FlowDrawBoxElement drawing) =>
|
||||
drawing.Attributes.TryGetValue("fit", out var fit) && fit.Equals("content", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static void RenderFlowElement(IContainer container, TemplateElement element, LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||
{
|
||||
@@ -267,7 +286,7 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
||||
break;
|
||||
case FlowDrawBoxElement drawing when values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue:
|
||||
DrawingElementRenderer.RenderFlow(container, drawingValue,
|
||||
drawing.Width, drawing.Height, template.Layout.Unit);
|
||||
drawing.Width, drawing.Height, template.Layout.Unit, FitsContent(drawing));
|
||||
break;
|
||||
case ImageElement image:
|
||||
var imageContainer = container;
|
||||
@@ -520,7 +539,8 @@ internal static class DrawingElementRenderer
|
||||
container.Svg(BuildSvg(drawing, width, height, 0, unit));
|
||||
}
|
||||
|
||||
public static void RenderFlow(IContainer container, PlaceholderValue value, float width, float pageHeight, string unit)
|
||||
public static void RenderFlow(IContainer container, PlaceholderValue value, float width, float pageHeight, string unit,
|
||||
bool fitContent = false)
|
||||
{
|
||||
var pages = value switch
|
||||
{
|
||||
@@ -529,6 +549,11 @@ internal static class DrawingElementRenderer
|
||||
.Select(commands => new DrawingValue(commands, pageHeight)).ToList(),
|
||||
_ => throw new InvalidDataException("FLOWDRAWBOX erwartet einen Drawing-Platzhalter."),
|
||||
};
|
||||
// Nur ein direkter DrawingValue kennt seine Inhaltshöhe; ein per Callback aufgezeichneter
|
||||
// Inhalt füllt weiterhin jede Box vollständig.
|
||||
var lastHeight = fitContent && value is DrawingValue fitted
|
||||
? Math.Clamp(fitted.ContentHeight - (pages.Count - 1) * pageHeight, 0, pageHeight)
|
||||
: pageHeight;
|
||||
container.Column(column =>
|
||||
{
|
||||
// Kein manueller PageBreak() zwischen den Ausschnitten: RenderFlow sitzt in einer
|
||||
@@ -538,12 +563,14 @@ internal static class DrawingElementRenderer
|
||||
// z.B. begann der zweite Monat der Fehlzeitenliste auf Seite 3, obwohl auf Seite 2
|
||||
// noch reichlich Platz war. QuestPDF schiebt einen Ausschnitt, der nicht mehr passt,
|
||||
// ohnehin automatisch auf die nächste Seite.
|
||||
foreach (var page in pages)
|
||||
for (var index = 0; index < pages.Count; index++)
|
||||
{
|
||||
var height = index == pages.Count - 1 ? lastHeight : pageHeight;
|
||||
if (height <= 0) continue;
|
||||
// Koordinaten sind nach Slice()/RecordPages() bereits seitenlokal (bei 0 beginnend) -
|
||||
// kein zusätzlicher viewBox-Y-Offset nötig oder sinnvoll (siehe Slice()).
|
||||
column.Item().Height(UnitConverter.Points(pageHeight, unit)).Svg(
|
||||
BuildSvg(page, width, pageHeight, 0, unit));
|
||||
column.Item().Height(UnitConverter.Points(height, unit)).Svg(
|
||||
BuildSvg(pages[index], width, height, 0, unit));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -745,6 +772,14 @@ internal static class DrawingElementRenderer
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Zählt die Seiten eines von QuestPDF erzeugten PDFs anhand der unkomprimierten
|
||||
/// Seitenobjekte ("/Type /Page", nicht "/Type /Pages").</summary>
|
||||
internal static class PdfPageCounter
|
||||
{
|
||||
public static int Count(byte[] pdf) => System.Text.RegularExpressions.Regex
|
||||
.Matches(Encoding.Latin1.GetString(pdf), @"/Type\s*/Page(?![A-Za-z])").Count;
|
||||
}
|
||||
|
||||
internal static class ChartElementRenderer
|
||||
{
|
||||
public static void Render(IContainer container, ChartValue value, string chartType,
|
||||
|
||||
@@ -51,6 +51,21 @@ gleich hohe Seitenfenster und setzt ihn auf Folgeseiten fort. Wie bei `FLOWBOX`
|
||||
höchstens ein fließendes Element enthalten; bei einem eigenen Folgeseitenlayout müssen Typ,
|
||||
Position und Größe übereinstimmen.
|
||||
|
||||
Standardmäßig belegt jeder Ausschnitt einer `FLOWDRAWBOX` die volle deklarierte Höhe, auch wenn
|
||||
der Inhalt kürzer ist. Mit dem Opt-in-Attribut `fit=content` belegt der letzte Ausschnitt nur die
|
||||
tatsächliche Inhaltshöhe (`ContentHeight`), sodass nachfolgende Elemente eines `content-flow`
|
||||
direkt anschließen und bei wachsendem Inhalt nach unten wandern:
|
||||
|
||||
```text
|
||||
#pragma content-flow body
|
||||
FLOWDRAWBOX $Exam.PointsTable w=165 h=150 fit=content
|
||||
TEXT "Gesamtpunkte: $Result.Points von $Exam.MaxPoints Punkte" gap=14
|
||||
#pragma end-content-flow
|
||||
```
|
||||
|
||||
`fit=content` wirkt nur bei einem direkt gelieferten `DrawingValue`; ein per Callback
|
||||
aufgezeichneter Inhalt (`PagedDrawingValue`) füllt weiterhin jede Box vollständig.
|
||||
|
||||
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
|
||||
@@ -195,6 +210,43 @@ eindeutig und Werte bleiben einzeilig. Der Loader stellt sie der Anwendung direk
|
||||
weiterhin mit einer leeren Metadatensammlung geladen. Die empfohlenen Standardschlüssel stehen
|
||||
zusätzlich als `TemplateMetadataKeys.Language` und `TemplateMetadataKeys.ReportType` bereit.
|
||||
|
||||
## Klausur-Deckblätter (`report-type=exam-cover`)
|
||||
|
||||
Eine Vorlage mit dem Metadatum `report-type=exam-cover` (`TemplateReportTypes.ExamCover`) erscheint
|
||||
in der Hauptapp ausschließlich im Dialog „Deckblätter…" einer Klausur und nicht mehr in der Auswahl
|
||||
für Elternbriefe und Arbeitsblätter. Importiert wird sie wie jede andere `.lavorlage` in den
|
||||
Einstellungen. Je ausgewähltem Schüler entsteht eine Seite in einem Sammel-PDF
|
||||
(`ITemplateBatchRenderer.RenderBatchToPdf`). Zusätzlich zu den Elternbrief-Platzhaltern
|
||||
(`Student.Name`, `Student.FirstName`, `Student.LastName`, `Group.Name`, `SchoolYear`, `Datum`)
|
||||
stehen bereit:
|
||||
|
||||
| Platzhalter | Typ | Inhalt |
|
||||
| --- | --- | --- |
|
||||
| `Exam.Title`, `Exam.Number`, `Exam.Kind`, `Exam.Niveau` | Text | Titel, Nummer, Art, Niveau der Arbeit |
|
||||
| `Exam.Date` | Date | Datum der Arbeit |
|
||||
| `Exam.ApprovalDate`, `Exam.AnnouncedDate`, `Exam.ReturnDate` | Text | Genehmigung, Ankündigung, Rückgabe (`TT.MM.JJJJ`, leer wenn nicht gesetzt) |
|
||||
| `Exam.Course` | Text | `E-Kurs` / `G-Kurs` (Niveau der Arbeit, sonst des Schülers in der Lerngruppe); leer im Klassenverband |
|
||||
| `Teacher.Abbreviation` | Text | Kürzel der Lehrkraft aus den Einstellungen, im Deckblatt-Dialog je Druck änderbar |
|
||||
| `Exam.Variant` | Text | geschriebene Fassung (A/B), leer ohne Fassungen |
|
||||
| `Exam.MaxPoints` | Text | erreichbare Punktzahl der geschriebenen Fassung, ohne Bonusaufgaben |
|
||||
| `Result.Points`, `Result.Grade` | Text | erreichte Punkte und Note; als Blanko eine Linie aus Unterstrichen |
|
||||
| `Result.Percent` | Text | erreichter Anteil, als Blanko leer |
|
||||
| `Subject.Name`, `Group.GradeLevel` | Text | Fach und Jahrgangsstufe der Lerngruppe |
|
||||
| `Exam.PointsTable` | Drawing | Punktetabelle (Aufgabe / Max. Punktzahl / Punktzahl) |
|
||||
|
||||
Im Vorlagen-Designer richtet „Als Klausur-Deckblatt einrichten" (Reiter mit den Metadaten) ein
|
||||
Projekt dafür ein: Der Vorlagentyp wird gesetzt und alle Platzhalter aus dieser Tabelle werden mit
|
||||
Beispielwerten angelegt (`ExamCoverPlaceholders`, derselbe Katalog, gegen den die Hauptapp getestet
|
||||
ist). Die Punktetabelle steht unter „LehrerApp-Sonderinhalte" und wird als
|
||||
`FLOWDRAWBOX … fit=content` in den Content-Flow vorbereitet. Die Vorschau zeichnet sie mit
|
||||
`ExamPointsTableDrawing` — derselbe Code wie in der Hauptapp — in der Breite der deklarierten Box,
|
||||
mit acht Aufgaben und einer Bonusaufgabe.
|
||||
|
||||
Die Punktetabelle hat feste Spaltenbreiten (38 mm Beschriftung, 20,5 mm je Aufgabe). Passen nicht
|
||||
alle Aufgaben in die Breite der Box, folgt darunter ein weiterer Block; verkleinert wird nichts.
|
||||
Sie gehört deshalb als `FLOWDRAWBOX … fit=content` in einen `content-flow`. Bonusaufgaben stehen
|
||||
ohne Nummer und mit `+` vor der Punktzahl in der Tabelle.
|
||||
|
||||
## Bildskalierung
|
||||
|
||||
`IMG` unterstützt neben dem festen Begrenzungsrahmen eine optionale prozentuale Skalierung:
|
||||
|
||||
@@ -6,6 +6,14 @@ public static class TemplateMetadataKeys
|
||||
public const string ReportType = "report-type";
|
||||
}
|
||||
|
||||
/// <summary>Bekannte Werte für <see cref="TemplateMetadataKeys.ReportType"/>. Pakete ohne Angabe
|
||||
/// (oder mit einem anderen Wert) gelten wie bisher als Brief-/Arbeitsblattvorlage.</summary>
|
||||
public static class TemplateReportTypes
|
||||
{
|
||||
public const string Letter = "letter";
|
||||
public const string ExamCover = "exam-cover";
|
||||
}
|
||||
|
||||
public static class TemplateMetadataText
|
||||
{
|
||||
public const string FileName = "metadata.txt";
|
||||
|
||||
@@ -63,7 +63,10 @@ public static class TemplatePackage
|
||||
}
|
||||
|
||||
public sealed record InstalledTemplate(string Id, string Name, string Description, string PackagePath,
|
||||
DateTime ImportedAtUtc);
|
||||
DateTime ImportedAtUtc, string ReportType = "")
|
||||
{
|
||||
public bool IsExamCover => ReportType.Equals(TemplateReportTypes.ExamCover, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class TemplateStore
|
||||
{
|
||||
@@ -86,7 +89,7 @@ public sealed class TemplateStore
|
||||
{
|
||||
var loaded = _loader.LoadFromPackage(path);
|
||||
result.Add(new(loaded.Manifest.Id, loaded.Manifest.Name, loaded.Manifest.Description, path,
|
||||
File.GetLastWriteTimeUtc(path)));
|
||||
File.GetLastWriteTimeUtc(path), ReportTypeOf(loaded)));
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or TemplateValidationException) { }
|
||||
}
|
||||
@@ -102,9 +105,13 @@ public sealed class TemplateStore
|
||||
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);
|
||||
return new(loaded.Manifest.Id, loaded.Manifest.Name, loaded.Manifest.Description, destination, DateTime.UtcNow,
|
||||
ReportTypeOf(loaded));
|
||||
}
|
||||
|
||||
private static string ReportTypeOf(LoadedTemplate loaded) =>
|
||||
loaded.Manifest.Metadata.GetValueOrDefault(TemplateMetadataKeys.ReportType, "");
|
||||
|
||||
public LoadedTemplate Load(InstalledTemplate template) => _loader.LoadFromPackage(template.PackagePath);
|
||||
public void Delete(string id)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Tests;
|
||||
|
||||
public class ExamCoverSheetServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Columns_BonusaufgabeHatKeineNummerUndZaehltNichtMit()
|
||||
{
|
||||
List<ExamTask> tasks =
|
||||
[
|
||||
new() { Nr = 1, MaxPoints = 5 },
|
||||
new() { Nr = 2, MaxPoints = 3, IsBonus = true },
|
||||
new() { Nr = 3, MaxPoints = 12 },
|
||||
];
|
||||
|
||||
var columns = ExamCoverSheetService.Columns(tasks, ExamVariant.A, null);
|
||||
|
||||
Assert.Equal(["1", "", "2"], columns.Select(c => c.Number));
|
||||
Assert.True(columns[1].IsBonus);
|
||||
Assert.All(columns, c => Assert.Null(c.AchievedPoints));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Columns_NurAufgabenDerGeschriebenenFassung_MitPunktenAusDerGesamtreihenfolge()
|
||||
{
|
||||
List<ExamTask> tasks =
|
||||
[
|
||||
new() { Nr = 1, MaxPoints = 5 },
|
||||
new() { Nr = 2, MaxPoints = 6, Variant = ExamVariant.A },
|
||||
new() { Nr = 3, MaxPoints = 7, Variant = ExamVariant.B },
|
||||
new() { Nr = 4, MaxPoints = 4 },
|
||||
];
|
||||
|
||||
var columns = ExamCoverSheetService.Columns(tasks, ExamVariant.B, [4, 0, 6.5, 3]);
|
||||
|
||||
Assert.Equal(["1", "2", "3"], columns.Select(c => c.Number));
|
||||
Assert.Equal([5, 7, 4], columns.Select(c => c.MaxPoints));
|
||||
Assert.Equal([4, 6.5, 3], columns.Select(c => c.AchievedPoints!.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCorrected_AbwesendOderLeer_GiltNichtAlsKorrigiert()
|
||||
{
|
||||
Assert.False(ExamCoverSheetService.IsCorrected(null));
|
||||
Assert.False(ExamCoverSheetService.IsCorrected(new ExamResult()));
|
||||
Assert.False(ExamCoverSheetService.IsCorrected(new ExamResult { Absent = true, Points = [1] }));
|
||||
Assert.True(ExamCoverSheetService.IsCorrected(new ExamResult { Points = [1] }));
|
||||
Assert.True(ExamCoverSheetService.IsCorrected(new ExamResult { Grade = "2" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Includes_NiveauKlausur_NurPassendesNiveau()
|
||||
{
|
||||
var exam = new Exam { Date = new DateOnly(2025, 12, 18), Niveau = Niveau.G };
|
||||
|
||||
Assert.True(ExamParticipantService.Includes(exam, new GroupMembership { Niveau = Niveau.G }, false, false));
|
||||
Assert.False(ExamParticipantService.Includes(exam, new GroupMembership { Niveau = Niveau.E }, false, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Includes_Nachschreibarbeit_OhneErgebnisNurWerDieErsteArbeitVersaeumtHat()
|
||||
{
|
||||
var original = new Exam();
|
||||
var makeUp = new Exam { IsMakeUp = true, RepeatOfExamId = original.Id };
|
||||
var wrote = Guid.NewGuid();
|
||||
var missed = Guid.NewGuid();
|
||||
List<ExamResult> originalResults =
|
||||
[
|
||||
new() { ExamId = original.Id, StudentId = wrote, Grade = "3" },
|
||||
new() { ExamId = original.Id, StudentId = missed, Absent = true },
|
||||
];
|
||||
|
||||
var wroteOriginal = ExamParticipantService.WroteOriginal(makeUp, _ => originalResults);
|
||||
|
||||
Assert.Equal([wrote], wroteOriginal);
|
||||
Assert.False(ExamParticipantService.Includes(makeUp, null, false, wroteOriginal.Contains(wrote)));
|
||||
Assert.True(ExamParticipantService.Includes(makeUp, null, true, wroteOriginal.Contains(wrote)));
|
||||
Assert.True(ExamParticipantService.Includes(makeUp, null, false, wroteOriginal.Contains(missed)));
|
||||
}
|
||||
}
|
||||
@@ -4828,6 +4828,60 @@ beides vor dem ersten produktiven Zwei-Geräte-Einsatz empfehlenswert nachzuhole
|
||||
`MainWindowViewModel.CanPersonalizeWorksheet` beobachtet dafür `GroupDetailViewModel.Group`
|
||||
per PropertyChanged-Abo, weil dessen Wert erst nach dem Seitenwechsel per `LoadGroup` gesetzt
|
||||
wird (siehe Kommentar in `NavigateToGroupDetail`).
|
||||
- [x] **11.5d** Individuelle Klausur-Deckblätter nach Schulvorgabe als Sammel-PDF
|
||||
(Nutzer-Wunsch). Einstieg über "Deckblätter…" auf der Klausuren-Hauptseite und im
|
||||
Klausuren-Tab der Lerngruppe: Vorlage wählen, Schüler anhaken, ein PDF mit einer Seite je
|
||||
Schüler erzeugen.
|
||||
|
||||
**Vorlagentyp statt eigener Bibliothek:** Deckblatt-Vorlagen sind normale `.lavorlage`-Pakete
|
||||
mit dem Metadatum `report-type=exam-cover` (`TemplateReportTypes.ExamCover`).
|
||||
`InstalledTemplate` trägt dafür den `ReportType`; der Deckblatt-Dialog liest beide
|
||||
vorhandenen Bibliotheken (Elternbrief + Arbeitsblatt) und zeigt nur diesen Typ, während
|
||||
Elternbrief-, Arbeitsblatt- und MCP-Auswahl ihn ausblenden. Pakete ohne Typ verhalten sich
|
||||
wie bisher.
|
||||
|
||||
**Platzhalter:** `ExamCoverPlaceholderBuilder` ergänzt die Elternbrief-Standardwerte um
|
||||
`Exam.*`, `Result.*`, `Subject.Name`, `Group.GradeLevel` und die Punktetabelle
|
||||
`Exam.PointsTable` (Liste in [README.md](LehrerApp.Templating/README.md)). Maximalpunktzahl
|
||||
und Tabelle gelten je Schüler für die geschriebene Fassung; Bonusaufgaben haben keine Nummer,
|
||||
ein "+" vor der Punktzahl und zählen nicht zur erreichbaren Punktzahl
|
||||
(`ExamCoverSheetService.Columns`, nummeriert wird innerhalb der Fassung).
|
||||
|
||||
Nachgezogen (Nutzer-Feedback): `Exam.Course` liefert "E-Kurs"/"G-Kurs" (Niveau der Arbeit,
|
||||
sonst der Mitgliedschaft) und bleibt im Klassenverband (`GroupType.Class`) leer.
|
||||
`Teacher.Abbreviation` kommt aus der neuen `TeacherSettingsService`-Konfiguration
|
||||
(Einstellungen → Briefvorlagen → "Klausur-Deckblätter", `teachersettings.json`) statt fest
|
||||
aus der Vorlage und ist im Dialog je Druck überschreibbar, ohne die Einstellung zu ändern —
|
||||
für gemeinsam unterrichtete Kurse mit zwei Kürzeln.
|
||||
|
||||
**Punktetabelle ohne Verkleinern:** `ExamPointsTableDrawingBuilder` zeichnet feste
|
||||
Spaltenbreiten und bricht in weitere Blöcke um. Damit "Gesamtpunkte"/"Note" darunter
|
||||
mitwandern, bekam `FLOWDRAWBOX` das Opt-in-Attribut `fit=content` (belegt nur die
|
||||
Inhaltshöhe statt der vollen deklarierten Höhe; bestehende Vorlagen unverändert).
|
||||
|
||||
**Blanko oder ausgefüllt:** Schalter "Punkte und Note eintragen", vorausgewählt sobald
|
||||
mindestens ein Ergebnis korrigiert ist. Als Blanko (und für noch nicht korrigierte Schüler)
|
||||
stehen Unterstrich-Linien statt Punkten/Note auf dem Blatt. Abwesende sind standardmäßig
|
||||
abgewählt. Die Schülerauswahl folgt derselben Regel wie die Punkteeingabe (Niveau,
|
||||
Nachschreibarbeit, Mitgliedschaft am Klausurtag), dafür nach `ExamParticipantService`
|
||||
ausgelagert.
|
||||
|
||||
**Sammel-PDF:** neues `ITemplateBatchRenderer.RenderBatchToPdf` (QuestPDF `Document.Merge`)
|
||||
liefert zusätzlich die Seitenzahl je Datensatz; läuft ein Deckblatt wegen zu vieler Aufgaben
|
||||
auf eine zweite Seite, warnt der Dialog.
|
||||
|
||||
**TemplateDesigner:** "Als Klausur-Deckblatt einrichten" setzt `report-type=exam-cover` und
|
||||
legt alle Deckblatt-Platzhalter mit Beispielwerten an; vorhandene Platzhalter bleiben, ein
|
||||
abweichender Typ wird gemeldet statt überschrieben. Neuer Sonderinhalt "Punktetabelle
|
||||
(Klausur-Deckblatt)" bereitet die `FLOWDRAWBOX … fit=content` im Content-Flow vor
|
||||
(`SpecialContentItem` bekam dafür empfohlene Attribute und Ziel-Scope). Damit die Vorschau
|
||||
dieselbe Tabelle zeigt wie der spätere Druck, zog das Zeichnen nach
|
||||
`LehrerApp.Templating` um (`ExamPointsTableDrawing`, dazu der Platzhalterkatalog
|
||||
`ExamCoverPlaceholders` und `DrawingBoxLocator`, vorher `LetterPlaceholderBuilder`); die
|
||||
Beispieltabelle richtet sich nach der im Layout deklarierten Boxbreite. Ein Test in
|
||||
`ExamCoverSheetTests` hält Katalog und `ExamCoverPlaceholderBuilder` zusammen. Beim Öffnen
|
||||
einer Deckblatt-Vorlage bekommen bekannte Platzhalter sprechende Beispielwerte statt
|
||||
"Beispielwert".
|
||||
- [ ] **11.6** Vollständiger Datenexport eines Schuljahres (Archivierung).
|
||||
|
||||
---
|
||||
|
||||
Reference in new issue
Block a user