fix: Kalenderraster mit einem gemeinsamen Faktor statt unabhängig in x/y skalieren
CI / build-and-test (push) Waiting to run

Breite und Höhe wurden bisher getrennt behandelt (Breite = volle reale
Boxbreite, Höhe = Größenwahl*Einheitenfaktor) - bei einer Box, deren
Seitenverhältnis vom Referenzentwurf abweicht, verzerrte das die Tageskacheln
sichtbar.

Das Raster wird jetzt komplett in einem festen 170mm-Referenzentwurf
berechnet und erst am Ende mit einem einzigen Faktor k = min(a_x, a_y,
Größenwahl) * millimeterScale in die Koordinaten der Vorlage skaliert - a_x/
a_y sind die Verhältnisse von realer Boxbreite/-höhe zum Referenzentwurf.
Dadurch bleiben die Kacheln immer proportional, auf Kosten von ungenutztem
Rand auf der Achse, die nicht der begrenzende Faktor ist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 15:42:56 +02:00
co-authored by Claude Sonnet 5
parent 4542f263fc
commit c128fc6741
2 changed files with 81 additions and 57 deletions
@@ -127,6 +127,33 @@ public sealed class ClassTeacherViewModelsTests
Assert.True(large.ContentHeight > small.ContentHeight); Assert.True(large.ContentHeight > small.ContentHeight);
} }
// Regression: Breite und Höhe dürfen nur mit EINEM gemeinsamen Faktor skaliert werden (a =
// min(a_x, a_y)), nicht unabhängig voneinander - sonst verzerren die Tageskacheln, wenn das
// Seitenverhältnis der realen Box vom Referenzentwurf abweicht (z.B. eine sehr breite, aber
// niedrige DRAWBOX). Das Breiten-/Höhenverhältnis einer Kachel muss unabhängig von contentWidth
// gleich bleiben, solange die Höhe der begrenzende Faktor ist.
[Fact]
public void ElternbriefKalender_SkaliertKachelnGleichmaessigOhneVerzerrung()
{
var options = new AttendanceCalendarOptions(new DateOnly(2026, 9, 18), 3, AttendanceCalendarSize.Large);
var schmaleBox = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller", options, [], [],
contentWidth: 170, contentHeight: 60);
var sehrBreiteBox = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller", options, [], [],
contentWidth: 1000, contentHeight: 60);
var schmaleKachel = schmaleBox.Commands.OfType<DrawRoundedRectangle>().First();
var breiteKachel = sehrBreiteBox.Commands.OfType<DrawRoundedRectangle>().First();
var schmalesVerhaeltnis = schmaleKachel.Width / schmaleKachel.Height;
var breitesVerhaeltnis = breiteKachel.Width / breiteKachel.Height;
Assert.Equal(schmalesVerhaeltnis, breitesVerhaeltnis, 0.001f);
// Bei gleicher (begrenzender) Höhe muss die Kachelgröße selbst identisch sein - die
// zusätzliche Breite der zweiten Box bleibt ungenutzt (kein Verzerren, kein Zwang zum
// vollständigen Ausfüllen), statt die Kacheln künstlich in die Breite zu ziehen.
Assert.Equal(schmaleKachel.Width, breiteKachel.Width, 0.01f);
Assert.Equal(schmaleKachel.Height, breiteKachel.Height, 0.01f);
}
// Regression: DRAWBOX bricht anders als FLOWDRAWBOX nicht automatisch um - Inhalt, der die von // Regression: DRAWBOX bricht anders als FLOWDRAWBOX nicht automatisch um - Inhalt, der die von
// der Vorlage deklarierte Höhe überschreitet, wird von QuestTemplateRenderer stillschweigend am // der Vorlage deklarierte Höhe überschreitet, wird von QuestTemplateRenderer stillschweigend am
// unteren Rand abgeschnitten (kein Fehler, keine Warnung). "Größe" muss deshalb automatisch so // unteren Rand abgeschnitten (kein Fehler, keine Warnung). "Größe" muss deshalb automatisch so
@@ -39,26 +39,18 @@ public static class StudentAttendanceCalendarDrawingBuilder
}; };
// contentWidth/contentHeight/millimeterScale kommen aus der tatsächlichen DRAWBOX/ // contentWidth/contentHeight/millimeterScale kommen aus der tatsächlichen DRAWBOX/
// FLOWDRAWBOX-Deklaration der jeweiligen Vorlage (siehe // FLOWDRAWBOX-Deklaration der jeweiligen Vorlage (siehe
// LetterPlaceholderBuilder.FindDeclaredDrawingBox). Das Raster ist in Millimetern entworfen; // LetterPlaceholderBuilder.FindDeclaredDrawingBox). Das gesamte Raster wird in einem festen
// millimeterScale rechnet das in die Koordinaten-Einheit der Vorlage um (1, wenn die Vorlage // Millimeter-Referenzentwurf (designWidth 170mm) gezeichnet und dann mit EINEM einzigen,
// schon "mm" nutzt; ≈2.83 bei "pt") - ohne das würde das Raster bei einer in "pt" deklarierten // gemeinsamen Faktor "k" in die Koordinaten-Einheit der Vorlage skaliert - k ist bewusst für
// Vorlage nur rund ein Drittel der vorgesehenen Größe erreichen. Schriftgrößen (FontSize) sind // X- und Y-Richtung identisch (kein separates a_x/a_y), sonst verzerren die Tageskacheln je
// davon unabhängig immer echte Punktgrößen. contentWidth ist unabhängig von der Größenwahl // nachdem, ob Breite oder Höhe der realen Box stärker vom Referenzentwurf abweicht.
// fest - nur Schrift/Zellenhöhe skalieren mit "Größe". Würde die Breite mit skalieren, würde var designWidth = 170f;
// "Groß" (scale=1) exakt die Skript-Box ausfüllen, "Klein"/"Normal" aber nur einen Teil davon var designMonthGapX = 6f;
// - 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 first = options.NormalizedStartMonth;
var monthCount = options.NormalizedMonthCount; var monthCount = options.NormalizedMonthCount;
// Wochenanzahl je Monat vorab ermitteln, unabhängig von "Größe" - nötig, um VOR dem // 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 // eigentlichen Zeichnen zu wissen, wie viel Höhe das Raster im Referenzentwurf braucht.
// 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; var maxWeeks = 0;
for (var monthIndex = 0; monthIndex < monthCount; monthIndex++) for (var monthIndex = 0; monthIndex < monthCount; monthIndex++)
{ {
@@ -68,39 +60,45 @@ public static class StudentAttendanceCalendarDrawingBuilder
var offset = ((int)current.DayOfWeek + 6) % 7; var offset = ((int)current.DayOfWeek + 6) % 7;
maxWeeks = Math.Max(maxWeeks, (int)Math.Ceiling((offset + days.Count) / 7d)); 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): // Höhe des Referenzentwurfs bei k=1 (mm): Titel+Name+Abstand (26) + Monats-/Wochentagskopf
// Titel+Name+Abstand (26) + Monats-/Wochentagskopf (20) + maxWeeks Wochenzeilen (10 je Woche) // (20) + maxWeeks Wochenzeilen (10 je Woche) + monthGapX (6) + Legende/Nachlauf (9). Muss mit
// + Legende/Nachlauf (9), alles mit scale*millimeterScale skaliert, plus der einmalige, // der Höhenberechnung am Ende dieser Methode ("y") übereinstimmen.
// größenunabhängige monthGapX-Abstand (6*millimeterScale). var designHeight = 26f + 20f + 10f * maxWeeks + designMonthGapX + 9f;
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; // a_x/a_y: wie viel größer/kleiner als der Referenzentwurf die reale Box in jeweils einer
// War 10mm - bei 3 Monaten nebeneinander größer als eine einzelne Tageskachel und zog damit // Richtung ist. Eine DRAWBOX bricht anders als FLOWDRAWBOX nicht automatisch auf Folgeseiten
// spürbar Platz von den Kacheln ab, ohne selbst als Inhalt wahrgenommen zu werden. // um (siehe DrawingElementRenderer.RenderFixed/Slice) - Inhalt, der contentHeight
var monthGapX = 6f * millimeterScale; // überschreitet, wird von QuestTemplateRenderer am unteren Rand stillschweigend
var monthWidth = (contentWidth - monthGapX * (monthCount - 1)) / monthCount; // abgeschnitten. a = min(a_x, a_y, "Größe") verkleinert nötigenfalls gleichmäßig, statt die
// Boxgrenzen zu verletzen oder die Kacheln durch getrenntes Skalieren zu verzerren.
var scaleForWidth = contentWidth / (designWidth * millimeterScale);
var scaleForHeight = float.IsFinite(contentHeight)
? contentHeight / (designHeight * millimeterScale)
: float.PositiveInfinity;
var finalScale = Math.Max(.35f, Math.Min(requestedScale, Math.Min(scaleForWidth, scaleForHeight)));
var k = finalScale * millimeterScale;
var cellHeight = 10 * k;
// War 10mm im Referenzentwurf - 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 = designMonthGapX * k;
var monthWidth = (designWidth * k - 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 1mm im
// mm, also bis zu 1mm auf jeder Seite - bei einer ~7mm breiten Zelle ein gutes Viertel // Referenzentwurf - bei einer ~7mm breiten Zelle ein gutes Viertel reiner Leerraum). Die
// reiner Leerraum). Die Kachel selbst füllt dadurch ihren Rasterplatz sichtbar besser aus. // Kachel selbst füllt dadurch ihren Rasterplatz sichtbar besser aus.
var cellInset = scale * .4f * millimeterScale; var cellInset = .4f * k;
var cellCornerRadius = Math.Min(cellWidth, cellHeight) * .2f; var cellCornerRadius = Math.Min(cellWidth, cellHeight) * .2f;
var commands = new List<DrawingCommand>(); var commands = new List<DrawingCommand>();
var contentWidthDesign = designWidth * k;
var y = 0f; var y = 0f;
commands.Add(new DrawStringEx(0, y, 14 * scale * millimeterScale, contentWidth, "Anwesenheit", commands.Add(new DrawStringEx(0, y, 14 * k, contentWidthDesign, "Anwesenheit",
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true)); DrawingTextAlignment.AlignLeft, 11 * finalScale, Color: "#1F2937", Bold: true));
y += 14 * scale * millimeterScale; y += 14 * k;
commands.Add(new DrawStringEx(0, y, 10 * scale * millimeterScale, contentWidth, studentName, commands.Add(new DrawStringEx(0, y, 10 * k, contentWidthDesign, studentName,
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280")); DrawingTextAlignment.AlignLeft, 8 * finalScale, Color: "#6B7280"));
y += 10 * scale * millimeterScale + 2 * scale * millimeterScale; y += 10 * k + 2 * k;
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;
for (var monthIndex = 0; monthIndex < monthCount; monthIndex++) for (var monthIndex = 0; monthIndex < monthCount; monthIndex++)
@@ -111,15 +109,15 @@ public static class StudentAttendanceCalendarDrawingBuilder
var offset = ((int)current.DayOfWeek + 6) % 7; var offset = ((int)current.DayOfWeek + 6) % 7;
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 * k, monthWidth,
current.ToString("MMMM yyyy", CultureInfo.GetCultureInfo("de-DE")), current.ToString("MMMM yyyy", CultureInfo.GetCultureInfo("de-DE")),
DrawingTextAlignment.AlignLeft, 9 * scale, Color: "#374151", Bold: true)); DrawingTextAlignment.AlignLeft, 9 * finalScale, Color: "#374151", Bold: true));
monthY += 11 * scale * millimeterScale; monthY += 11 * k;
for (var column = 0; column < 7; column++) for (var column = 0; column < 7; column++)
commands.Add(new DrawStringEx(xOffset + column * cellWidth, monthY, 9 * scale * millimeterScale, commands.Add(new DrawStringEx(xOffset + column * cellWidth, monthY, 9 * k,
cellWidth, weekdays[column], DrawingTextAlignment.AlignCenter, 7 * scale, Color: "#6B7280", cellWidth, weekdays[column], DrawingTextAlignment.AlignCenter, 7 * finalScale, Color: "#6B7280",
Bold: true)); Bold: true));
monthY += 9 * scale * millimeterScale; monthY += 9 * k;
foreach (var day in days) foreach (var day in days)
{ {
@@ -131,18 +129,17 @@ public static class StudentAttendanceCalendarDrawingBuilder
commands.Add(new DrawRoundedRectangle(x + cellInset, cellY, cellWidth - 2 * cellInset, commands.Add(new DrawRoundedRectangle(x + cellInset, cellY, cellWidth - 2 * cellInset,
cellHeight - cellInset, cellCornerRadius, "#D1D5DB", .35f, cellHeight - cellInset, cellCornerRadius, "#D1D5DB", .35f,
day.HasSignal ? day.SignalColorHex : "#FFFFFF")); day.HasSignal ? day.SignalColorHex : "#FFFFFF"));
commands.Add(new DrawStringEx(x, cellY + scale * millimeterScale, commands.Add(new DrawStringEx(x, cellY + k, cellHeight - 2 * k, cellWidth,
cellHeight - 2 * scale * millimeterScale, cellWidth, day.HasSignal ? day.SignalCode : day.DayNumber, DrawingTextAlignment.AlignCenter, 7 * finalScale,
day.HasSignal ? day.SignalCode : day.DayNumber, DrawingTextAlignment.AlignCenter, 7 * scale,
Color: day.HasSignal ? "#FFFFFF" : "#374151", Bold: day.HasSignal)); Color: day.HasSignal ? "#FFFFFF" : "#374151", Bold: day.HasSignal));
} }
} }
y = gridStartY + 11 * scale * millimeterScale + 9 * scale * millimeterScale + maxWeeks * cellHeight + monthGapX; y = gridStartY + 11 * k + 9 * k + maxWeeks * cellHeight + monthGapX;
commands.Add(new DrawStringEx(0, y, 8 * scale * millimeterScale, contentWidth, commands.Add(new DrawStringEx(0, y, 8 * k, contentWidthDesign,
"U unentschuldigt · A abwesend · V verspätet · E entschuldigt · ! Klassenbuch", "U unentschuldigt · A abwesend · V verspätet · E entschuldigt · ! Klassenbuch",
DrawingTextAlignment.AlignLeft, 6.5f * scale, Color: "#6B7280")); DrawingTextAlignment.AlignLeft, 6.5f * finalScale, Color: "#6B7280"));
return new DrawingValue(commands, y + 9 * scale * millimeterScale); return new DrawingValue(commands, y + 9 * k);
} }
} }