fix: Anwesenheitskalender respektiert die von der Vorlage vorgegebene Boxhöhe
CI / build-and-test (push) Waiting to run

Nachdem die Kalender-Breite jetzt an die reale DRAWBOX/FLOWDRAWBOX-Breite
gekoppelt ist, konnte das (korrekt größer werdende) Raster die von der
Vorlage deklarierte Höhe überschreiten - eine DRAWBOX bricht anders als eine
FLOWDRAWBOX nicht automatisch auf Folgeseiten um, überschüssiger Inhalt wird
von QuestTemplateRenderer stillschweigend am unteren Rand abgeschnitten.

StudentAttendanceCalendarDrawingBuilder ermittelt jetzt vorab, wie viele
Wochen die gewählten Monate brauchen, und verkleinert "Größe" automatisch so
weit, dass der Kalender innerhalb der deklarierten Höhe bleibt (nur bei
DRAWBOX/IsFixed - eine FLOWDRAWBOX darf weiterhin frei wachsen, da sie bei
Bedarf auf weitere Seiten fließt statt abzuschneiden).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 14:39:45 +02:00
co-authored by Claude Sonnet 5
parent 86a97d91a7
commit 4542f263fc
7 changed files with 104 additions and 49 deletions
@@ -127,6 +127,25 @@ public sealed class ClassTeacherViewModelsTests
Assert.True(large.ContentHeight > small.ContentHeight); Assert.True(large.ContentHeight > small.ContentHeight);
} }
// Regression: DRAWBOX bricht anders als FLOWDRAWBOX nicht automatisch um - Inhalt, der die von
// der Vorlage deklarierte Höhe überschreitet, wird von QuestTemplateRenderer stillschweigend am
// unteren Rand abgeschnitten (kein Fehler, keine Warnung). "Größe" muss deshalb automatisch so
// weit verkleinert werden, dass alle gewählten Monate innerhalb von contentHeight Platz finden.
[Fact]
public void ElternbriefKalender_VerkleinertGroesseAutomatischUmDieVorlagenHoeheEinzuhalten()
{
var start = new DateOnly(2026, 9, 18);
var uneingeschraenkt = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller",
new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Large), [], []);
var engeBox = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller",
new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Large), [], [],
contentHeight: 60);
Assert.True(uneingeschraenkt.ContentHeight > 60,
"Testvoraussetzung: 3 Monate in 'Groß' brauchen ohne Deckelung mehr als 60 Einheiten Höhe.");
Assert.True(engeBox.ContentHeight <= 60 + 0.01f);
}
[Fact] [Fact]
public void ElternbriefFehltage_ListetDatumUmfangUndEntschuldigungsstatus() public void ElternbriefFehltage_ListetDatumUmfangUndEntschuldigungsstatus()
{ {
@@ -62,7 +62,7 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)); StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Lena Beispiel", new DateOnly(2026, 9, 1), [], []); var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Lena Beispiel", new DateOnly(2026, 9, 1), [], []);
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), (_, _, _) => drawing); new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), (_, _, _, _) => drawing);
var output = Path.Combine(_directory, "Kalender.pdf"); var output = Path.Combine(_directory, "Kalender.pdf");
Assert.True(vm.UsesAttendanceCalendar); Assert.True(vm.UsesAttendanceCalendar);
@@ -100,7 +100,7 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
(_, width, millimeterScale) => { receivedWidth = width; receivedMillimeterScale = millimeterScale; return new DrawingValue([], 0); }); (_, width, millimeterScale, _) => { receivedWidth = width; receivedMillimeterScale = millimeterScale; return new DrawingValue([], 0); });
Assert.True(vm.UsesAttendanceCalendar); Assert.True(vm.UsesAttendanceCalendar);
Assert.Equal(250, receivedWidth); Assert.Equal(250, receivedWidth);
@@ -129,7 +129,7 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
(_, width, millimeterScale) => { receivedWidth = width; receivedMillimeterScale = millimeterScale; return new DrawingValue([], 0); }); (_, width, millimeterScale, _) => { receivedWidth = width; receivedMillimeterScale = millimeterScale; return new DrawingValue([], 0); });
Assert.True(vm.UsesAttendanceCalendar); Assert.True(vm.UsesAttendanceCalendar);
Assert.Equal(487, receivedWidth); Assert.Equal(487, receivedWidth);
@@ -145,7 +145,7 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
AttendanceCalendarOptions? requested = null; AttendanceCalendarOptions? requested = null;
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
(_, _, _) => new DrawingValue([], 0), attendanceDataRefresher: (options, _) => (_, _, _, _) => new DrawingValue([], 0), attendanceDataRefresher: (options, _) =>
{ requested = options; return Task.CompletedTask; }); { requested = options; return Task.CompletedTask; });
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions( await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
@@ -165,7 +165,7 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)); StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
(_, _, _) => new DrawingValue([], 0), (_, _, _, _) => new DrawingValue([], 0),
attendanceDataRefresher: (_, _) => throw new WebUntisIntegrationException("Keine Verbindung.")); attendanceDataRefresher: (_, _) => throw new WebUntisIntegrationException("Keine Verbindung."));
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions( await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
@@ -185,7 +185,7 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1), []); new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1), []);
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
attendanceCalendarFactory: null, absenceDayListFactory: (_, _, _) => drawing); attendanceCalendarFactory: null, absenceDayListFactory: (_, _, _, _) => drawing);
Assert.False(vm.UsesAttendanceCalendar); Assert.False(vm.UsesAttendanceCalendar);
Assert.True(vm.UsesAbsenceDayList); Assert.True(vm.UsesAbsenceDayList);
+3 -3
View File
@@ -20,9 +20,9 @@ public static class LetterDialogs
App.Services.GetRequiredService<ITemplateRenderer>(), App.Services.GetRequiredService<ITemplateRenderer>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(), App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IGroupRepository>(), App.Services.GetRequiredService<IGroupRepository>(),
(options, contentWidth, millimeterScale) => App.Services.GetRequiredService<StudentAttendanceCalendarService>() (options, contentWidth, millimeterScale, contentHeight) => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
.Build(student, options, contentWidth, millimeterScale), .Build(student, options, contentWidth, millimeterScale, contentHeight),
(options, contentWidth, millimeterScale) => App.Services.GetRequiredService<StudentAttendanceCalendarService>() (options, contentWidth, millimeterScale, contentHeight) => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
.BuildAbsenceDayList(student, options, contentWidth, millimeterScale), .BuildAbsenceDayList(student, options, contentWidth, millimeterScale),
RefreshAttendanceDataAsync); RefreshAttendanceDataAsync);
var dialog = new CreateLetterDialog { DataContext = vm }; var dialog = new CreateLetterDialog { DataContext = vm };
@@ -51,12 +51,16 @@ public static class LetterPlaceholderBuilder
_ => false, _ => false,
}; };
/// <summary>Tatsächliche DRAWBOX/FLOWDRAWBOX-Breite eines Platzhalters (in der Maßeinheit der /// <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 /// Vorlage) plus dem Umrechnungsfaktor "1mm in dieser Maßeinheit". DrawingValue-Builder wie
/// <see cref="StudentAttendanceCalendarDrawingBuilder"/> entwerfen ihr Raster in Millimetern; /// <see cref="StudentAttendanceCalendarDrawingBuilder"/> entwerfen ihr Raster in Millimetern;
/// ohne MillimeterScale bliebe das bei einer in "pt" (statt "mm") deklarierten Vorlage viel zu /// 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).</summary> /// klein (1 "mm-Einheit" würde als 1pt ≈ 0.35mm gerendert). IsFixed unterscheidet DRAWBOX (feste,
public readonly record struct DeclaredDrawingBox(float Width, float MillimeterScale); /// 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 DeclaredDrawingBox? FindDeclaredDrawingBox(LoadedTemplate template, string placeholderName) => public static DeclaredDrawingBox? FindDeclaredDrawingBox(LoadedTemplate template, string placeholderName) =>
FindDeclaredDrawingBox(template.Layout, placeholderName) ?? FindDeclaredDrawingBox(template.Layout, placeholderName) ??
@@ -64,17 +68,17 @@ public static class LetterPlaceholderBuilder
private static DeclaredDrawingBox? FindDeclaredDrawingBox(TemplateLayout layout, string placeholderName) private static DeclaredDrawingBox? FindDeclaredDrawingBox(TemplateLayout layout, string placeholderName)
{ {
var width = layout.Elements.Concat(layout.PageTemplates.SelectMany(p => p.Elements)) var box = layout.Elements.Concat(layout.PageTemplates.SelectMany(p => p.Elements))
.Concat(layout.ContentFlows.SelectMany(f => f.Elements)) .Concat(layout.ContentFlows.SelectMany(f => f.Elements))
.Select(e => e switch .Select(e => e switch
{ {
DrawBoxElement draw when draw.Placeholder == placeholderName => (float?)draw.Width, DrawBoxElement draw when draw.Placeholder == placeholderName => ((float Width, float Height, bool IsFixed)?)(draw.Width, draw.Height, true),
FlowDrawBoxElement flow when flow.Placeholder == placeholderName => (float?)flow.Width, FlowDrawBoxElement flow when flow.Placeholder == placeholderName => (flow.Width, flow.Height, false),
_ => null, _ => null,
}) })
.FirstOrDefault(w => w is not null); .FirstOrDefault(b => b is not null);
if (width is null) return null; if (box is null) return null;
var millimeterScale = UnitConverter.Points(1, "mm") / UnitConverter.Points(1, layout.Unit); var millimeterScale = UnitConverter.Points(1, "mm") / UnitConverter.Points(1, layout.Unit);
return new DeclaredDrawingBox(width.Value, millimeterScale); return new DeclaredDrawingBox(box.Value.Width, box.Value.Height, millimeterScale, box.Value.IsFixed);
} }
} }
@@ -75,12 +75,13 @@ public class LetterTemplateTools(
var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today); var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today);
var calendarBox = LetterPlaceholderBuilder.FindDeclaredDrawingBox(loaded, var calendarBox = LetterPlaceholderBuilder.FindDeclaredDrawingBox(loaded,
StudentAttendanceCalendarDrawingBuilder.PlaceholderName) ?? new(170, 1); StudentAttendanceCalendarDrawingBuilder.PlaceholderName) ?? new(170, float.PositiveInfinity, 1, false);
var absenceDayListBox = LetterPlaceholderBuilder.FindDeclaredDrawingBox(loaded, var absenceDayListBox = LetterPlaceholderBuilder.FindDeclaredDrawingBox(loaded,
StudentAbsenceDayListDrawingBuilder.PlaceholderName) ?? new(170, 1); StudentAbsenceDayListDrawingBuilder.PlaceholderName) ?? new(170, float.PositiveInfinity, 1, false);
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText, var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText,
teacherName, attendanceCalendars?.Build(student, new AttendanceCalendarOptions(date, 1), teacherName, attendanceCalendars?.Build(student, new AttendanceCalendarOptions(date, 1),
calendarBox.Width, calendarBox.MillimeterScale), calendarBox.Width, calendarBox.MillimeterScale,
calendarBox.IsFixed ? calendarBox.Height : float.PositiveInfinity),
attendanceCalendars?.BuildAbsenceDayList(student, new AttendanceCalendarOptions(date, 1), attendanceCalendars?.BuildAbsenceDayList(student, new AttendanceCalendarOptions(date, 1),
absenceDayListBox.Width, absenceDayListBox.MillimeterScale)); absenceDayListBox.Width, absenceDayListBox.MillimeterScale));
foreach (var (name, raw) in extraValues ?? []) foreach (var (name, raw) in extraValues ?? [])
@@ -29,32 +29,63 @@ public static class StudentAttendanceCalendarDrawingBuilder
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options, public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences, IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, float contentWidth = 170, IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, float contentWidth = 170,
float millimeterScale = 1) float millimeterScale = 1, float contentHeight = float.PositiveInfinity)
{ {
var scale = options.Size switch var requestedScale = options.Size switch
{ {
AttendanceCalendarSize.Small => .7f, AttendanceCalendarSize.Small => .7f,
AttendanceCalendarSize.Large => 1f, AttendanceCalendarSize.Large => 1f,
_ => .85f, _ => .85f,
}; };
// contentWidth/millimeterScale kommen aus der tatsächlichen DRAWBOX/FLOWDRAWBOX-Deklaration // contentWidth/contentHeight/millimeterScale kommen aus der tatsächlichen DRAWBOX/
// der jeweiligen Vorlage (siehe LetterPlaceholderBuilder.FindDeclaredDrawingBox). Das Raster // FLOWDRAWBOX-Deklaration der jeweiligen Vorlage (siehe
// ist in Millimetern entworfen; millimeterScale rechnet das in die Koordinaten-Einheit der // LetterPlaceholderBuilder.FindDeclaredDrawingBox). Das Raster ist in Millimetern entworfen;
// Vorlage um (1, wenn die Vorlage schon "mm" nutzt; ≈2.83 bei "pt") - ohne das würde das // millimeterScale rechnet das in die Koordinaten-Einheit der Vorlage um (1, wenn die Vorlage
// Raster bei einer in "pt" deklarierten Vorlage nur rund ein Drittel der vorgesehenen Größe // schon "mm" nutzt; ≈2.83 bei "pt") - ohne das würde das Raster bei einer in "pt" deklarierten
// erreichen. Schriftgrößen (FontSize) sind davon unabhängig immer echte Punktgrößen. // Vorlage nur rund ein Drittel der vorgesehenen Größe erreichen. Schriftgrößen (FontSize) sind
// contentWidth ist unabhängig von der Größenwahl fest - nur Schrift/Zellenhöhe skalieren mit // davon unabhängig immer echte Punktgrößen. contentWidth ist unabhängig von der Größenwahl
// "Größe". Würde die Breite mit skalieren, würde "Groß" (scale=1) exakt die Skript-Box // fest - nur Schrift/Zellenhöhe skalieren mit "Größe". Würde die Breite mit skalieren, würde
// ausfüllen, "Klein"/"Normal" aber nur einen Teil davon - und eine größere Skalierung als 1 // "Groß" (scale=1) exakt die Skript-Box ausfüllen, "Klein"/"Normal" aber nur einen Teil davon
// liefe über die Box hinaus und würde am rechten Rand abgeschnitten (SVG overflow="hidden"). // - und eine größere Skalierung als 1 liefe über die Box hinaus und würde am rechten Rand
// Die Defaults (170/1) greifen nur, wenn die Vorlage nicht ermittelt werden kann (z.B. // abgeschnitten (SVG overflow="hidden"). Die Defaults (170/1/unendlich) greifen nur, wenn die
// Vorschau ohne Kontext). // Vorlage nicht ermittelt werden kann (z.B. Vorschau ohne Kontext).
var first = options.NormalizedStartMonth;
var monthCount = options.NormalizedMonthCount;
// Wochenanzahl je Monat vorab ermitteln, unabhängig von "Größe" - nötig, um VOR dem
// eigentlichen Zeichnen zu wissen, wie viel Höhe das Raster braucht. Eine DRAWBOX bricht
// anders als FLOWDRAWBOX nicht automatisch auf Folgeseiten um (siehe
// DrawingElementRenderer.RenderFixed/Slice): Inhalt, der contentHeight überschreitet, wird
// von QuestTemplateRenderer am unteren Rand stillschweigend abgeschnitten. "Größe" wird
// deshalb nötigenfalls automatisch verkleinert, statt die von der Vorlage vorgegebene
// Boxhöhe zu verletzen.
var maxWeeks = 0;
for (var monthIndex = 0; monthIndex < monthCount; monthIndex++)
{
var current = first.AddMonths(monthIndex);
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(current, absences, registerEntries,
DateOnly.FromDateTime(DateTime.Today), studentName);
var offset = ((int)current.DayOfWeek + 6) % 7;
maxWeeks = Math.Max(maxWeeks, (int)Math.Ceiling((offset + days.Count) / 7d));
}
// Muss mit der Höhenformel am Ende dieser Methode übereinstimmen (dort als "y" berechnet):
// Titel+Name+Abstand (26) + Monats-/Wochentagskopf (20) + maxWeeks Wochenzeilen (10 je Woche)
// + Legende/Nachlauf (9), alles mit scale*millimeterScale skaliert, plus der einmalige,
// größenunabhängige monthGapX-Abstand (6*millimeterScale).
float RequiredHeight(float s) => s * millimeterScale * (55 + 10 * maxWeeks) + 6f * millimeterScale;
var scale = requestedScale;
if (float.IsFinite(contentHeight) && RequiredHeight(requestedScale) > contentHeight)
{
var perScaleUnit = RequiredHeight(1) - RequiredHeight(0);
scale = perScaleUnit > 0
? Math.Clamp((contentHeight - RequiredHeight(0)) / perScaleUnit, .35f, requestedScale)
: requestedScale;
}
var cellHeight = 10 * scale * millimeterScale; var cellHeight = 10 * scale * millimeterScale;
// War 10mm - bei 3 Monaten nebeneinander größer als eine einzelne Tageskachel und zog damit // War 10mm - bei 3 Monaten nebeneinander größer als eine einzelne Tageskachel und zog damit
// spürbar Platz von den Kacheln ab, ohne selbst als Inhalt wahrgenommen zu werden. // spürbar Platz von den Kacheln ab, ohne selbst als Inhalt wahrgenommen zu werden.
var monthGapX = 6f * millimeterScale; var monthGapX = 6f * millimeterScale;
var first = options.NormalizedStartMonth;
var monthCount = options.NormalizedMonthCount;
var monthWidth = (contentWidth - monthGapX * (monthCount - 1)) / monthCount; var monthWidth = (contentWidth - monthGapX * (monthCount - 1)) / monthCount;
var cellWidth = monthWidth / 7; var cellWidth = monthWidth / 7;
// Kachel-Zwischenraum (Trennung zu Nachbarzellen) deutlich knapper als vorher (war "scale" // Kachel-Zwischenraum (Trennung zu Nachbarzellen) deutlich knapper als vorher (war "scale"
@@ -72,15 +103,12 @@ public static class StudentAttendanceCalendarDrawingBuilder
y += 10 * scale * millimeterScale + 2 * scale * millimeterScale; y += 10 * scale * millimeterScale + 2 * scale * millimeterScale;
var weekdays = new[] { "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So" }; var weekdays = new[] { "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So" };
var gridStartY = y; var gridStartY = y;
var maxWeeks = 0;
for (var monthIndex = 0; monthIndex < monthCount; monthIndex++) for (var monthIndex = 0; monthIndex < monthCount; monthIndex++)
{ {
var current = first.AddMonths(monthIndex); var current = first.AddMonths(monthIndex);
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(current, absences, registerEntries, var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(current, absences, registerEntries,
DateOnly.FromDateTime(DateTime.Today), studentName); DateOnly.FromDateTime(DateTime.Today), studentName);
var offset = ((int)current.DayOfWeek + 6) % 7; var offset = ((int)current.DayOfWeek + 6) % 7;
var weeks = (int)Math.Ceiling((offset + days.Count) / 7d);
maxWeeks = Math.Max(maxWeeks, weeks);
var xOffset = monthIndex * (monthWidth + monthGapX); var xOffset = monthIndex * (monthWidth + monthGapX);
var monthY = gridStartY; var monthY = gridStartY;
commands.Add(new DrawStringEx(xOffset, monthY, 11 * scale * millimeterScale, monthWidth, commands.Add(new DrawStringEx(xOffset, monthY, 11 * scale * millimeterScale, monthWidth,
@@ -222,7 +250,7 @@ public sealed class StudentAttendanceCalendarService(
Build(student, new AttendanceCalendarOptions(month, 1)); Build(student, new AttendanceCalendarOptions(month, 1));
public DrawingValue Build(Student student, AttendanceCalendarOptions options, float contentWidth = 170, public DrawingValue Build(Student student, AttendanceCalendarOptions options, float contentWidth = 170,
float millimeterScale = 1) float millimeterScale = 1, float contentHeight = float.PositiveInfinity)
{ {
var className = settings.HomeroomClassName; var className = settings.HomeroomClassName;
if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0); if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0);
@@ -245,7 +273,8 @@ public sealed class StudentAttendanceCalendarService(
e.TeacherUsername, e.CategoryName, e.CategoryGroup, e.Text)) e.TeacherUsername, e.CategoryName, e.CategoryGroup, e.Text))
.ToList(); .ToList();
return StudentAttendanceCalendarDrawingBuilder.Build(rosterName, options, return StudentAttendanceCalendarDrawingBuilder.Build(rosterName, options,
ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences), register, contentWidth, millimeterScale); ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences), register, contentWidth, millimeterScale,
contentHeight);
} }
public DrawingValue BuildAbsenceDayList(Student student, AttendanceCalendarOptions options, float contentWidth = 170, public DrawingValue BuildAbsenceDayList(Student student, AttendanceCalendarOptions options, float contentWidth = 170,
@@ -12,8 +12,8 @@ public partial class CreateLetterDialogViewModel : ObservableObject
private readonly Student _student; private readonly Student _student;
private readonly TemplateStore _templates; private readonly TemplateStore _templates;
private readonly ITemplateRenderer _renderer; private readonly ITemplateRenderer _renderer;
private readonly Func<AttendanceCalendarOptions, float, float, DrawingValue>? _attendanceCalendarFactory; private readonly Func<AttendanceCalendarOptions, float, float, float, DrawingValue>? _attendanceCalendarFactory;
private readonly Func<AttendanceCalendarOptions, float, float, DrawingValue>? _absenceDayListFactory; private readonly Func<AttendanceCalendarOptions, float, float, float, DrawingValue>? _absenceDayListFactory;
private readonly Func<AttendanceCalendarOptions, CancellationToken, Task>? _attendanceDataRefresher; private readonly Func<AttendanceCalendarOptions, CancellationToken, Task>? _attendanceDataRefresher;
private AttendanceCalendarOptions _attendanceCalendarOptions = new( private AttendanceCalendarOptions _attendanceCalendarOptions = new(
new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1), 1); new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1), 1);
@@ -52,8 +52,8 @@ public partial class CreateLetterDialogViewModel : ObservableObject
public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer, public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer,
IGroupMembershipRepository memberships, IGroupRepository groups, IGroupMembershipRepository memberships, IGroupRepository groups,
Func<AttendanceCalendarOptions, float, float, DrawingValue>? attendanceCalendarFactory = null, Func<AttendanceCalendarOptions, float, float, float, DrawingValue>? attendanceCalendarFactory = null,
Func<AttendanceCalendarOptions, float, float, DrawingValue>? absenceDayListFactory = null, Func<AttendanceCalendarOptions, float, float, float, DrawingValue>? absenceDayListFactory = null,
Func<AttendanceCalendarOptions, CancellationToken, Task>? attendanceDataRefresher = null) Func<AttendanceCalendarOptions, CancellationToken, Task>? attendanceDataRefresher = null)
{ {
_student = student; _templates = templates; _renderer = renderer; _student = student; _templates = templates; _renderer = renderer;
@@ -143,9 +143,11 @@ public partial class CreateLetterDialogViewModel : ObservableObject
_student, SelectedContact?.Model, SelectedGroup?.Model, _student, SelectedContact?.Model, SelectedGroup?.Model,
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName, DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName,
UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions, UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions,
calendarBox.Width, calendarBox.MillimeterScale) : null, calendarBox.Width, calendarBox.MillimeterScale,
calendarBox.IsFixed ? calendarBox.Height : float.PositiveInfinity) : null,
UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions, UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions,
absenceDayListBox.Width, absenceDayListBox.MillimeterScale) : null); absenceDayListBox.Width, absenceDayListBox.MillimeterScale,
absenceDayListBox.IsFixed ? absenceDayListBox.Height : float.PositiveInfinity) : null);
values["Anrede"] = new TextValue(Anrede); values["Letter.Salutation"] = new TextValue(Anrede); values["Anrede"] = new TextValue(Anrede); values["Letter.Salutation"] = new TextValue(Anrede);
foreach (var custom in CustomPlaceholders) values[custom.Name] = custom.ToPlaceholderValue(); foreach (var custom in CustomPlaceholders) values[custom.Name] = custom.ToPlaceholderValue();
return values; return values;
@@ -157,7 +159,7 @@ public partial class CreateLetterDialogViewModel : ObservableObject
/// 170mm zurück, wenn keine Vorlage gewählt ist oder die Box nicht gefunden wird.</summary> /// 170mm zurück, wenn keine Vorlage gewählt ist oder die Box nicht gefunden wird.</summary>
private LetterPlaceholderBuilder.DeclaredDrawingBox DeclaredDrawingBox(string placeholderName) private LetterPlaceholderBuilder.DeclaredDrawingBox DeclaredDrawingBox(string placeholderName)
{ {
var fallback = new LetterPlaceholderBuilder.DeclaredDrawingBox(170, 1); var fallback = new LetterPlaceholderBuilder.DeclaredDrawingBox(170, float.PositiveInfinity, 1, false);
if (SelectedTemplate is null) return fallback; if (SelectedTemplate is null) return fallback;
try { return LetterPlaceholderBuilder.FindDeclaredDrawingBox(_templates.Load(SelectedTemplate.Model), placeholderName) ?? fallback; } try { return LetterPlaceholderBuilder.FindDeclaredDrawingBox(_templates.Load(SelectedTemplate.Model), placeholderName) ?? fallback; }
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { return fallback; } catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { return fallback; }