diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index 74fd0c0..1e3a4eb 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -127,6 +127,25 @@ public sealed class ClassTeacherViewModelsTests 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] public void ElternbriefFehltage_ListetDatumUmfangUndEntschuldigungsstatus() { diff --git a/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs index 1f1fe95..9a8c376 100644 --- a/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs @@ -62,7 +62,7 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)); var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Lena Beispiel", new DateOnly(2026, 9, 1), [], []); 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"); Assert.True(vm.UsesAttendanceCalendar); @@ -100,7 +100,7 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, 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.Equal(250, receivedWidth); @@ -129,7 +129,7 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, 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.Equal(487, receivedWidth); @@ -145,7 +145,7 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable AttendanceCalendarOptions? requested = null; var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), - (_, _, _) => new DrawingValue([], 0), attendanceDataRefresher: (options, _) => + (_, _, _, _) => new DrawingValue([], 0), attendanceDataRefresher: (options, _) => { requested = options; return Task.CompletedTask; }); await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions( @@ -165,7 +165,7 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)); var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), - (_, _, _) => new DrawingValue([], 0), + (_, _, _, _) => new DrawingValue([], 0), attendanceDataRefresher: (_, _) => throw new WebUntisIntegrationException("Keine Verbindung.")); await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions( @@ -185,7 +185,7 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1), []); var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), - attendanceCalendarFactory: null, absenceDayListFactory: (_, _, _) => drawing); + attendanceCalendarFactory: null, absenceDayListFactory: (_, _, _, _) => drawing); Assert.False(vm.UsesAttendanceCalendar); Assert.True(vm.UsesAbsenceDayList); diff --git a/LehrerApp.Desktop/Services/LetterDialogs.cs b/LehrerApp.Desktop/Services/LetterDialogs.cs index a1e0aac..cbe7950 100644 --- a/LehrerApp.Desktop/Services/LetterDialogs.cs +++ b/LehrerApp.Desktop/Services/LetterDialogs.cs @@ -20,9 +20,9 @@ public static class LetterDialogs App.Services.GetRequiredService(), App.Services.GetRequiredService(), App.Services.GetRequiredService(), - (options, contentWidth, millimeterScale) => App.Services.GetRequiredService() - .Build(student, options, contentWidth, millimeterScale), - (options, contentWidth, millimeterScale) => App.Services.GetRequiredService() + (options, contentWidth, millimeterScale, contentHeight) => App.Services.GetRequiredService() + .Build(student, options, contentWidth, millimeterScale, contentHeight), + (options, contentWidth, millimeterScale, contentHeight) => App.Services.GetRequiredService() .BuildAbsenceDayList(student, options, contentWidth, millimeterScale), RefreshAttendanceDataAsync); var dialog = new CreateLetterDialog { DataContext = vm }; diff --git a/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs b/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs index 8596a24..fd71e90 100644 --- a/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs +++ b/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs @@ -51,12 +51,16 @@ public static class LetterPlaceholderBuilder _ => false, }; - /// Tatsächliche DRAWBOX/FLOWDRAWBOX-Breite eines Platzhalters (in der Maßeinheit der + /// 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 /// 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). - 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. + 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); } } diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs index 429bfb2..8c3dd46 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs @@ -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 ?? []) diff --git a/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs b/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs index 4552c83..309d835 100644 --- a/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs +++ b/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs @@ -29,32 +29,63 @@ public static class StudentAttendanceCalendarDrawingBuilder public static DrawingValue Build(string studentName, AttendanceCalendarOptions options, IReadOnlyList absences, IReadOnlyList 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, diff --git a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs index 873334b..e1406f1 100644 --- a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs @@ -12,8 +12,8 @@ public partial class CreateLetterDialogViewModel : ObservableObject private readonly Student _student; private readonly TemplateStore _templates; private readonly ITemplateRenderer _renderer; - private readonly Func? _attendanceCalendarFactory; - private readonly Func? _absenceDayListFactory; + private readonly Func? _attendanceCalendarFactory; + private readonly Func? _absenceDayListFactory; private readonly Func? _attendanceDataRefresher; private AttendanceCalendarOptions _attendanceCalendarOptions = new( 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, IGroupMembershipRepository memberships, IGroupRepository groups, - Func? attendanceCalendarFactory = null, - Func? absenceDayListFactory = null, + Func? attendanceCalendarFactory = null, + Func? absenceDayListFactory = null, Func? attendanceDataRefresher = null) { _student = student; _templates = templates; _renderer = renderer; @@ -143,9 +143,11 @@ public partial class CreateLetterDialogViewModel : ObservableObject _student, SelectedContact?.Model, SelectedGroup?.Model, DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName, UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions, - calendarBox.Width, calendarBox.MillimeterScale) : null, + calendarBox.Width, calendarBox.MillimeterScale, + calendarBox.IsFixed ? calendarBox.Height : float.PositiveInfinity) : null, 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); foreach (var custom in CustomPlaceholders) values[custom.Name] = custom.ToPlaceholderValue(); 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. 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; try { return LetterPlaceholderBuilder.FindDeclaredDrawingBox(_templates.Load(SelectedTemplate.Model), placeholderName) ?? fallback; } catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { return fallback; }