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
+3 -3
View File
@@ -20,9 +20,9 @@ public static class LetterDialogs
App.Services.GetRequiredService<ITemplateRenderer>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
(options, contentWidth, millimeterScale) => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
.Build(student, options, contentWidth, millimeterScale),
(options, contentWidth, millimeterScale) => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
(options, contentWidth, millimeterScale, contentHeight) => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
.Build(student, options, contentWidth, millimeterScale, contentHeight),
(options, contentWidth, millimeterScale, contentHeight) => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
.BuildAbsenceDayList(student, options, contentWidth, millimeterScale),
RefreshAttendanceDataAsync);
var dialog = new CreateLetterDialog { DataContext = vm };
@@ -51,12 +51,16 @@ public static class LetterPlaceholderBuilder
_ => 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
/// <see cref="StudentAttendanceCalendarDrawingBuilder"/> entwerfen ihr Raster in Millimetern;
/// ohne MillimeterScale bliebe das bei einer in "pt" (statt "mm") deklarierten Vorlage viel zu
/// klein (1 "mm-Einheit" würde als 1pt ≈ 0.35mm gerendert).</summary>
public readonly record struct DeclaredDrawingBox(float Width, float MillimeterScale);
/// klein (1 "mm-Einheit" würde als 1pt ≈ 0.35mm gerendert). IsFixed unterscheidet DRAWBOX (feste,
/// nicht umbrechende Box - Inhalt, der die Höhe sprengt, wird von QuestTemplateRenderer
/// stillschweigend am unteren Rand abgeschnitten statt umzubrechen) von FLOWDRAWBOX (fließt bei
/// Bedarf automatisch auf weitere Seiten, siehe DrawingElementRenderer.Slice) - nur bei
/// IsFixed=true darf/muss ein Builder seine Höhe an Height anpassen.</summary>
public readonly record struct DeclaredDrawingBox(float Width, float Height, float MillimeterScale, bool IsFixed);
public static DeclaredDrawingBox? FindDeclaredDrawingBox(LoadedTemplate template, string placeholderName) =>
FindDeclaredDrawingBox(template.Layout, placeholderName) ??
@@ -64,17 +68,17 @@ public static class LetterPlaceholderBuilder
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))
.Select(e => e switch
{
DrawBoxElement draw when draw.Placeholder == placeholderName => (float?)draw.Width,
FlowDrawBoxElement flow when flow.Placeholder == placeholderName => (float?)flow.Width,
DrawBoxElement draw when draw.Placeholder == placeholderName => ((float Width, float Height, bool IsFixed)?)(draw.Width, draw.Height, true),
FlowDrawBoxElement flow when flow.Placeholder == placeholderName => (flow.Width, flow.Height, false),
_ => null,
})
.FirstOrDefault(w => w is not null);
if (width is null) return null;
.FirstOrDefault(b => b is not null);
if (box is null) return null;
var millimeterScale = UnitConverter.Points(1, "mm") / UnitConverter.Points(1, layout.Unit);
return new DeclaredDrawingBox(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 calendarBox = LetterPlaceholderBuilder.FindDeclaredDrawingBox(loaded,
StudentAttendanceCalendarDrawingBuilder.PlaceholderName) ?? new(170, 1);
StudentAttendanceCalendarDrawingBuilder.PlaceholderName) ?? new(170, float.PositiveInfinity, 1, false);
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,
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),
absenceDayListBox.Width, absenceDayListBox.MillimeterScale));
foreach (var (name, raw) in extraValues ?? [])
@@ -29,32 +29,63 @@ public static class StudentAttendanceCalendarDrawingBuilder
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
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.Large => 1f,
_ => .85f,
};
// contentWidth/millimeterScale kommen aus der tatsächlichen DRAWBOX/FLOWDRAWBOX-Deklaration
// der jeweiligen Vorlage (siehe LetterPlaceholderBuilder.FindDeclaredDrawingBox). Das Raster
// ist in Millimetern entworfen; millimeterScale rechnet das in die Koordinaten-Einheit der
// Vorlage um (1, wenn die Vorlage schon "mm" nutzt; ≈2.83 bei "pt") - ohne das würde das
// Raster bei einer in "pt" deklarierten Vorlage nur rund ein Drittel der vorgesehenen Größe
// erreichen. Schriftgrößen (FontSize) sind davon unabhängig immer echte Punktgrößen.
// contentWidth ist unabhängig von der Größenwahl fest - 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").
// Die Defaults (170/1) greifen nur, wenn die Vorlage nicht ermittelt werden kann (z.B.
// Vorschau ohne Kontext).
// contentWidth/contentHeight/millimeterScale kommen aus der tatsächlichen DRAWBOX/
// FLOWDRAWBOX-Deklaration der jeweiligen Vorlage (siehe
// LetterPlaceholderBuilder.FindDeclaredDrawingBox). Das Raster ist in Millimetern entworfen;
// millimeterScale rechnet das in die Koordinaten-Einheit der Vorlage um (1, wenn die Vorlage
// schon "mm" nutzt; ≈2.83 bei "pt") - ohne das würde das Raster bei einer in "pt" deklarierten
// Vorlage nur rund ein Drittel der vorgesehenen Größe erreichen. Schriftgrößen (FontSize) sind
// davon unabhängig immer echte Punktgrößen. contentWidth ist unabhängig von der Größenwahl
// fest - 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"). Die Defaults (170/1/unendlich) greifen nur, wenn die
// 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;
// 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.
var monthGapX = 6f * millimeterScale;
var first = options.NormalizedStartMonth;
var monthCount = options.NormalizedMonthCount;
var monthWidth = (contentWidth - monthGapX * (monthCount - 1)) / monthCount;
var cellWidth = monthWidth / 7;
// 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;
var weekdays = new[] { "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So" };
var gridStartY = y;
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;
var weeks = (int)Math.Ceiling((offset + days.Count) / 7d);
maxWeeks = Math.Max(maxWeeks, weeks);
var xOffset = monthIndex * (monthWidth + monthGapX);
var monthY = gridStartY;
commands.Add(new DrawStringEx(xOffset, monthY, 11 * scale * millimeterScale, monthWidth,
@@ -222,7 +250,7 @@ public sealed class StudentAttendanceCalendarService(
Build(student, new AttendanceCalendarOptions(month, 1));
public DrawingValue Build(Student student, AttendanceCalendarOptions options, float contentWidth = 170,
float millimeterScale = 1)
float millimeterScale = 1, float contentHeight = float.PositiveInfinity)
{
var className = settings.HomeroomClassName;
if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0);
@@ -245,7 +273,8 @@ public sealed class StudentAttendanceCalendarService(
e.TeacherUsername, e.CategoryName, e.CategoryGroup, e.Text))
.ToList();
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,