diff --git a/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs b/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs
index aa97caf..85f9c15 100644
--- a/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs
+++ b/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs
@@ -120,7 +120,7 @@ public sealed class ExamCoverSheetTests : IDisposable
}
[Fact]
- public void Generate_SchreibtEinSammelPdfMitEinerSeiteJeSchueler()
+ public async Task Generate_SchreibtEinSammelPdfMitEinerSeiteJeSchueler()
{
_results.Add(new ExamResult
{ ExamId = _exam.Id, StudentId = _anna.Id, Points = [4.5, 12, 1], TotalPoints = 17.5 });
@@ -129,7 +129,7 @@ public sealed class ExamCoverSheetTests : IDisposable
var vm = Build([store]);
var output = Path.Combine(_directory, "deckblaetter.pdf");
- Assert.True(vm.Generate(output));
+ Assert.True(await vm.GenerateAsync(output));
using var document = UglyToad.PdfPig.PdfDocument.Open(output);
Assert.Equal(2, document.NumberOfPages);
@@ -145,10 +145,12 @@ public sealed class ExamCoverSheetTests : IDisposable
// 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);
+ Assert.False(vm.IsGenerating);
+ Assert.True(vm.CanGenerate);
}
[Fact]
- public void Generate_OhnePunkteEintragen_BleibtAuchBeiKorrigiertenBlanko()
+ public async Task Generate_OhnePunkteEintragen_BleibtAuchBeiKorrigiertenBlanko()
{
_results.Add(new ExamResult
{ ExamId = _exam.Id, StudentId = _anna.Id, Points = [4.5, 12, 1], TotalPoints = 17.5 });
@@ -158,21 +160,21 @@ public sealed class ExamCoverSheetTests : IDisposable
vm.FillResults = false;
var output = Path.Combine(_directory, "blanko.pdf");
- Assert.True(vm.Generate(output));
+ Assert.True(await vm.GenerateAsync(output));
using var document = UglyToad.PdfPig.PdfDocument.Open(output);
Assert.DoesNotContain("17,5", Normalize(document.GetPage(1).Text));
}
[Fact]
- public void Generate_ZuVieleAufgabenFuerEineSeite_WarntImStatus()
+ public async Task 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.True(await vm.GenerateAsync(Path.Combine(_directory, "lang.pdf")));
Assert.Contains("passen nicht auf eine Seite", vm.StatusMessage);
}
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamCoverSheetDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamCoverSheetDialogViewModel.cs
index 02df108..70efeb2 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ExamCoverSheetDialogViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ExamCoverSheetDialogViewModel.cs
@@ -31,12 +31,20 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject
/// Vorbelegt mit dem internen Klausurtitel, hier nur für diesen Druck änderbar — der
/// Titel der Klausur bleibt unberührt. Leer gelassen gilt wieder der interne Titel.
[ObservableProperty] private string _coverTitle;
+ [ObservableProperty, NotifyPropertyChangedFor(nameof(CanGenerate))] private bool _isGenerating;
+ [ObservableProperty] private int _progressValue;
+ [ObservableProperty] private int _progressMaximum = 1;
+ /// Alle Deckblätter sind gesetzt, das Sammel-PDF entsteht — dieser Schritt meldet
+ /// keinen Zwischenstand, der Balken läuft deshalb unbestimmt.
+ [ObservableProperty] private bool _isMerging;
+ [ObservableProperty] private string _progressText = "";
public string ExamTitle => _exam.Title;
public string ExamLabel => $"{_exam.Title} · {_group.Name} · {_exam.Date:dd.MM.yyyy}";
public ObservableCollection Templates { get; } = [];
public ObservableCollection Students { get; } = [];
public bool HasNoTemplates => Templates.Count == 0;
+ public bool CanGenerate => !HasNoTemplates && !IsGenerating;
public string SuggestedFileName => SanitizeFileName($"Deckblätter_{_group.Name}_{_exam.Title}.pdf");
public ExamCoverSheetDialogViewModel(Exam exam, LearningGroup group, string subjectName,
@@ -68,11 +76,12 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject
FillResults = Students.Any(s => s.IsCorrected);
}
- /// Schreibt das Sammel-PDF und meldet den Erfolg.
- public bool Generate(string outputPath)
+ /// Schreibt das Sammel-PDF und meldet den Erfolg. Gesetzt wird im Hintergrund, damit
+ /// der Dialog bei einem Klassensatz bedienbar bleibt und den Fortschritt zeigen kann.
+ public async Task GenerateAsync(string outputPath)
{
StatusMessage = "";
- if (SelectedTemplate is null) return false;
+ if (SelectedTemplate is null || IsGenerating) return false;
var included = Students.Where(s => s.IsIncluded).ToList();
if (included.Count == 0) { StatusMessage = "Kein Schüler ausgewählt."; return false; }
try
@@ -82,8 +91,15 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject
var data = included.Select(s => (ITemplateDataProvider)new LetterDataProvider(
ExamCoverPlaceholderBuilder.Build(_exam, _group, _subjectName, s.Model, s.Result, FillResults,
_grading, loaded, today, s.Niveau, TeacherAbbreviation, CoverTitle))).ToList();
- var result = _renderer.RenderBatchToPdf(loaded, data);
- File.WriteAllBytes(outputPath, result.Pdf);
+ ReportProgress(0, included.Count);
+ IsGenerating = true;
+ var progress = new Progress(done => ReportProgress(done, included.Count));
+ var result = await Task.Run(() =>
+ {
+ var rendered = _renderer.RenderBatchToPdf(loaded, data, progress);
+ File.WriteAllBytes(outputPath, rendered.Pdf);
+ return rendered;
+ });
var message = $"{included.Count} Deckblätter erzeugt.";
var blank = FillResults ? included.Count(s => !s.IsCorrected) : 0;
@@ -96,6 +112,17 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
{ StatusMessage = $"Deckblätter konnten nicht erzeugt werden: {ex.Message}"; return false; }
+ finally { IsGenerating = false; }
+ }
+
+ private void ReportProgress(int done, int total)
+ {
+ ProgressMaximum = total;
+ ProgressValue = done;
+ IsMerging = done >= total && total > 1;
+ ProgressText = IsMerging
+ ? "Sammel-PDF wird zusammengefügt …"
+ : $"{done} von {total} Deckblättern gesetzt …";
}
private static string SanitizeFileName(string value)
diff --git a/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml b/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml
index 1dd47de..d731c9e 100644
--- a/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml
+++ b/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml
@@ -49,13 +49,21 @@
-
+
+
+
+
+
+
+
-
+
+ IsEnabled="{Binding CanGenerate}"/>
diff --git a/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml.cs b/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml.cs
index bd74b6a..58491d8 100644
--- a/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml.cs
+++ b/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml.cs
@@ -7,7 +7,13 @@ namespace LehrerApp.Desktop.Views.Groups;
public partial class ExamCoverSheetDialog : Window
{
- public ExamCoverSheetDialog() => InitializeComponent();
+ public ExamCoverSheetDialog()
+ {
+ InitializeComponent();
+ // Solange das PDF entsteht, bleibt der Dialog offen — sonst liefe die Erzeugung ohne
+ // sichtbare Rückmeldung weiter.
+ Closing += (_, e) => e.Cancel = DataContext is ExamCoverSheetDialogViewModel { IsGenerating: true };
+ }
private async void OnGenerate(object? sender, RoutedEventArgs e)
{
@@ -21,7 +27,7 @@ public partial class ExamCoverSheetDialog : Window
});
if (file is null) return;
var path = file.Path.LocalPath;
- if (vm.Generate(path))
+ if (await vm.GenerateAsync(path))
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
}
diff --git a/LehrerApp.Templating.Tests/ExamCoverTemplatingTests.cs b/LehrerApp.Templating.Tests/ExamCoverTemplatingTests.cs
index 2be3dfc..95063d5 100644
--- a/LehrerApp.Templating.Tests/ExamCoverTemplatingTests.cs
+++ b/LehrerApp.Templating.Tests/ExamCoverTemplatingTests.cs
@@ -61,14 +61,17 @@ public sealed class ExamCoverTemplatingTests : IDisposable
var tall = new DrawingValue(
[new DrawStringEx(0, 290, 12, 170, "LANG", DrawingTextAlignment.AlignLeft, 10)], 300);
+ var progress = new RecordingProgress();
+
var result = new QuestTemplateRenderer().RenderBatchToPdf(template,
[
new DictionaryProvider(new Dictionary { ["Tabelle"] = ShortDrawing }),
new DictionaryProvider(new Dictionary { ["Tabelle"] = tall }),
- ]);
+ ], progress);
using var document = UglyToad.PdfPig.PdfDocument.Open(result.Pdf);
Assert.Equal([1, 2], result.PageCounts);
+ Assert.Equal([1, 2], progress.Reported);
Assert.Equal(3, document.NumberOfPages);
Assert.Contains("TABELLE", document.GetPage(1).Text);
}
diff --git a/LehrerApp.Templating.Tests/TemplatingTests.cs b/LehrerApp.Templating.Tests/TemplatingTests.cs
index ef80079..a8ae190 100644
--- a/LehrerApp.Templating.Tests/TemplatingTests.cs
+++ b/LehrerApp.Templating.Tests/TemplatingTests.cs
@@ -394,3 +394,11 @@ internal sealed class DictionaryProvider(IReadOnlyDictionary GetValues() => values;
}
+
+/// Zeichnet Meldungen synchron auf — würde sie ohne
+/// SynchronizationContext erst später auf dem Threadpool zustellen.
+internal sealed class RecordingProgress : IProgress
+{
+ public List Reported { get; } = [];
+ public void Report(int value) => Reported.Add(value);
+}
diff --git a/LehrerApp.Templating/Model.cs b/LehrerApp.Templating/Model.cs
index 0b3a43a..efddfb3 100644
--- a/LehrerApp.Templating/Model.cs
+++ b/LehrerApp.Templating/Model.cs
@@ -192,7 +192,10 @@ public sealed record BatchRenderResult(byte[] Pdf, IReadOnlyList PageCounts
public interface ITemplateBatchRenderer
{
/// Rendert dieselbe Vorlage je Datensatz und fügt alles zu einem PDF zusammen.
- BatchRenderResult RenderBatchToPdf(LoadedTemplate template, IReadOnlyList data);
+ /// Bekommt nach jedem gesetzten Datensatz deren bisherige Anzahl. Nach
+ /// dem letzten folgt noch das Zusammenfügen, das sich nicht weiter unterteilen lässt.
+ BatchRenderResult RenderBatchToPdf(LoadedTemplate template, IReadOnlyList data,
+ IProgress? progress = null);
}
public interface ITemplatePreviewRenderer
diff --git a/LehrerApp.Templating/QuestTemplateRenderer.cs b/LehrerApp.Templating/QuestTemplateRenderer.cs
index 79dad51..cfcc1f3 100644
--- a/LehrerApp.Templating/QuestTemplateRenderer.cs
+++ b/LehrerApp.Templating/QuestTemplateRenderer.cs
@@ -14,17 +14,23 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
public byte[] RenderToPdf(LoadedTemplate template, ITemplateDataProvider data) =>
BuildDocument(template, ValidateData(template, data)).GeneratePdf();
- public BatchRenderResult RenderBatchToPdf(LoadedTemplate template, IReadOnlyList data)
+ public BatchRenderResult RenderBatchToPdf(LoadedTemplate template, IReadOnlyList data,
+ IProgress? progress = null)
{
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 single = new List(documents.Count);
+ foreach (var document in documents)
+ {
+ single.Add(document.GeneratePdf());
+ progress?.Report(single.Count);
+ }
var pdf = documents.Count == 1
- ? documents[0].GeneratePdf()
+ ? single[0]
: Document.Merge(documents).UseOriginalPageNumbers().GeneratePdf();
- return new(pdf, pageCounts);
+ return new(pdf, single.Select(PdfPageCounter.Count).ToList());
}
public byte[] RenderFirstPageToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
diff --git a/TODO.md b/TODO.md
index 6cab246..34b92b6 100644
--- a/TODO.md
+++ b/TODO.md
@@ -4876,6 +4876,13 @@ beides vor dem ersten produktiven Zwei-Geräte-Einsatz empfehlenswert nachzuhole
liefert zusätzlich die Seitenzahl je Datensatz; läuft ein Deckblatt wegen zu vieler Aufgaben
auf eine zweite Seite, warnt der Dialog.
+ Nachgezogen (Nutzer-Wunsch): Fortschrittsbalken im Dialog, weil ein Klassensatz spürbar
+ dauert. `GenerateAsync` setzt im Hintergrund (`Task.Run`), `RenderBatchToPdf` meldet über
+ ein optionales `IProgress` jeden gesetzten Datensatz; das abschließende
+ `Document.Merge` meldet nichts, dort läuft der Balken unbestimmt ("Sammel-PDF wird
+ zusammengefügt …"). Währenddessen sind beide Schaltflächen gesperrt und der Dialog lässt
+ sich nicht schließen. Ein einzelnes Deckblatt wird dabei nicht mehr doppelt gesetzt.
+
**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