7 Commits
Author SHA1 Message Date
adminandClaude Sonnet 5 77fe9b1c79 fix: Dokumentationsdialog wächst bei Elternbrief über den Bildschirm
CI / build-and-test (push) Waiting to run
SizeToContent="Height" ohne Begrenzung ließ das Fenster beim Typ
"Elternbrief" (drei zusätzliche Felder) so hoch werden, dass der
Speichern-Button auf kleineren Bildschirmen (z.B. MacBook) nicht mehr
erreichbar war. ScrollViewer bekommt eine MaxHeight, ab der intern
gescrollt wird - analog zum bestehenden Muster in McpConfirmDialog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:12:53 +02:00
adminandClaude Sonnet 5 92e497b54f fix: Fehlzeitenkalender Spaltenbreiten und unnötigen Seitenumbruch
CI / build-and-test (push) Waiting to run
Datum-Spalte in der Fehltage-Tabelle war für "dd.MM.yyyy" zu schmal
bemessen (an der kurzen Kopfzeile "Datum" orientiert) und wurde
abgeschnitten. RenderFlow erzwang außerdem zwischen den Ausschnitten
einer zu hohen Zeichenbox einen harten Seitenumbruch unabhängig vom
tatsächlich verbleibenden Platz auf der Seite - dadurch begann z.B.
der zweite Monat auf Seite 3, obwohl Seite 2 noch reichlich Platz
hatte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:42:29 +02:00
admin 861f2c76ca fix: Umbruchlogik
CI / build-and-test (push) Canceled after 0s
2026-09-16 02:21:21 +02:00
admin ef3e9dbbb6 fix: Briefe Kontaktauswahl
CI / build-and-test (push) Canceled after 0s
2026-09-16 01:49:46 +02:00
admin 03a5e0dd9c fix: Fehlzeitenkalender in Templates, Datenbezug und Layout
CI / build-and-test (push) Canceled after 0s
2026-09-16 00:58:09 +02:00
admin 6dccf96039 fix: Fehlzeitenkalender in Templates
CI / build-and-test (push) Canceled after 0s
2026-09-16 00:50:10 +02:00
admin 6e57bf407d feat: fehlende Variablen ergänzen
CI / build-and-test (push) Canceled after 0s
2026-09-15 23:30:22 +02:00
19 changed files with 614 additions and 77 deletions
@@ -56,7 +56,7 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
}
[Fact]
public void AdvancedContent_Anwesenheitskalender_WirdAlsDrawingGerendert()
public async Task AdvancedContent_Anwesenheitskalender_WirdAlsDrawingGerendert()
{
var store = StoreWithTemplate(new PlaceholderDefinition(
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
@@ -66,7 +66,7 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
var output = Path.Combine(_directory, "Kalender.pdf");
Assert.True(vm.UsesAttendanceCalendar);
vm.SetAttendanceCalendarOptions(new AttendanceCalendarOptions(
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
new DateOnly(2026, 8, 19), 3, AttendanceCalendarSize.Large));
Assert.True(vm.AttendanceCalendarConfigured);
Assert.Contains("August 2026", vm.AttendanceCalendarSummary);
@@ -76,6 +76,45 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
}
[Fact]
public async Task AttendanceKalenderKonfigurieren_LoestGezieltenDatenAbrufFuerDenZeitraumAus()
{
var store = StoreWithTemplate(new PlaceholderDefinition(
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
AttendanceCalendarOptions? requested = null;
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
_ => new DrawingValue([], 0), attendanceDataRefresher: (options, _) =>
{ requested = options; return Task.CompletedTask; });
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
new DateOnly(2026, 9, 1), 2, AttendanceCalendarSize.Medium));
Assert.NotNull(requested);
Assert.Equal(new DateOnly(2026, 9, 1), requested!.NormalizedStartMonth);
Assert.Equal(2, requested.NormalizedMonthCount);
Assert.False(vm.IsRefreshingAttendanceData);
Assert.Equal("", vm.AttendanceRefreshError);
}
[Fact]
public async Task AttendanceKalenderKonfigurieren_ZeigtFehlerBeiFehlgeschlagenemAbrufAnStattZuBlockieren()
{
var store = StoreWithTemplate(new PlaceholderDefinition(
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
_ => new DrawingValue([], 0),
attendanceDataRefresher: (_, _) => throw new WebUntisIntegrationException("Keine Verbindung."));
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
new DateOnly(2026, 9, 1), 1, AttendanceCalendarSize.Medium));
Assert.True(vm.AttendanceCalendarConfigured);
Assert.Contains("Keine Verbindung.", vm.AttendanceRefreshError);
Assert.False(vm.IsRefreshingAttendanceData);
}
[Fact]
public void AdvancedContent_Fehlzeitenliste_AktiviertKonfigurationsschritt()
{
@@ -93,6 +132,108 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
Assert.True(vm.CanGenerate);
}
[Fact]
public void EigenerPlatzhalter_KannImDialogEingegebenWerden()
{
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
new PlaceholderDefinition("Betreff", PlaceholderType.Text, true));
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
vm.LetterText = "Dies ist der Inhalt.";
var betreff = Assert.Single(vm.CustomPlaceholders);
Assert.Equal("Betreff", betreff.Name);
Assert.False(vm.CanGenerate);
Assert.Contains(vm.Issues, i => i.Message.Contains("Betreff", StringComparison.OrdinalIgnoreCase));
betreff.TextValue = "Wichtiger Termin";
var output = Path.Combine(_directory, "MitBetreff.pdf");
Assert.True(vm.CanGenerate);
Assert.True(vm.Generate(output));
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
}
[Fact]
public void StudentName_WirdAutomatischMitVollemNamenBefuellt()
{
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
new PlaceholderDefinition("Student.Name", PlaceholderType.Text, true));
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
vm.LetterText = "Dies ist der Inhalt.";
Assert.Empty(vm.CustomPlaceholders);
Assert.True(vm.CanGenerate);
var output = Path.Combine(_directory, "StudentName.pdf");
Assert.True(vm.Generate(output));
}
[Fact]
public void Anrede_WirdAusKontaktVorbelegtUndBleibtEditierbar()
{
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
var vm = Build(StudentWithContact(null), store);
vm.LetterText = "Dies ist der Inhalt.";
Assert.Equal("", vm.Anrede);
Assert.False(vm.CanGenerate);
Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase));
vm.Anrede = "Sehr geehrte Familie Beispiel,";
var output = Path.Combine(_directory, "AnredeManuell.pdf");
Assert.True(vm.CanGenerate);
Assert.True(vm.Generate(output));
}
[Fact]
public void AddressPreview_FolgtDemGewaehltenKontaktUndAktualisiertSichBeimWechsel()
{
var student = new Student
{
FirstName = "Lena", LastName = "Beispiel",
Contacts =
[
new Contact { Name = "Frau Beispiel", Relation = "Mutter", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" },
new Contact { Name = "Herr Beispiel", Relation = "Vater", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" },
],
};
var vm = Build(student, StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
Assert.Equal(2, vm.Contacts.Count);
Assert.Contains("Frau Beispiel", vm.AddressPreview);
Assert.Contains("Erste Str. 1", vm.AddressPreview);
vm.SelectedContact = vm.Contacts.Single(c => c.Model.Name == "Herr Beispiel");
Assert.Contains("Herr Beispiel", vm.AddressPreview);
Assert.Contains("Zweite Str. 2", vm.AddressPreview);
Assert.DoesNotContain("Frau Beispiel", vm.AddressPreview);
}
[Fact]
public void SelectContactDialog_StartetBeimAktuellenKontaktUndAktualisiertVorschau()
{
var contacts = new List<LetterContactChoice>
{
new(new Contact { Name = "Frau Beispiel", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" }),
new(new Contact { Name = "Herr Beispiel", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" }),
};
var dialogVm = new SelectContactDialogViewModel(contacts, contacts[1]);
Assert.Same(contacts[1], dialogVm.SelectedContact);
Assert.Contains("Zweite Str. 2", dialogVm.AddressPreview);
dialogVm.SelectedContact = contacts[0];
Assert.Contains("Erste Str. 1", dialogVm.AddressPreview);
}
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
@@ -18,4 +18,18 @@ public sealed class UntisNameMatchingTests
[InlineData("Ben Schmidt", "")]
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) =>
Assert.False(UntisNameMatching.NamesMatch(a, b));
// Regression: Student.FullName liefert "Nachname, Vorname" (mit Komma) für Anzeigezwecke.
// Wird dieser String direkt an NamesMatch übergeben, bleibt das Komma am Wort kleben
// ("gerste," != "gerste") und der Abgleich gegen WebUntis-Namen (immer ohne Komma) schlägt
// fehl - genau das ließ den Anwesenheitskalender/die Fehlzeitenliste im Elternbrief leer
// bleiben, obwohl echte Fehlzeiten vorlagen. Aufrufer müssen deshalb "Vorname Nachname" ohne
// Komma bilden (siehe StudentAttendanceCalendarDrawingBuilder.StudentAttendanceCalendarService
// und ClassTeacherOverviewViewModel.cs:1173), statt Student.FullName direkt zu verwenden.
[Fact]
public void NamesMatch_KommaGetrennterAnzeigename_PasstNichtOhneUmformung()
{
Assert.False(UntisNameMatching.NamesMatch("Gerste, Amelia", "Gerste Amelia"));
Assert.True(UntisNameMatching.NamesMatch("Amelia Gerste", "Gerste Amelia"));
}
}
@@ -0,0 +1,40 @@
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class _TempFlowCheck2
{
[Fact]
public void MarkerTest()
{
var outDir = @"C:\Users\SHedt\AppData\Local\Temp\claude\d--source-LehrerApp\fb3681df-caaf-4f2d-9996-e343e44f5554\scratchpad";
Directory.CreateDirectory(outDir);
var commands = new List<DrawingCommand>
{
new DrawStringEx(0, 0, 12, 170, "TOP-0", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
new DrawStringEx(0, 90, 12, 170, "NEARBOTTOM-90", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
new DrawStringEx(0, 105, 12, 170, "AFTERBOUNDARY-105", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
new DrawStringEx(0, 190, 12, 170, "BOTTOM-190", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
};
var drawing = new DrawingValue(commands, 200);
var manifest = new TemplateManifest
{
Id = "marker", Name = "Marker",
Placeholders = [new("Marker", PlaceholderType.Drawing, true)],
};
var layout = "PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 100 $Marker\n";
var loadedLayout = new LayoutParser().Parse(layout);
var loaded = new LoadedTemplate(manifest, loadedLayout, new Dictionary<string, byte[]>());
var provider = new FakeProvider(new Dictionary<string, PlaceholderValue> { ["Marker"] = drawing });
var pngs = new QuestTemplateRenderer().RenderPagesToPng(loaded, provider, dpi: 150);
for (var i = 0; i < pngs.Count; i++)
File.WriteAllBytes(Path.Combine(outDir, $"marker2-{i}.png"), pngs[i]);
}
private sealed class FakeProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }
}
+18 -1
View File
@@ -22,10 +22,27 @@ public static class LetterDialogs
App.Services.GetRequiredService<IGroupRepository>(),
options => App.Services.GetRequiredService<StudentAttendanceCalendarService>().Build(student, options),
options => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
.BuildAbsenceDayList(student, options));
.BuildAbsenceDayList(student, options),
RefreshAttendanceDataAsync);
var dialog = new CreateLetterDialog { DataContext = vm };
var path = await dialog.ShowDialog<string?>(owner);
if (!string.IsNullOrEmpty(path) && File.Exists(path))
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
}
/// <summary>Holt genau den im Anwesenheitskalender-Dialog gewählten Zeitraum gezielt per
/// WebUntis nach (ausgelöst durch den expliziten "Konfigurieren…"-Klick, kein Hintergrundabruf
/// beim bloßen Öffnen des Briefdialogs). Damit sieht <see cref="StudentAttendanceCalendarService"/>
/// anschließend frische Daten im lokalen Cache, statt stillschweigend "keine Fehltage" zu
/// melden, nur weil die Klassenlehrer-Übersicht für diesen Zeitraum noch nie geöffnet wurde.</summary>
private static async Task RefreshAttendanceDataAsync(AttendanceCalendarOptions options, CancellationToken token)
{
var className = App.Services.GetRequiredService<WebUntisSettingsService>().HomeroomClassName;
if (string.IsNullOrWhiteSpace(className)) return;
var cache = App.Services.GetRequiredService<UntisReportCacheService>();
var start = options.NormalizedStartMonth;
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
await cache.GetAbsencesAsync(className, start, end, token: token);
await cache.GetClassRegisterEventsAsync(className, start, end, token: token);
}
}
@@ -18,14 +18,14 @@ public static class LetterPlaceholderBuilder
string letterText, string teacherName, DrawingValue? attendanceCalendar = null,
DrawingValue? absenceDays = null)
{
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
var address = FormatAddress(contact);
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
{
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
["Brieftext"] = new MultilineValue(letterText), ["LehrerName"] = new TextValue(teacherName),
["Student.FirstName"] = new TextValue(student.FirstName), ["Student.LastName"] = new TextValue(student.LastName),
["Student.Name"] = new TextValue(student.FullName),
["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
@@ -35,6 +35,14 @@ public static class LetterPlaceholderBuilder
};
}
/// <summary>Mehrzeilige Anschrift (Name/Straße/PLZ Ort) für Adressvorschau im Dialog und
/// den <c>Contact.Address</c>-Platzhalter - eine Formatierung für beide Verwendungen.</summary>
public static string FormatAddress(Contact? contact)
{
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
return string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
}
public static bool IsEmpty(PlaceholderValue value) => value switch
{
TextValue x => string.IsNullOrWhiteSpace(x.Value),
@@ -37,21 +37,29 @@ public static class StudentAttendanceCalendarDrawingBuilder
AttendanceCalendarSize.Large => 1f,
_ => .85f,
};
var contentWidth = width * scale;
var cellWidth = contentWidth / 7;
// Die Breite richtet sich nach der DRAWBOX-Deklaration im Layout-Skript (fest, unabhängig
// von der Größenwahl) - nur Schrift/Zellenhöhe skalieren mit "Größe". Würde die Breite mit
// skalieren, würde "Groß" (scale=1) exakt die Skript-Box ausfüllen, "Klein"/"Normal" aber
// nur einen Teil davon - und eine größere Skalierung als 1 liefe über die Box hinaus und
// würde am rechten Rand abgeschnitten (SVG overflow="hidden").
var contentWidth = width;
var cellHeight = 10 * scale;
var monthGap = 6 * scale;
var monthGapX = 10f;
var first = options.NormalizedStartMonth;
var monthCount = options.NormalizedMonthCount;
var commands = new List<DrawingCommand>
{
new DrawStringEx(0, 0, 7 * scale, contentWidth, "Anwesenheit",
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true),
new DrawStringEx(0, 7 * scale, 6 * scale, contentWidth, studentName,
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"),
};
var monthWidth = (contentWidth - monthGapX * (monthCount - 1)) / monthCount;
var cellWidth = monthWidth / 7;
var commands = new List<DrawingCommand>();
var y = 0f;
commands.Add(new DrawStringEx(0, y, 14 * scale, contentWidth, "Anwesenheit",
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true));
y += 14 * scale;
commands.Add(new DrawStringEx(0, y, 10 * scale, contentWidth, studentName,
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"));
y += 10 * scale + 2 * scale;
var weekdays = new[] { "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So" };
var y = 15 * scale;
var gridStartY = y;
var maxWeeks = 0;
for (var monthIndex = 0; monthIndex < monthCount; monthIndex++)
{
var current = first.AddMonths(monthIndex);
@@ -59,30 +67,33 @@ public static class StudentAttendanceCalendarDrawingBuilder
DateOnly.FromDateTime(DateTime.Today), studentName);
var offset = ((int)current.DayOfWeek + 6) % 7;
var weeks = (int)Math.Ceiling((offset + days.Count) / 7d);
commands.Add(new DrawStringEx(0, y, 7 * scale, contentWidth,
maxWeeks = Math.Max(maxWeeks, weeks);
var xOffset = monthIndex * (monthWidth + monthGapX);
var monthY = gridStartY;
commands.Add(new DrawStringEx(xOffset, monthY, 11 * scale, monthWidth,
current.ToString("MMMM yyyy", CultureInfo.GetCultureInfo("de-DE")),
DrawingTextAlignment.AlignLeft, 9 * scale, Color: "#374151", Bold: true));
y += 7 * scale;
monthY += 11 * scale;
for (var column = 0; column < 7; column++)
commands.Add(new DrawStringEx(column * cellWidth, y, 6 * scale, cellWidth, weekdays[column],
DrawingTextAlignment.AlignCenter, 7 * scale, Color: "#6B7280", Bold: true));
y += 7 * scale;
commands.Add(new DrawStringEx(xOffset + column * cellWidth, monthY, 9 * scale, cellWidth,
weekdays[column], DrawingTextAlignment.AlignCenter, 7 * scale, Color: "#6B7280", Bold: true));
monthY += 9 * scale;
foreach (var day in days)
{
var index = offset + day.Date.Day - 1;
var column = index % 7;
var row = index / 7;
var x = column * cellWidth;
var cellY = y + row * cellHeight;
var x = xOffset + column * cellWidth;
var cellY = monthY + row * cellHeight;
commands.Add(new DrawRectangle(x + scale, cellY, cellWidth - 2 * scale, cellHeight - scale,
"#D1D5DB", .35f, day.HasSignal ? day.SignalColorHex : "#FFFFFF"));
commands.Add(new DrawStringEx(x, cellY + scale, cellHeight - 2 * scale, cellWidth,
day.HasSignal ? day.SignalCode : day.DayNumber, DrawingTextAlignment.AlignCenter, 7 * scale,
Color: day.HasSignal ? "#FFFFFF" : "#374151", Bold: day.HasSignal));
}
y += weeks * cellHeight + monthGap;
}
y = gridStartY + 11 * scale + 9 * scale + maxWeeks * cellHeight + monthGapX;
commands.Add(new DrawStringEx(0, y, 8 * scale, contentWidth,
"U unentschuldigt · A abwesend · V verspätet · E entschuldigt · ! Klassenbuch",
@@ -106,8 +117,10 @@ public static class StudentAbsenceDayListDrawingBuilder
AttendanceCalendarSize.Large => 1f,
_ => .88f,
};
var contentWidth = width * scale;
var rowHeight = 9 * scale;
// Breite bleibt an die DRAWBOX-Deklaration im Layout-Skript gebunden (siehe
// StudentAttendanceCalendarDrawingBuilder) - nur Zeilenhöhe/Schrift skalieren mit "Größe".
var contentWidth = width;
var rowHeight = 12 * scale;
var start = options.NormalizedStartMonth;
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
var rows = absences
@@ -115,26 +128,32 @@ public static class StudentAbsenceDayListDrawingBuilder
UntisNameMatching.NamesMatch(a.StudentName, studentName))
.OrderBy(a => a.Date)
.ToList();
var commands = new List<DrawingCommand>
{
new DrawStringEx(0, 0, 7 * scale, contentWidth, "Fehltage",
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true),
new DrawStringEx(0, 7 * scale, 6 * scale, contentWidth, studentName,
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"),
};
var y = 16 * scale;
var commands = new List<DrawingCommand>();
var y = 0f;
commands.Add(new DrawStringEx(0, y, 14 * scale, contentWidth, "Fehltage",
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true));
y += 14 * scale;
commands.Add(new DrawStringEx(0, y, 10 * scale, contentWidth, studentName,
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"));
y += 10 * scale + 2 * scale;
// Spaltenbreiten so gewählt, dass auch die Datenzeilen (nicht nur die kurzen Kopfzeilen-
// Labels) hineinpassen - "Datum" als Kopfzeile ist kürzer als "dd.MM.yyyy" und wurde bei
// 31 zu schmal bemessen, wodurch das Datum am rechten Rand abgeschnitten wurde.
const float dateColumnX = 2, dateColumnWidth = 38;
const float extentColumnX = 44, extentColumnWidth = 66;
const float statusColumnX = 114, statusColumnWidth = 54;
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#CBD5E1", .4f, "#F3F4F6"));
commands.Add(new DrawStringEx(2 * scale, y + scale, rowHeight - 2 * scale, 31 * scale, "Datum",
commands.Add(new DrawStringEx(dateColumnX, y + scale, rowHeight - scale, dateColumnWidth, "Datum",
DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
commands.Add(new DrawStringEx(36 * scale, y + scale, rowHeight - 2 * scale, 75 * scale, "Umfang",
commands.Add(new DrawStringEx(extentColumnX, y + scale, rowHeight - scale, extentColumnWidth, "Umfang",
DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
commands.Add(new DrawStringEx(113 * scale, y + scale, rowHeight - 2 * scale, 55 * scale, "Status",
commands.Add(new DrawStringEx(statusColumnX, y + scale, rowHeight - scale, statusColumnWidth, "Status",
DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
y += rowHeight;
if (rows.Count == 0)
{
commands.Add(new DrawStringEx(2 * scale, y + 2 * scale, rowHeight, contentWidth - 4 * scale,
commands.Add(new DrawStringEx(2, y + 2 * scale, rowHeight, contentWidth - 4,
"Keine Fehltage im gewählten Zeitraum", DrawingTextAlignment.AlignLeft, 8 * scale,
Color: "#6B7280", Italic: true));
y += rowHeight + 3 * scale;
@@ -157,12 +176,12 @@ public static class StudentAbsenceDayListDrawingBuilder
: row.FriendlyStatusLabel;
var statusColor = row.IsUnexcused ? "#C62828" : "#2E7D32";
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#E5E7EB", .3f, fill));
commands.Add(new DrawStringEx(2 * scale, y + scale, rowHeight - 2 * scale, 31 * scale,
commands.Add(new DrawStringEx(dateColumnX, y + scale, rowHeight - scale, dateColumnWidth,
row.Date.ToString("dd.MM.yyyy"), DrawingTextAlignment.AlignLeft, 7.5f * scale,
Color: "#374151"));
commands.Add(new DrawStringEx(36 * scale, y + scale, rowHeight - 2 * scale, 75 * scale,
commands.Add(new DrawStringEx(extentColumnX, y + scale, rowHeight - scale, extentColumnWidth,
extent, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151"));
commands.Add(new DrawStringEx(113 * scale, y + scale, rowHeight - 2 * scale, 55 * scale,
commands.Add(new DrawStringEx(statusColumnX, y + scale, rowHeight - scale, statusColumnWidth,
status, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: statusColor,
Bold: row.IsUnexcused));
y += rowHeight;
@@ -190,9 +209,10 @@ public sealed class StudentAttendanceCalendarService(
var first = options.NormalizedStartMonth;
var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1);
var matchName = $"{student.FirstName} {student.LastName}";
var rosterName = rosterCache.GetByClass(className)
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, student.FullName))?.DisplayName
?? student.FullName;
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, matchName))?.DisplayName
?? matchName;
var start = first.Year * 10000 + first.Month * 100 + first.Day;
var end = last.Year * 10000 + last.Month * 100 + last.Day;
var absences = absenceCache.GetByClassAndRange(className, start, end)
@@ -215,9 +235,10 @@ public sealed class StudentAttendanceCalendarService(
var first = options.NormalizedStartMonth;
var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1);
var matchName = $"{student.FirstName} {student.LastName}";
var rosterName = rosterCache.GetByClass(className)
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, student.FullName))?.DisplayName
?? student.FullName;
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, matchName))?.DisplayName
?? matchName;
var start = first.Year * 10000 + first.Month * 100 + first.Day;
var end = last.Year * 10000 + last.Month * 100 + last.Day;
var absences = absenceCache.GetByClassAndRange(className, start, end)
@@ -14,6 +14,7 @@ public partial class CreateLetterDialogViewModel : ObservableObject
private readonly ITemplateRenderer _renderer;
private readonly Func<AttendanceCalendarOptions, DrawingValue>? _attendanceCalendarFactory;
private readonly Func<AttendanceCalendarOptions, DrawingValue>? _absenceDayListFactory;
private readonly Func<AttendanceCalendarOptions, CancellationToken, Task>? _attendanceDataRefresher;
private AttendanceCalendarOptions _attendanceCalendarOptions = new(
new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1), 1);
@@ -21,6 +22,7 @@ public partial class CreateLetterDialogViewModel : ObservableObject
[ObservableProperty] private LetterContactChoice? _selectedContact;
[ObservableProperty] private LetterGroupChoice? _selectedGroup;
[ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now;
[ObservableProperty] private string _anrede = "";
[ObservableProperty] private string _letterText = "";
[ObservableProperty] private string _teacherName = "";
[ObservableProperty] private string _generationError = "";
@@ -29,15 +31,21 @@ public partial class CreateLetterDialogViewModel : ObservableObject
[ObservableProperty] private bool _usesAbsenceDayList;
[ObservableProperty] private bool _attendanceCalendarConfigured;
[ObservableProperty] private string _attendanceCalendarSummary = "1 Monat · Standardgröße";
[ObservableProperty] private bool _isRefreshingAttendanceData;
[ObservableProperty] private string _attendanceRefreshError = "";
public string StudentName => _student.FullName;
public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
public ObservableCollection<LetterContactChoice> Contacts { get; } = [];
public ObservableCollection<LetterGroupChoice> Groups { get; } = [];
public ObservableCollection<LetterGenerationIssue> Issues { get; } = [];
public ObservableCollection<LetterPlaceholderInput> CustomPlaceholders { get; } = [];
public bool HasIssues => Issues.Count > 0;
public bool HasCustomPlaceholders => CustomPlaceholders.Count > 0;
public bool HasNoTemplates => Templates.Count == 0;
public bool HasNoContacts => Contacts.Count == 0;
public string AddressPreview => LetterPlaceholderBuilder.FormatAddress(SelectedContact?.Model);
public bool HasAddressPreview => !string.IsNullOrWhiteSpace(AddressPreview);
public bool UsesAttendanceAdvancedContent => UsesAttendanceCalendar || UsesAbsenceDayList;
public string SuggestedFileName => SanitizeFileName(
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf");
@@ -45,11 +53,13 @@ public partial class CreateLetterDialogViewModel : ObservableObject
public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer,
IGroupMembershipRepository memberships, IGroupRepository groups,
Func<AttendanceCalendarOptions, DrawingValue>? attendanceCalendarFactory = null,
Func<AttendanceCalendarOptions, DrawingValue>? absenceDayListFactory = null)
Func<AttendanceCalendarOptions, DrawingValue>? absenceDayListFactory = null,
Func<AttendanceCalendarOptions, CancellationToken, Task>? attendanceDataRefresher = null)
{
_student = student; _templates = templates; _renderer = renderer;
_attendanceCalendarFactory = attendanceCalendarFactory;
_absenceDayListFactory = absenceDayListFactory;
_attendanceDataRefresher = attendanceDataRefresher;
foreach (var template in templates.GetTemplates()) Templates.Add(new(template));
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact));
foreach (var membership in memberships.GetByStudent(student.Id))
@@ -68,9 +78,17 @@ public partial class CreateLetterDialogViewModel : ObservableObject
OnPropertyChanged(nameof(UsesAttendanceAdvancedContent));
AttendanceCalendarConfigured = false;
ResetAttendanceCalendarOptions();
RebuildCustomPlaceholders(value);
RefreshValidation();
}
partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation();
partial void OnSelectedContactChanged(LetterContactChoice? value)
{
Anrede = value?.Model.LetterSalutation ?? "";
OnPropertyChanged(nameof(AddressPreview));
OnPropertyChanged(nameof(HasAddressPreview));
RefreshValidation();
}
partial void OnAnredeChanged(string value) => RefreshValidation();
partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation();
partial void OnLetterDateChanged(DateTimeOffset? value)
{
@@ -117,20 +135,68 @@ public partial class CreateLetterDialogViewModel : ObservableObject
OnPropertyChanged(nameof(HasIssues));
}
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues() => LetterPlaceholderBuilder.BuildStandardValues(
_student, SelectedContact?.Model, SelectedGroup?.Model,
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName,
UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions) : null,
UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions) : null);
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues()
{
var values = LetterPlaceholderBuilder.BuildStandardValues(
_student, SelectedContact?.Model, SelectedGroup?.Model,
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName,
UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions) : null,
UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions) : null);
values["Anrede"] = new TextValue(Anrede); values["Letter.Salutation"] = new TextValue(Anrede);
foreach (var custom in CustomPlaceholders) values[custom.Name] = custom.ToPlaceholderValue();
return values;
}
private void RebuildCustomPlaceholders(LetterTemplateChoice? choice)
{
foreach (var existing in CustomPlaceholders) existing.PropertyChanged -= OnCustomPlaceholderChanged;
CustomPlaceholders.Clear();
if (choice is not null)
{
try
{
var loaded = _templates.Load(choice.Model);
foreach (var placeholder in loaded.Manifest.Placeholders.Where(p => !p.IsConstant
&& !StandardPlaceholderNames.Contains(p.Name) && p.Type is PlaceholderType.Text
or PlaceholderType.Multiline or PlaceholderType.Date or PlaceholderType.Number))
{
var input = new LetterPlaceholderInput(placeholder.Name, placeholder.Type);
input.PropertyChanged += OnCustomPlaceholderChanged;
CustomPlaceholders.Add(input);
}
}
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { }
}
OnPropertyChanged(nameof(HasCustomPlaceholders));
}
private void OnCustomPlaceholderChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => RefreshValidation();
private static readonly HashSet<string> StandardPlaceholderNames = new(StringComparer.Ordinal)
{
"Datum", "CurrentDate", "Empfaenger", "Anrede", "Brieftext", "LehrerName",
"Student.FirstName", "Student.LastName", "Student.Name", "Contact.Name", "Contact.Address", "Contact.Street",
"Contact.PostalCode", "Contact.City", "Letter.Salutation", "Group.Name", "SchoolYear",
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, StudentAbsenceDayListDrawingBuilder.PlaceholderName,
};
public AttendanceCalendarOptions GetAttendanceCalendarOptions() => _attendanceCalendarOptions;
public void SetAttendanceCalendarOptions(AttendanceCalendarOptions options)
public async Task SetAttendanceCalendarOptionsAsync(AttendanceCalendarOptions options, CancellationToken token = default)
{
_attendanceCalendarOptions = options with { StartMonth = options.NormalizedStartMonth,
MonthCount = options.NormalizedMonthCount };
AttendanceCalendarConfigured = true;
AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions);
AttendanceRefreshError = "";
if (_attendanceDataRefresher is not null)
{
IsRefreshingAttendanceData = true;
try { await _attendanceDataRefresher(_attendanceCalendarOptions, token); }
catch (WebUntisIntegrationException ex)
{ AttendanceRefreshError = $"WebUntis-Daten konnten nicht aktualisiert werden: {ex.Message}"; }
finally { IsRefreshingAttendanceData = false; }
}
RefreshValidation();
}
@@ -187,3 +253,33 @@ public sealed class LetterContactChoice(Contact model) { public Contact Model {
public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; }
public sealed class LetterGenerationIssue(string message, bool isStrong)
{ public string Icon { get; } = isStrong ? "⚠" : "ⓘ"; public string Message { get; } = message; public string Color { get; } = isStrong ? "#D97706" : "#6B7280"; }
public sealed partial class LetterPlaceholderInput : ObservableObject
{
public string Name { get; }
public PlaceholderType Type { get; }
public string Label => Type switch
{
PlaceholderType.Date => $"{Name} (Datum)",
PlaceholderType.Number => $"{Name} (Zahl)",
_ => Name,
};
public bool IsTextType => Type == PlaceholderType.Text;
public bool IsMultilineType => Type == PlaceholderType.Multiline;
public bool IsDateType => Type == PlaceholderType.Date;
public bool IsNumberType => Type == PlaceholderType.Number;
[ObservableProperty] private string _textValue = "";
[ObservableProperty] private DateTimeOffset? _dateValue;
[ObservableProperty] private decimal? _numberValue;
public LetterPlaceholderInput(string name, PlaceholderType type) { Name = name; Type = type; }
public PlaceholderValue ToPlaceholderValue() => Type switch
{
PlaceholderType.Multiline => new MultilineValue(TextValue),
PlaceholderType.Date => new DateValue(DateValue.HasValue ? DateOnly.FromDateTime(DateValue.Value.LocalDateTime) : default),
PlaceholderType.Number => new NumberValue(NumberValue ?? 0),
_ => new TextValue(TextValue),
};
}
@@ -0,0 +1,25 @@
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Students;
public partial class SelectContactDialogViewModel : ObservableObject
{
[ObservableProperty] private LetterContactChoice? _selectedContact;
public IReadOnlyList<LetterContactChoice> Contacts { get; }
public string AddressPreview => LetterPlaceholderBuilder.FormatAddress(SelectedContact?.Model);
public bool HasAddressPreview => !string.IsNullOrWhiteSpace(AddressPreview);
public SelectContactDialogViewModel(IReadOnlyList<LetterContactChoice> contacts, LetterContactChoice? current)
{
Contacts = contacts;
SelectedContact = current ?? contacts.FirstOrDefault();
}
partial void OnSelectedContactChanged(LetterContactChoice? value)
{
OnPropertyChanged(nameof(AddressPreview));
OnPropertyChanged(nameof(HasAddressPreview));
}
}
@@ -26,11 +26,21 @@
Classes="emptyhint" IsVisible="{Binding HasNoTemplates}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Kontakt *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Contacts}" SelectedItem="{Binding SelectedContact}"
DisplayMemberBinding="{Binding Display}" HorizontalAlignment="Stretch"/>
<TextBlock Text="Kein aktueller Kontakt vorhanden." Classes="emptyhint"
IsVisible="{Binding HasNoContacts}"/>
<TextBlock Text="Anschrift *" FontSize="12" Opacity="0.7"/>
<Border BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" CornerRadius="6" Padding="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding AddressPreview}" TextWrapping="Wrap"
VerticalAlignment="Center" IsVisible="{Binding HasAddressPreview}"/>
<TextBlock Grid.Column="0" Text="Kein aktueller Kontakt vorhanden." Classes="emptyhint"
VerticalAlignment="Center" IsVisible="{Binding HasNoContacts}"/>
<Button Grid.Column="1" Content="Kontakt wechseln …" Click="OnSelectContact"
IsEnabled="{Binding !HasNoContacts}" VerticalAlignment="Center"/>
</Grid>
</Border>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Anrede *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Anrede}" PlaceholderText="z.B. Sehr geehrte Frau Mustermann,"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
@@ -46,14 +56,20 @@
</Grid>
<Border IsVisible="{Binding UsesAttendanceAdvancedContent}" BorderBrush="{DynamicResource AppCardBorderBrush}"
BorderThickness="1" CornerRadius="6" Padding="12">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="3">
<TextBlock Text="Anwesenheitsdaten im Brief" FontWeight="SemiBold"/>
<TextBlock Text="{Binding AttendanceCalendarSummary}" FontSize="12" Opacity="0.65"/>
</StackPanel>
<Button Grid.Column="1" Content="Konfigurieren …" Click="OnConfigureAttendanceCalendar"
VerticalAlignment="Center"/>
</Grid>
<StackPanel Spacing="6">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="3">
<TextBlock Text="Anwesenheitsdaten im Brief" FontWeight="SemiBold"/>
<TextBlock Text="{Binding AttendanceCalendarSummary}" FontSize="12" Opacity="0.65"/>
</StackPanel>
<Button Grid.Column="1" Content="Konfigurieren …" Click="OnConfigureAttendanceCalendar"
IsEnabled="{Binding !IsRefreshingAttendanceData}" VerticalAlignment="Center"/>
</Grid>
<TextBlock Text="Aktualisiere WebUntis-Daten für den gewählten Zeitraum …" FontSize="12"
Foreground="#6B7280" IsVisible="{Binding IsRefreshingAttendanceData}"/>
<TextBlock Text="{Binding AttendanceRefreshError}" FontSize="12" Foreground="#D97706" TextWrapping="Wrap"
IsVisible="{Binding AttendanceRefreshError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Border>
<StackPanel Spacing="4">
<TextBlock Text="Brieftext" FontSize="12" Opacity="0.7"/>
@@ -65,6 +81,26 @@
<TextBox Text="{Binding TeacherName}" PlaceholderText="Name der Lehrkraft"/>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding HasCustomPlaceholders}">
<TextBlock Text="Zusätzliche Felder der Vorlage" FontSize="12" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding CustomPlaceholders}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:LetterPlaceholderInput">
<StackPanel Spacing="4" Margin="0,0,0,8">
<TextBlock Text="{Binding Label}" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding TextValue}" IsVisible="{Binding IsTextType}"/>
<TextBox Text="{Binding TextValue}" AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"
IsVisible="{Binding IsMultilineType}"/>
<CalendarDatePicker SelectedDate="{Binding DateValue, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"
HorizontalAlignment="Stretch" IsVisible="{Binding IsDateType}"/>
<NumericUpDown Value="{Binding NumberValue}" FormatString="0.##" HorizontalAlignment="Stretch"
IsVisible="{Binding IsNumberType}"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<Border BorderBrush="#D97706" BorderThickness="1" CornerRadius="6" Padding="10"
IsVisible="{Binding HasIssues}">
<StackPanel Spacing="5">
@@ -30,12 +30,20 @@ public partial class CreateLetterDialog : Window
if (DataContext is CreateLetterDialogViewModel vm) await ConfigureAttendanceCalendar(vm);
}
private async void OnSelectContact(object? sender, RoutedEventArgs e)
{
if (DataContext is not CreateLetterDialogViewModel vm) return;
var dialogVm = new SelectContactDialogViewModel(vm.Contacts, vm.SelectedContact);
var dialog = new SelectContactDialog { DataContext = dialogVm };
if (await dialog.ShowDialog<bool>(this)) vm.SelectedContact = dialogVm.SelectedContact;
}
private async Task<bool> ConfigureAttendanceCalendar(CreateLetterDialogViewModel vm)
{
var configVm = new AttendanceCalendarConfigurationViewModel(vm.GetAttendanceCalendarOptions());
var dialog = new AttendanceCalendarConfigurationDialog { DataContext = configVm };
if (!await dialog.ShowDialog<bool>(this)) return false;
vm.SetAttendanceCalendarOptions(configVm.BuildResult());
await vm.SetAttendanceCalendarOptionsAsync(configVm.BuildResult());
return true;
}
@@ -8,7 +8,7 @@
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<ScrollViewer Grid.Row="0">
<ScrollViewer Grid.Row="0" MaxHeight="560">
<StackPanel Spacing="12">
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
@@ -0,0 +1,33 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.SelectContactDialog"
x:DataType="vm:SelectContactDialogViewModel"
Title="Kontakt wählen" Width="380" Height="440"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="24">
<TextBlock Grid.Row="0" Text="Kontakt für Anrede und Anschrift" Classes="dialogtitle" Margin="0,0,0,12"/>
<ListBox Grid.Row="1" ItemsSource="{Binding Contacts}" SelectedItem="{Binding SelectedContact}"
BorderThickness="1">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:LetterContactChoice">
<TextBlock Text="{Binding Display}" Padding="4,2"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Border Grid.Row="2" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" CornerRadius="6"
Padding="10" Margin="0,12,0,0" IsVisible="{Binding HasAddressPreview}">
<TextBlock Text="{Binding AddressPreview}" TextWrapping="Wrap" FontSize="12"/>
</Border>
<Grid Grid.Row="3" ColumnDefinitions="*,10,*" Margin="0,16,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Übernehmen" HorizontalAlignment="Stretch" Click="OnApply"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,12 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Students;
public partial class SelectContactDialog : Window
{
public SelectContactDialog() => InitializeComponent();
private void OnApply(object? sender, RoutedEventArgs e) => Close(true);
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -49,6 +49,7 @@ public partial class DesignerViewModel : ObservableObject
[ObservableProperty] private string _selectedPageTemplate = "first";
[ObservableProperty] private DesignerPreviewPage? _selectedPreviewPage;
[ObservableProperty] private SpecialContentItem? _selectedSpecialContent;
[ObservableProperty] private bool _isLegacyContinuationSupported = true;
public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"];
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
@@ -586,6 +587,8 @@ public partial class DesignerViewModel : ObservableObject
try
{
var page = new LayoutParser().Parse(value);
IsLegacyContinuationSupported = !page.UsesPageTemplates;
if (page.UsesPageTemplates) UseContinuationLayout = false;
OverlayPageWidth = page.Width; OverlayPageHeight = page.Height; OverlayPageUnit = page.Unit;
var names = page.PageTemplates.Select(x => x.Name).ToList();
if (names.Count == 0) names.Add("legacy");
+2 -2
View File
@@ -271,12 +271,12 @@
<Button Grid.Column="1" Content="Prüfen &amp; Vorschau" Click="OnPreview"/>
<Button Grid.Column="3" Content="Als PDF exportieren …" Click="OnExportCurrentPdf"/></Grid>
<TabControl Grid.Row="1" Margin="12">
<TabItem Header="Seite 1">
<TabItem Header="Skript">
<TextBox Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap"
FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top"
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
</TabItem>
<TabItem Header="Folgeseiten">
<TabItem Header="Folgeseiten (Legacy)" IsVisible="{Binding IsLegacyContinuationSupported}">
<Grid RowDefinitions="Auto,*">
<CheckBox Margin="8" Content="Eigenes Layout für Seite 2 und alle weiteren Seiten im Paket speichern"
IsChecked="{Binding UseContinuationLayout}"/>
@@ -108,7 +108,7 @@ public sealed class PdfImportPipeline
var blank = templatePath is null ? null : Extract(templatePath);
EnsureCompatible(example, blank);
if (example.Pages.Count > 1)
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend als Folgeseitenlayout ergänzt werden.");
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend im Reiter „Seiten & Flows“ als zusätzliche Seitenvorlage (page-template continuation) ergänzt werden.");
var candidates = FindCandidates(example, blank);
if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password))
{
@@ -124,7 +124,7 @@ public sealed class PdfImportPipeline
{
if (document.Pages.Count == 0) throw new InvalidDataException("Das PDF enthält keine Seiten.");
if (document.Pages.Count > 1)
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend als Folgeseitenlayout ergänzt werden.");
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend im Reiter „Seiten & Flows“ als zusätzliche Seitenvorlage (page-template continuation) ergänzt werden.");
var first = document.Pages[0];
var assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
{
@@ -134,6 +134,8 @@ public sealed class PdfImportPipeline
var lines = new List<string>
{
$"PAGE {N(first.Width)} {N(first.Height)} pt",
"#pragma format-version 3",
"#pragma page-template first",
"BG pdf-import-background.png",
};
var definitions = new List<PlaceholderDefinition>();
@@ -157,6 +159,7 @@ public sealed class PdfImportPipeline
definitions.Add(new(name, multiline ? PlaceholderType.Multiline : candidate.Type, false));
candidate.Name = name;
}
lines.Add("#pragma end-page-template");
var manifest = new TemplateManifest
{
Id = "pdf-import", Name = "PDF-Import", Description = "Automatisch aus einem PDF rekonstruiert",
@@ -3,6 +3,7 @@
<ItemGroup>
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="PdfPig" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio"><PrivateAssets>all</PrivateAssets></PackageReference>
</ItemGroup>
@@ -96,6 +96,43 @@ public sealed class TemplatingTests : IDisposable
System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b"));
}
// Regression: RenderFlow fensterte Seiten bisher nur über einen SVG-viewBox-Y-Offset, den
// QuestPDFs Svg()-Renderer nicht respektiert - dadurch landete auf jeder Folgeseite (nahezu)
// der komplette Zeicheninhalt erneut, statt nur des jeweiligen Ausschnitts (siehe Slice() in
// QuestTemplateRenderer.DrawingElementRenderer). Prüft per echter PDF-Textextraktion, dass
// seitenspezifische Marker nur auf ihrer jeweiligen Seite auftauchen.
[Fact]
public void FlowDrawBox_ZerschneidetInhaltEchtStattIhnAufFolgeseitenZuWiederholen()
{
var template = new LoadedTemplate(new TemplateManifest
{
Placeholders = [new("Marker", PlaceholderType.Drawing, true)],
}, new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 100 $Marker"),
new Dictionary<string, byte[]>());
var drawing = new DrawingValue(
[
new DrawStringEx(0, 0, 12, 170, "MARKERTOP", DrawingTextAlignment.AlignLeft, 10),
new DrawStringEx(0, 90, 12, 170, "MARKERNEARBOTTOM", DrawingTextAlignment.AlignLeft, 10),
new DrawStringEx(0, 105, 12, 170, "MARKERAFTERBOUNDARY", DrawingTextAlignment.AlignLeft, 10),
new DrawStringEx(0, 190, 12, 170, "MARKERBOTTOM", DrawingTextAlignment.AlignLeft, 10),
], 200);
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Marker"] = drawing }));
using var document = UglyToad.PdfPig.PdfDocument.Open(pdf);
var pages = document.GetPages().Select(p => p.Text).ToList();
Assert.Equal(2, pages.Count);
Assert.Contains("MARKERTOP", pages[0]);
Assert.Contains("MARKERNEARBOTTOM", pages[0]);
Assert.DoesNotContain("MARKERAFTERBOUNDARY", pages[0]);
Assert.DoesNotContain("MARKERBOTTOM", pages[0]);
Assert.DoesNotContain("MARKERTOP", pages[1]);
Assert.DoesNotContain("MARKERNEARBOTTOM", pages[1]);
Assert.Contains("MARKERAFTERBOUNDARY", pages[1]);
Assert.Contains("MARKERBOTTOM", pages[1]);
}
[Fact]
public void FlowDrawBox_PaginertHohenDeklarativenZeichenraum()
{
+47 -5
View File
@@ -531,22 +531,64 @@ internal static class DrawingElementRenderer
};
container.Column(column =>
{
for (var page = 0; page < pages.Count; page++)
// Kein manueller PageBreak() zwischen den Ausschnitten: RenderFlow sitzt in einer
// ohnehin automatisch paginierenden Column (Flow-Dokument) bzw. füllt im Legacy-Modell
// fast die ganze Seite aus. Ein erzwungener Break hier sprang unabhängig vom
// tatsächlich verbleibenden Platz auf der aktuellen physischen Seite auf eine neue -
// 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)
{
if (page > 0) column.Item().PageBreak();
var offset = value is DrawingValue ? page * pageHeight : 0;
// 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(pages[page], width, pageHeight, offset, unit));
BuildSvg(page, width, pageHeight, 0, unit));
}
});
}
// Schneidet die Zeichenbefehle anhand ihrer Y-Koordinate in Seiten auf und verschiebt sie auf
// seitenlokale Koordinaten (Y=0 am Seitenanfang). Ein SVG-Element mit "viewBox" und
// "overflow=hidden" wird von QuestPDFs Svg()-Renderer NICHT zuverlässig geclippt - ein
// (Y-)Offset im viewBox allein reicht nicht, um pro Seite nur den jeweiligen Ausschnitt zu
// zeigen; ohne dieses echte Zerschneiden landet auf jeder Seite (nahezu) der komplette
// Zeicheninhalt erneut (Bug: doppelte/überlappende Fehlzeitenliste über mehrere Seiten).
private static List<DrawingValue> Slice(DrawingValue value, float pageHeight)
{
var pageCount = Math.Max(1, (int)Math.Ceiling(Math.Max(0, value.ContentHeight) / pageHeight));
return Enumerable.Repeat(value, pageCount).ToList();
var pages = new List<List<DrawingCommand>>();
for (var i = 0; i < pageCount; i++) pages.Add([]);
var pendingLinePage = 0;
foreach (var command in value.Commands)
{
var y = CommandY(command);
if (y is null) { pages[0].Add(command); continue; }
var page = Math.Clamp((int)(y.Value / pageHeight), 0, pageCount - 1);
// LineTo gehört inhaltlich zum vorherigen MoveTo (eine Linie) - beide müssen auf
// derselben Seite landen, sonst fehlt beim Rendern der Linie der Startpunkt.
if (command is MoveTo) pendingLinePage = page;
else if (command is LineTo) page = pendingLinePage;
pages[page].Add(ShiftY(command, page * pageHeight));
}
return pages.Select(commands => new DrawingValue(commands, pageHeight)).ToList();
}
private static float? CommandY(DrawingCommand command) => command switch
{
DrawString s => s.Y, DrawStringEx s => s.Y, MoveTo m => m.Y, LineTo l => l.Y,
DrawRectangle r => r.Y, DrawImage i => i.Y, DrawLine ln => Math.Min(ln.Y1, ln.Y2), _ => null,
};
private static DrawingCommand ShiftY(DrawingCommand command, float dy) => command switch
{
DrawString s => s with { Y = s.Y - dy }, DrawStringEx s => s with { Y = s.Y - dy },
MoveTo m => m with { Y = m.Y - dy }, LineTo l => l with { Y = l.Y - dy },
DrawRectangle r => r with { Y = r.Y - dy }, DrawImage i => i with { Y = i.Y - dy },
DrawLine ln => ln with { Y1 = ln.Y1 - dy, Y2 = ln.Y2 - dy },
_ => command,
};
internal static List<IReadOnlyList<DrawingCommand>> RecordPages(PagedDrawingValue value,
float width, float height, int maxPages)
{