fix: Kalenderraster mit einem gemeinsamen Faktor statt unabhängig in x/y skalieren
CI / build-and-test (push) Waiting to run
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:
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user