diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index 1e3a4eb..50ece9c 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -127,6 +127,33 @@ public sealed class ClassTeacherViewModelsTests 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().First(); + var breiteKachel = sehrBreiteBox.Commands.OfType().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 // der Vorlage deklarierte Höhe überschreitet, wird von QuestTemplateRenderer stillschweigend am // unteren Rand abgeschnitten (kein Fehler, keine Warnung). "Größe" muss deshalb automatisch so diff --git a/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs b/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs index 309d835..a6077d5 100644 --- a/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs +++ b/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs @@ -39,26 +39,18 @@ public static class StudentAttendanceCalendarDrawingBuilder }; // 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). + // LetterPlaceholderBuilder.FindDeclaredDrawingBox). Das gesamte Raster wird in einem festen + // Millimeter-Referenzentwurf (designWidth 170mm) gezeichnet und dann mit EINEM einzigen, + // gemeinsamen Faktor "k" in die Koordinaten-Einheit der Vorlage skaliert - k ist bewusst für + // X- und Y-Richtung identisch (kein separates a_x/a_y), sonst verzerren die Tageskacheln je + // nachdem, ob Breite oder Höhe der realen Box stärker vom Referenzentwurf abweicht. + var designWidth = 170f; + var designMonthGapX = 6f; 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. + // eigentlichen Zeichnen zu wissen, wie viel Höhe das Raster im Referenzentwurf braucht. var maxWeeks = 0; for (var monthIndex = 0; monthIndex < monthCount; monthIndex++) { @@ -68,39 +60,45 @@ public static class StudentAttendanceCalendarDrawingBuilder 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; - } + // Höhe des Referenzentwurfs bei k=1 (mm): Titel+Name+Abstand (26) + Monats-/Wochentagskopf + // (20) + maxWeeks Wochenzeilen (10 je Woche) + monthGapX (6) + Legende/Nachlauf (9). Muss mit + // der Höhenberechnung am Ende dieser Methode ("y") übereinstimmen. + var designHeight = 26f + 20f + 10f * maxWeeks + designMonthGapX + 9f; - 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 monthWidth = (contentWidth - monthGapX * (monthCount - 1)) / monthCount; + // a_x/a_y: wie viel größer/kleiner als der Referenzentwurf die reale Box in jeweils einer + // Richtung ist. 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. 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; - // Kachel-Zwischenraum (Trennung zu Nachbarzellen) deutlich knapper als vorher (war "scale" - // mm, also bis zu 1mm auf jeder Seite - bei einer ~7mm breiten Zelle ein gutes Viertel - // reiner Leerraum). Die Kachel selbst füllt dadurch ihren Rasterplatz sichtbar besser aus. - var cellInset = scale * .4f * millimeterScale; + // Kachel-Zwischenraum (Trennung zu Nachbarzellen) deutlich knapper als vorher (war 1mm im + // Referenzentwurf - bei einer ~7mm breiten Zelle ein gutes Viertel reiner Leerraum). Die + // Kachel selbst füllt dadurch ihren Rasterplatz sichtbar besser aus. + var cellInset = .4f * k; var cellCornerRadius = Math.Min(cellWidth, cellHeight) * .2f; var commands = new List(); + var contentWidthDesign = designWidth * k; var y = 0f; - commands.Add(new DrawStringEx(0, y, 14 * scale * millimeterScale, contentWidth, "Anwesenheit", - DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true)); - y += 14 * scale * millimeterScale; - commands.Add(new DrawStringEx(0, y, 10 * scale * millimeterScale, contentWidth, studentName, - DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280")); - y += 10 * scale * millimeterScale + 2 * scale * millimeterScale; + commands.Add(new DrawStringEx(0, y, 14 * k, contentWidthDesign, "Anwesenheit", + DrawingTextAlignment.AlignLeft, 11 * finalScale, Color: "#1F2937", Bold: true)); + y += 14 * k; + commands.Add(new DrawStringEx(0, y, 10 * k, contentWidthDesign, studentName, + DrawingTextAlignment.AlignLeft, 8 * finalScale, Color: "#6B7280")); + y += 10 * k + 2 * k; var weekdays = new[] { "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So" }; var gridStartY = y; for (var monthIndex = 0; monthIndex < monthCount; monthIndex++) @@ -111,15 +109,15 @@ public static class StudentAttendanceCalendarDrawingBuilder var offset = ((int)current.DayOfWeek + 6) % 7; var xOffset = monthIndex * (monthWidth + monthGapX); 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")), - DrawingTextAlignment.AlignLeft, 9 * scale, Color: "#374151", Bold: true)); - monthY += 11 * scale * millimeterScale; + DrawingTextAlignment.AlignLeft, 9 * finalScale, Color: "#374151", Bold: true)); + monthY += 11 * k; for (var column = 0; column < 7; column++) - commands.Add(new DrawStringEx(xOffset + column * cellWidth, monthY, 9 * scale * millimeterScale, - cellWidth, weekdays[column], DrawingTextAlignment.AlignCenter, 7 * scale, Color: "#6B7280", + commands.Add(new DrawStringEx(xOffset + column * cellWidth, monthY, 9 * k, + cellWidth, weekdays[column], DrawingTextAlignment.AlignCenter, 7 * finalScale, Color: "#6B7280", Bold: true)); - monthY += 9 * scale * millimeterScale; + monthY += 9 * k; foreach (var day in days) { @@ -131,18 +129,17 @@ public static class StudentAttendanceCalendarDrawingBuilder commands.Add(new DrawRoundedRectangle(x + cellInset, cellY, cellWidth - 2 * cellInset, cellHeight - cellInset, cellCornerRadius, "#D1D5DB", .35f, day.HasSignal ? day.SignalColorHex : "#FFFFFF")); - commands.Add(new DrawStringEx(x, cellY + scale * millimeterScale, - cellHeight - 2 * scale * millimeterScale, cellWidth, - day.HasSignal ? day.SignalCode : day.DayNumber, DrawingTextAlignment.AlignCenter, 7 * scale, + commands.Add(new DrawStringEx(x, cellY + k, cellHeight - 2 * k, cellWidth, + day.HasSignal ? day.SignalCode : day.DayNumber, DrawingTextAlignment.AlignCenter, 7 * finalScale, 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", - DrawingTextAlignment.AlignLeft, 6.5f * scale, Color: "#6B7280")); - return new DrawingValue(commands, y + 9 * scale * millimeterScale); + DrawingTextAlignment.AlignLeft, 6.5f * finalScale, Color: "#6B7280")); + return new DrawingValue(commands, y + 9 * k); } }