Compare commits
10
Commits
0b5cfc5522
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4542f263fc | ||
|
|
86a97d91a7 | ||
|
|
763d7744be | ||
|
|
77fe9b1c79 | ||
|
|
92e497b54f | ||
|
|
861f2c76ca | ||
|
|
ef3e9dbbb6 | ||
|
|
03a5e0dd9c | ||
|
|
6dccf96039 | ||
|
|
6e57bf407d |
@@ -127,6 +127,25 @@ public sealed class ClassTeacherViewModelsTests
|
|||||||
Assert.True(large.ContentHeight > small.ContentHeight);
|
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]
|
[Fact]
|
||||||
public void ElternbriefFehltage_ListetDatumUmfangUndEntschuldigungsstatus()
|
public void ElternbriefFehltage_ListetDatumUmfangUndEntschuldigungsstatus()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -56,17 +56,17 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AdvancedContent_Anwesenheitskalender_WirdAlsDrawingGerendert()
|
public async Task AdvancedContent_Anwesenheitskalender_WirdAlsDrawingGerendert()
|
||||||
{
|
{
|
||||||
var store = StoreWithTemplate(new PlaceholderDefinition(
|
var store = StoreWithTemplate(new PlaceholderDefinition(
|
||||||
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
|
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
|
||||||
var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Lena Beispiel", new DateOnly(2026, 9, 1), [], []);
|
var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Lena Beispiel", new DateOnly(2026, 9, 1), [], []);
|
||||||
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
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");
|
var output = Path.Combine(_directory, "Kalender.pdf");
|
||||||
|
|
||||||
Assert.True(vm.UsesAttendanceCalendar);
|
Assert.True(vm.UsesAttendanceCalendar);
|
||||||
vm.SetAttendanceCalendarOptions(new AttendanceCalendarOptions(
|
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
|
||||||
new DateOnly(2026, 8, 19), 3, AttendanceCalendarSize.Large));
|
new DateOnly(2026, 8, 19), 3, AttendanceCalendarSize.Large));
|
||||||
Assert.True(vm.AttendanceCalendarConfigured);
|
Assert.True(vm.AttendanceCalendarConfigured);
|
||||||
Assert.Contains("August 2026", vm.AttendanceCalendarSummary);
|
Assert.Contains("August 2026", vm.AttendanceCalendarSummary);
|
||||||
@@ -76,6 +76,106 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
|
|||||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression: Der DrawingValue-Builder hat früher immer eine feste Breite (170) angenommen,
|
||||||
|
// unabhängig davon, wie breit die DRAWBOX in der jeweiligen Vorlage tatsächlich deklariert war -
|
||||||
|
// bei breiteren (oder in "pt" statt "mm" deklarierten) Boxen blieb dadurch ein Großteil der
|
||||||
|
// eigentlich verfügbaren Fläche ungenutzt leer (sichtbar in echten Elternbriefen als "Luft"
|
||||||
|
// rechts/unterhalb des Kalenders). Der Dialog muss die real deklarierte Breite ermitteln und an
|
||||||
|
// den Builder weiterreichen.
|
||||||
|
[Fact]
|
||||||
|
public void AdvancedContent_Anwesenheitskalender_NutztDieImLayoutDeklarierteBoxbreite()
|
||||||
|
{
|
||||||
|
var manifest = new TemplateManifest
|
||||||
|
{
|
||||||
|
Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief",
|
||||||
|
Placeholders = [new(StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)],
|
||||||
|
};
|
||||||
|
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
|
||||||
|
TemplatePackage.Create(source, manifest,
|
||||||
|
$"PAGE 210 297 mm\nDRAWBOX 20 20 250 80 ${StudentAttendanceCalendarDrawingBuilder.PlaceholderName}",
|
||||||
|
new Dictionary<string, byte[]>());
|
||||||
|
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
|
||||||
|
store.Import(source);
|
||||||
|
float? receivedWidth = null; float? receivedMillimeterScale = null;
|
||||||
|
|
||||||
|
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); });
|
||||||
|
|
||||||
|
Assert.True(vm.UsesAttendanceCalendar);
|
||||||
|
Assert.Equal(250, receivedWidth);
|
||||||
|
Assert.Equal(1, receivedMillimeterScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: Eine reale, aus einem Word-Dokument übernommene Vorlage deklariert die Seite in
|
||||||
|
// "pt" statt "mm" (PAGE ... pt). Ohne Umrechnung würde das mm-entworfene Kalenderraster dort nur
|
||||||
|
// rund ein Drittel der vorgesehenen physischen Größe erreichen (1 "mm-Einheit" würde als 1pt statt
|
||||||
|
// als ~2.83pt gerendert).
|
||||||
|
[Fact]
|
||||||
|
public void AdvancedContent_Anwesenheitskalender_RechnetBoxbreiteBeiPunktBasierterVorlageUm()
|
||||||
|
{
|
||||||
|
var manifest = new TemplateManifest
|
||||||
|
{
|
||||||
|
Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief",
|
||||||
|
Placeholders = [new(StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)],
|
||||||
|
};
|
||||||
|
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
|
||||||
|
TemplatePackage.Create(source, manifest,
|
||||||
|
$"PAGE 595.32 841.92 pt\nDRAWBOX 71 350 487 177 ${StudentAttendanceCalendarDrawingBuilder.PlaceholderName}",
|
||||||
|
new Dictionary<string, byte[]>());
|
||||||
|
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
|
||||||
|
store.Import(source);
|
||||||
|
float? receivedWidth = null; float? receivedMillimeterScale = null;
|
||||||
|
|
||||||
|
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); });
|
||||||
|
|
||||||
|
Assert.True(vm.UsesAttendanceCalendar);
|
||||||
|
Assert.Equal(487, receivedWidth);
|
||||||
|
Assert.NotNull(receivedMillimeterScale);
|
||||||
|
Assert.Equal(2.8346f, receivedMillimeterScale!.Value, 0.001f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AttendanceKalenderKonfigurieren_LoestGezieltenDatenAbrufFuerDenZeitraumAus()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition(
|
||||||
|
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
|
||||||
|
AttendanceCalendarOptions? requested = null;
|
||||||
|
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||||
|
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
|
||||||
|
(_, _, _, _) => new DrawingValue([], 0), attendanceDataRefresher: (options, _) =>
|
||||||
|
{ requested = options; return Task.CompletedTask; });
|
||||||
|
|
||||||
|
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
|
||||||
|
new DateOnly(2026, 9, 1), 2, AttendanceCalendarSize.Medium));
|
||||||
|
|
||||||
|
Assert.NotNull(requested);
|
||||||
|
Assert.Equal(new DateOnly(2026, 9, 1), requested!.NormalizedStartMonth);
|
||||||
|
Assert.Equal(2, requested.NormalizedMonthCount);
|
||||||
|
Assert.False(vm.IsRefreshingAttendanceData);
|
||||||
|
Assert.Equal("", vm.AttendanceRefreshError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AttendanceKalenderKonfigurieren_ZeigtFehlerBeiFehlgeschlagenemAbrufAnStattZuBlockieren()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition(
|
||||||
|
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
|
||||||
|
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||||
|
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
|
||||||
|
(_, _, _, _) => new DrawingValue([], 0),
|
||||||
|
attendanceDataRefresher: (_, _) => throw new WebUntisIntegrationException("Keine Verbindung."));
|
||||||
|
|
||||||
|
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
|
||||||
|
new DateOnly(2026, 9, 1), 1, AttendanceCalendarSize.Medium));
|
||||||
|
|
||||||
|
Assert.True(vm.AttendanceCalendarConfigured);
|
||||||
|
Assert.Contains("Keine Verbindung.", vm.AttendanceRefreshError);
|
||||||
|
Assert.False(vm.IsRefreshingAttendanceData);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AdvancedContent_Fehlzeitenliste_AktiviertKonfigurationsschritt()
|
public void AdvancedContent_Fehlzeitenliste_AktiviertKonfigurationsschritt()
|
||||||
{
|
{
|
||||||
@@ -85,7 +185,7 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
|
|||||||
new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1), []);
|
new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1), []);
|
||||||
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||||
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
|
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
|
||||||
attendanceCalendarFactory: null, absenceDayListFactory: _ => drawing);
|
attendanceCalendarFactory: null, absenceDayListFactory: (_, _, _, _) => drawing);
|
||||||
|
|
||||||
Assert.False(vm.UsesAttendanceCalendar);
|
Assert.False(vm.UsesAttendanceCalendar);
|
||||||
Assert.True(vm.UsesAbsenceDayList);
|
Assert.True(vm.UsesAbsenceDayList);
|
||||||
@@ -93,6 +193,108 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
|
|||||||
Assert.True(vm.CanGenerate);
|
Assert.True(vm.CanGenerate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EigenerPlatzhalter_KannImDialogEingegebenWerden()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||||
|
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||||
|
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
|
||||||
|
new PlaceholderDefinition("Betreff", PlaceholderType.Text, true));
|
||||||
|
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||||
|
vm.LetterText = "Dies ist der Inhalt.";
|
||||||
|
|
||||||
|
var betreff = Assert.Single(vm.CustomPlaceholders);
|
||||||
|
Assert.Equal("Betreff", betreff.Name);
|
||||||
|
Assert.False(vm.CanGenerate);
|
||||||
|
Assert.Contains(vm.Issues, i => i.Message.Contains("Betreff", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
betreff.TextValue = "Wichtiger Termin";
|
||||||
|
var output = Path.Combine(_directory, "MitBetreff.pdf");
|
||||||
|
|
||||||
|
Assert.True(vm.CanGenerate);
|
||||||
|
Assert.True(vm.Generate(output));
|
||||||
|
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StudentName_WirdAutomatischMitVollemNamenBefuellt()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||||
|
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||||
|
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
|
||||||
|
new PlaceholderDefinition("Student.Name", PlaceholderType.Text, true));
|
||||||
|
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||||
|
vm.LetterText = "Dies ist der Inhalt.";
|
||||||
|
|
||||||
|
Assert.Empty(vm.CustomPlaceholders);
|
||||||
|
Assert.True(vm.CanGenerate);
|
||||||
|
var output = Path.Combine(_directory, "StudentName.pdf");
|
||||||
|
Assert.True(vm.Generate(output));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Anrede_WirdAusKontaktVorbelegtUndBleibtEditierbar()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||||
|
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||||
|
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
|
||||||
|
var vm = Build(StudentWithContact(null), store);
|
||||||
|
vm.LetterText = "Dies ist der Inhalt.";
|
||||||
|
|
||||||
|
Assert.Equal("", vm.Anrede);
|
||||||
|
Assert.False(vm.CanGenerate);
|
||||||
|
Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
vm.Anrede = "Sehr geehrte Familie Beispiel,";
|
||||||
|
var output = Path.Combine(_directory, "AnredeManuell.pdf");
|
||||||
|
|
||||||
|
Assert.True(vm.CanGenerate);
|
||||||
|
Assert.True(vm.Generate(output));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddressPreview_FolgtDemGewaehltenKontaktUndAktualisiertSichBeimWechsel()
|
||||||
|
{
|
||||||
|
var student = new Student
|
||||||
|
{
|
||||||
|
FirstName = "Lena", LastName = "Beispiel",
|
||||||
|
Contacts =
|
||||||
|
[
|
||||||
|
new Contact { Name = "Frau Beispiel", Relation = "Mutter", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" },
|
||||||
|
new Contact { Name = "Herr Beispiel", Relation = "Vater", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var vm = Build(student, StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
|
||||||
|
|
||||||
|
Assert.Equal(2, vm.Contacts.Count);
|
||||||
|
Assert.Contains("Frau Beispiel", vm.AddressPreview);
|
||||||
|
Assert.Contains("Erste Str. 1", vm.AddressPreview);
|
||||||
|
|
||||||
|
vm.SelectedContact = vm.Contacts.Single(c => c.Model.Name == "Herr Beispiel");
|
||||||
|
|
||||||
|
Assert.Contains("Herr Beispiel", vm.AddressPreview);
|
||||||
|
Assert.Contains("Zweite Str. 2", vm.AddressPreview);
|
||||||
|
Assert.DoesNotContain("Frau Beispiel", vm.AddressPreview);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectContactDialog_StartetBeimAktuellenKontaktUndAktualisiertVorschau()
|
||||||
|
{
|
||||||
|
var contacts = new List<LetterContactChoice>
|
||||||
|
{
|
||||||
|
new(new Contact { Name = "Frau Beispiel", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" }),
|
||||||
|
new(new Contact { Name = "Herr Beispiel", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" }),
|
||||||
|
};
|
||||||
|
var dialogVm = new SelectContactDialogViewModel(contacts, contacts[1]);
|
||||||
|
|
||||||
|
Assert.Same(contacts[1], dialogVm.SelectedContact);
|
||||||
|
Assert.Contains("Zweite Str. 2", dialogVm.AddressPreview);
|
||||||
|
|
||||||
|
dialogVm.SelectedContact = contacts[0];
|
||||||
|
|
||||||
|
Assert.Contains("Erste Str. 1", dialogVm.AddressPreview);
|
||||||
|
}
|
||||||
|
|
||||||
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
|
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
|
||||||
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
|
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
|
||||||
|
|
||||||
|
|||||||
@@ -18,4 +18,18 @@ public sealed class UntisNameMatchingTests
|
|||||||
[InlineData("Ben Schmidt", "")]
|
[InlineData("Ben Schmidt", "")]
|
||||||
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) =>
|
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) =>
|
||||||
Assert.False(UntisNameMatching.NamesMatch(a, b));
|
Assert.False(UntisNameMatching.NamesMatch(a, b));
|
||||||
|
|
||||||
|
// Regression: Student.FullName liefert "Nachname, Vorname" (mit Komma) für Anzeigezwecke.
|
||||||
|
// Wird dieser String direkt an NamesMatch übergeben, bleibt das Komma am Wort kleben
|
||||||
|
// ("gerste," != "gerste") und der Abgleich gegen WebUntis-Namen (immer ohne Komma) schlägt
|
||||||
|
// fehl - genau das ließ den Anwesenheitskalender/die Fehlzeitenliste im Elternbrief leer
|
||||||
|
// bleiben, obwohl echte Fehlzeiten vorlagen. Aufrufer müssen deshalb "Vorname Nachname" ohne
|
||||||
|
// Komma bilden (siehe StudentAttendanceCalendarDrawingBuilder.StudentAttendanceCalendarService
|
||||||
|
// und ClassTeacherOverviewViewModel.cs:1173), statt Student.FullName direkt zu verwenden.
|
||||||
|
[Fact]
|
||||||
|
public void NamesMatch_KommaGetrennterAnzeigename_PasstNichtOhneUmformung()
|
||||||
|
{
|
||||||
|
Assert.False(UntisNameMatching.NamesMatch("Gerste, Amelia", "Gerste Amelia"));
|
||||||
|
Assert.True(UntisNameMatching.NamesMatch("Amelia Gerste", "Gerste Amelia"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using LehrerApp.Templating;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class _TempFlowCheck2
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void MarkerTest()
|
||||||
|
{
|
||||||
|
var outDir = @"C:\Users\SHedt\AppData\Local\Temp\claude\d--source-LehrerApp\fb3681df-caaf-4f2d-9996-e343e44f5554\scratchpad";
|
||||||
|
Directory.CreateDirectory(outDir);
|
||||||
|
|
||||||
|
var commands = new List<DrawingCommand>
|
||||||
|
{
|
||||||
|
new DrawStringEx(0, 0, 12, 170, "TOP-0", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
|
||||||
|
new DrawStringEx(0, 90, 12, 170, "NEARBOTTOM-90", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
|
||||||
|
new DrawStringEx(0, 105, 12, 170, "AFTERBOUNDARY-105", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
|
||||||
|
new DrawStringEx(0, 190, 12, 170, "BOTTOM-190", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
|
||||||
|
};
|
||||||
|
var drawing = new DrawingValue(commands, 200);
|
||||||
|
|
||||||
|
var manifest = new TemplateManifest
|
||||||
|
{
|
||||||
|
Id = "marker", Name = "Marker",
|
||||||
|
Placeholders = [new("Marker", PlaceholderType.Drawing, true)],
|
||||||
|
};
|
||||||
|
var layout = "PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 100 $Marker\n";
|
||||||
|
var loadedLayout = new LayoutParser().Parse(layout);
|
||||||
|
var loaded = new LoadedTemplate(manifest, loadedLayout, new Dictionary<string, byte[]>());
|
||||||
|
var provider = new FakeProvider(new Dictionary<string, PlaceholderValue> { ["Marker"] = drawing });
|
||||||
|
|
||||||
|
var pngs = new QuestTemplateRenderer().RenderPagesToPng(loaded, provider, dpi: 150);
|
||||||
|
for (var i = 0; i < pngs.Count; i++)
|
||||||
|
File.WriteAllBytes(Path.Combine(outDir, $"marker2-{i}.png"), pngs[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
|
||||||
|
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }
|
||||||
|
}
|
||||||
@@ -20,12 +20,30 @@ public static class LetterDialogs
|
|||||||
App.Services.GetRequiredService<ITemplateRenderer>(),
|
App.Services.GetRequiredService<ITemplateRenderer>(),
|
||||||
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||||
App.Services.GetRequiredService<IGroupRepository>(),
|
App.Services.GetRequiredService<IGroupRepository>(),
|
||||||
options => App.Services.GetRequiredService<StudentAttendanceCalendarService>().Build(student, options),
|
(options, contentWidth, millimeterScale, contentHeight) => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
|
||||||
options => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
|
.Build(student, options, contentWidth, millimeterScale, contentHeight),
|
||||||
.BuildAbsenceDayList(student, options));
|
(options, contentWidth, millimeterScale, contentHeight) => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
|
||||||
|
.BuildAbsenceDayList(student, options, contentWidth, millimeterScale),
|
||||||
|
RefreshAttendanceDataAsync);
|
||||||
var dialog = new CreateLetterDialog { DataContext = vm };
|
var dialog = new CreateLetterDialog { DataContext = vm };
|
||||||
var path = await dialog.ShowDialog<string?>(owner);
|
var path = await dialog.ShowDialog<string?>(owner);
|
||||||
if (!string.IsNullOrEmpty(path) && File.Exists(path))
|
if (!string.IsNullOrEmpty(path) && File.Exists(path))
|
||||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Holt genau den im Anwesenheitskalender-Dialog gewählten Zeitraum gezielt per
|
||||||
|
/// WebUntis nach (ausgelöst durch den expliziten "Konfigurieren…"-Klick, kein Hintergrundabruf
|
||||||
|
/// beim bloßen Öffnen des Briefdialogs). Damit sieht <see cref="StudentAttendanceCalendarService"/>
|
||||||
|
/// anschließend frische Daten im lokalen Cache, statt stillschweigend "keine Fehltage" zu
|
||||||
|
/// melden, nur weil die Klassenlehrer-Übersicht für diesen Zeitraum noch nie geöffnet wurde.</summary>
|
||||||
|
private static async Task RefreshAttendanceDataAsync(AttendanceCalendarOptions options, CancellationToken token)
|
||||||
|
{
|
||||||
|
var className = App.Services.GetRequiredService<WebUntisSettingsService>().HomeroomClassName;
|
||||||
|
if (string.IsNullOrWhiteSpace(className)) return;
|
||||||
|
var cache = App.Services.GetRequiredService<UntisReportCacheService>();
|
||||||
|
var start = options.NormalizedStartMonth;
|
||||||
|
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
||||||
|
await cache.GetAbsencesAsync(className, start, end, token: token);
|
||||||
|
await cache.GetClassRegisterEventsAsync(className, start, end, token: token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ public static class LetterPlaceholderBuilder
|
|||||||
string letterText, string teacherName, DrawingValue? attendanceCalendar = null,
|
string letterText, string teacherName, DrawingValue? attendanceCalendar = null,
|
||||||
DrawingValue? absenceDays = null)
|
DrawingValue? absenceDays = null)
|
||||||
{
|
{
|
||||||
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
var address = FormatAddress(contact);
|
||||||
var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
|
||||||
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
|
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
|
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
|
||||||
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
|
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||||
["Brieftext"] = new MultilineValue(letterText), ["LehrerName"] = new TextValue(teacherName),
|
["Brieftext"] = new MultilineValue(letterText), ["LehrerName"] = new TextValue(teacherName),
|
||||||
["Student.FirstName"] = new TextValue(student.FirstName), ["Student.LastName"] = new TextValue(student.LastName),
|
["Student.FirstName"] = new TextValue(student.FirstName), ["Student.LastName"] = new TextValue(student.LastName),
|
||||||
|
["Student.Name"] = new TextValue(student.FullName),
|
||||||
["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
|
["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
|
||||||
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
|
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
|
||||||
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
|
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||||
@@ -35,6 +35,14 @@ public static class LetterPlaceholderBuilder
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Mehrzeilige Anschrift (Name/Straße/PLZ Ort) für Adressvorschau im Dialog und
|
||||||
|
/// den <c>Contact.Address</c>-Platzhalter - eine Formatierung für beide Verwendungen.</summary>
|
||||||
|
public static string FormatAddress(Contact? contact)
|
||||||
|
{
|
||||||
|
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
return string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
}
|
||||||
|
|
||||||
public static bool IsEmpty(PlaceholderValue value) => value switch
|
public static bool IsEmpty(PlaceholderValue value) => value switch
|
||||||
{
|
{
|
||||||
TextValue x => string.IsNullOrWhiteSpace(x.Value),
|
TextValue x => string.IsNullOrWhiteSpace(x.Value),
|
||||||
@@ -42,4 +50,35 @@ public static class LetterPlaceholderBuilder
|
|||||||
DrawingValue x => x.ContentHeight <= 0 || x.Commands.Count == 0,
|
DrawingValue x => x.ContentHeight <= 0 || x.Commands.Count == 0,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <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). 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) ??
|
||||||
|
(template.ContinuationLayout is not null ? FindDeclaredDrawingBox(template.ContinuationLayout, placeholderName) : null);
|
||||||
|
|
||||||
|
private static DeclaredDrawingBox? FindDeclaredDrawingBox(TemplateLayout layout, string placeholderName)
|
||||||
|
{
|
||||||
|
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 Width, float Height, bool IsFixed)?)(draw.Width, draw.Height, true),
|
||||||
|
FlowDrawBoxElement flow when flow.Placeholder == placeholderName => (flow.Width, flow.Height, false),
|
||||||
|
_ => 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(box.Value.Width, box.Value.Height, millimeterScale, box.Value.IsFixed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,9 +74,16 @@ public class LetterTemplateTools(
|
|||||||
var group = groupId is { } gid ? groups.GetById(gid) : null;
|
var group = groupId is { } gid ? groups.GetById(gid) : null;
|
||||||
var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today);
|
var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
|
||||||
|
var calendarBox = LetterPlaceholderBuilder.FindDeclaredDrawingBox(loaded,
|
||||||
|
StudentAttendanceCalendarDrawingBuilder.PlaceholderName) ?? new(170, float.PositiveInfinity, 1, false);
|
||||||
|
var absenceDayListBox = LetterPlaceholderBuilder.FindDeclaredDrawingBox(loaded,
|
||||||
|
StudentAbsenceDayListDrawingBuilder.PlaceholderName) ?? new(170, float.PositiveInfinity, 1, false);
|
||||||
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText,
|
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText,
|
||||||
teacherName, attendanceCalendars?.Build(student, date),
|
teacherName, attendanceCalendars?.Build(student, new AttendanceCalendarOptions(date, 1),
|
||||||
attendanceCalendars?.BuildAbsenceDayList(student, new AttendanceCalendarOptions(date, 1)));
|
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 ?? [])
|
foreach (var (name, raw) in extraValues ?? [])
|
||||||
{
|
{
|
||||||
var definition = loaded.Manifest.Placeholders.FirstOrDefault(p => p.Name == name);
|
var definition = loaded.Manifest.Placeholders.FirstOrDefault(p => p.Name == name);
|
||||||
|
|||||||
@@ -28,66 +28,121 @@ public static class StudentAttendanceCalendarDrawingBuilder
|
|||||||
|
|
||||||
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
|
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
|
||||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||||||
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries)
|
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, float contentWidth = 170,
|
||||||
|
float millimeterScale = 1, float contentHeight = float.PositiveInfinity)
|
||||||
{
|
{
|
||||||
const float width = 170;
|
var requestedScale = options.Size switch
|
||||||
var scale = options.Size switch
|
|
||||||
{
|
{
|
||||||
AttendanceCalendarSize.Small => .7f,
|
AttendanceCalendarSize.Small => .7f,
|
||||||
AttendanceCalendarSize.Large => 1f,
|
AttendanceCalendarSize.Large => 1f,
|
||||||
_ => .85f,
|
_ => .85f,
|
||||||
};
|
};
|
||||||
var contentWidth = width * scale;
|
// contentWidth/contentHeight/millimeterScale kommen aus der tatsächlichen DRAWBOX/
|
||||||
var cellWidth = contentWidth / 7;
|
// FLOWDRAWBOX-Deklaration der jeweiligen Vorlage (siehe
|
||||||
var cellHeight = 10 * scale;
|
// LetterPlaceholderBuilder.FindDeclaredDrawingBox). Das Raster ist in Millimetern entworfen;
|
||||||
var monthGap = 6 * scale;
|
// 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 first = options.NormalizedStartMonth;
|
||||||
var monthCount = options.NormalizedMonthCount;
|
var monthCount = options.NormalizedMonthCount;
|
||||||
var commands = new List<DrawingCommand>
|
|
||||||
{
|
// Wochenanzahl je Monat vorab ermitteln, unabhängig von "Größe" - nötig, um VOR dem
|
||||||
new DrawStringEx(0, 0, 7 * scale, contentWidth, "Anwesenheit",
|
// eigentlichen Zeichnen zu wissen, wie viel Höhe das Raster braucht. Eine DRAWBOX bricht
|
||||||
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true),
|
// anders als FLOWDRAWBOX nicht automatisch auf Folgeseiten um (siehe
|
||||||
new DrawStringEx(0, 7 * scale, 6 * scale, contentWidth, studentName,
|
// DrawingElementRenderer.RenderFixed/Slice): Inhalt, der contentHeight überschreitet, wird
|
||||||
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"),
|
// von QuestTemplateRenderer am unteren Rand stillschweigend abgeschnitten. "Größe" wird
|
||||||
};
|
// deshalb nötigenfalls automatisch verkleinert, statt die von der Vorlage vorgegebene
|
||||||
var weekdays = new[] { "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So" };
|
// Boxhöhe zu verletzen.
|
||||||
var y = 15 * scale;
|
var maxWeeks = 0;
|
||||||
for (var monthIndex = 0; monthIndex < monthCount; monthIndex++)
|
for (var monthIndex = 0; monthIndex < monthCount; monthIndex++)
|
||||||
{
|
{
|
||||||
var current = first.AddMonths(monthIndex);
|
var current = first.AddMonths(monthIndex);
|
||||||
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(current, absences, registerEntries,
|
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(current, absences, registerEntries,
|
||||||
DateOnly.FromDateTime(DateTime.Today), studentName);
|
DateOnly.FromDateTime(DateTime.Today), studentName);
|
||||||
var offset = ((int)current.DayOfWeek + 6) % 7;
|
var offset = ((int)current.DayOfWeek + 6) % 7;
|
||||||
var weeks = (int)Math.Ceiling((offset + days.Count) / 7d);
|
maxWeeks = Math.Max(maxWeeks, (int)Math.Ceiling((offset + days.Count) / 7d));
|
||||||
commands.Add(new DrawStringEx(0, y, 7 * scale, contentWidth,
|
}
|
||||||
|
// 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 monthWidth = (contentWidth - 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;
|
||||||
|
var cellCornerRadius = Math.Min(cellWidth, cellHeight) * .2f;
|
||||||
|
var commands = new List<DrawingCommand>();
|
||||||
|
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;
|
||||||
|
var weekdays = new[] { "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So" };
|
||||||
|
var gridStartY = y;
|
||||||
|
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 xOffset = monthIndex * (monthWidth + monthGapX);
|
||||||
|
var monthY = gridStartY;
|
||||||
|
commands.Add(new DrawStringEx(xOffset, monthY, 11 * scale * millimeterScale, 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 * scale, Color: "#374151", Bold: true));
|
||||||
y += 7 * scale;
|
monthY += 11 * scale * millimeterScale;
|
||||||
for (var column = 0; column < 7; column++)
|
for (var column = 0; column < 7; column++)
|
||||||
commands.Add(new DrawStringEx(column * cellWidth, y, 6 * scale, cellWidth, weekdays[column],
|
commands.Add(new DrawStringEx(xOffset + column * cellWidth, monthY, 9 * scale * millimeterScale,
|
||||||
DrawingTextAlignment.AlignCenter, 7 * scale, Color: "#6B7280", Bold: true));
|
cellWidth, weekdays[column], DrawingTextAlignment.AlignCenter, 7 * scale, Color: "#6B7280",
|
||||||
y += 7 * scale;
|
Bold: true));
|
||||||
|
monthY += 9 * scale * millimeterScale;
|
||||||
|
|
||||||
foreach (var day in days)
|
foreach (var day in days)
|
||||||
{
|
{
|
||||||
var index = offset + day.Date.Day - 1;
|
var index = offset + day.Date.Day - 1;
|
||||||
var column = index % 7;
|
var column = index % 7;
|
||||||
var row = index / 7;
|
var row = index / 7;
|
||||||
var x = column * cellWidth;
|
var x = xOffset + column * cellWidth;
|
||||||
var cellY = y + row * cellHeight;
|
var cellY = monthY + row * cellHeight;
|
||||||
commands.Add(new DrawRectangle(x + scale, cellY, cellWidth - 2 * scale, cellHeight - scale,
|
commands.Add(new DrawRoundedRectangle(x + cellInset, cellY, cellWidth - 2 * cellInset,
|
||||||
"#D1D5DB", .35f, day.HasSignal ? day.SignalColorHex : "#FFFFFF"));
|
cellHeight - cellInset, cellCornerRadius, "#D1D5DB", .35f,
|
||||||
commands.Add(new DrawStringEx(x, cellY + scale, cellHeight - 2 * scale, cellWidth,
|
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,
|
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 += weeks * cellHeight + monthGap;
|
|
||||||
}
|
}
|
||||||
|
y = gridStartY + 11 * scale * millimeterScale + 9 * scale * millimeterScale + maxWeeks * cellHeight + monthGapX;
|
||||||
|
|
||||||
commands.Add(new DrawStringEx(0, y, 8 * scale, contentWidth,
|
commands.Add(new DrawStringEx(0, y, 8 * scale * millimeterScale, contentWidth,
|
||||||
"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 * scale, Color: "#6B7280"));
|
||||||
return new DrawingValue(commands, y + 9 * scale);
|
return new DrawingValue(commands, y + 9 * scale * millimeterScale);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,17 +152,19 @@ public static class StudentAbsenceDayListDrawingBuilder
|
|||||||
public const string PlaceholderName = "Student.AbsenceDays";
|
public const string PlaceholderName = "Student.AbsenceDays";
|
||||||
|
|
||||||
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
|
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
|
||||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absences)
|
IReadOnlyList<ClassAbsenceDaySummaryRow> absences, float contentWidth = 170,
|
||||||
|
float millimeterScale = 1)
|
||||||
{
|
{
|
||||||
const float width = 170;
|
|
||||||
var scale = options.Size switch
|
var scale = options.Size switch
|
||||||
{
|
{
|
||||||
AttendanceCalendarSize.Small => .75f,
|
AttendanceCalendarSize.Small => .75f,
|
||||||
AttendanceCalendarSize.Large => 1f,
|
AttendanceCalendarSize.Large => 1f,
|
||||||
_ => .88f,
|
_ => .88f,
|
||||||
};
|
};
|
||||||
var contentWidth = width * scale;
|
// contentWidth/millimeterScale: siehe StudentAttendanceCalendarDrawingBuilder - contentWidth
|
||||||
var rowHeight = 9 * scale;
|
// bleibt an die tatsächliche DRAWBOX/FLOWDRAWBOX-Deklaration im Layout-Skript gebunden,
|
||||||
|
// millimeterScale rechnet das mm-entworfene Raster in die Koordinaten-Einheit der Vorlage um.
|
||||||
|
var rowHeight = 12 * scale * millimeterScale;
|
||||||
var start = options.NormalizedStartMonth;
|
var start = options.NormalizedStartMonth;
|
||||||
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
||||||
var rows = absences
|
var rows = absences
|
||||||
@@ -115,29 +172,35 @@ public static class StudentAbsenceDayListDrawingBuilder
|
|||||||
UntisNameMatching.NamesMatch(a.StudentName, studentName))
|
UntisNameMatching.NamesMatch(a.StudentName, studentName))
|
||||||
.OrderBy(a => a.Date)
|
.OrderBy(a => a.Date)
|
||||||
.ToList();
|
.ToList();
|
||||||
var commands = new List<DrawingCommand>
|
var commands = new List<DrawingCommand>();
|
||||||
{
|
var y = 0f;
|
||||||
new DrawStringEx(0, 0, 7 * scale, contentWidth, "Fehltage",
|
commands.Add(new DrawStringEx(0, y, 14 * scale * millimeterScale, contentWidth, "Fehltage",
|
||||||
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true),
|
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true));
|
||||||
new DrawStringEx(0, 7 * scale, 6 * scale, contentWidth, studentName,
|
y += 14 * scale * millimeterScale;
|
||||||
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"),
|
commands.Add(new DrawStringEx(0, y, 10 * scale * millimeterScale, contentWidth, studentName,
|
||||||
};
|
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"));
|
||||||
var y = 16 * scale;
|
y += 10 * scale * millimeterScale + 2 * scale * millimeterScale;
|
||||||
|
// Spaltenbreiten so gewählt, dass auch die Datenzeilen (nicht nur die kurzen Kopfzeilen-
|
||||||
|
// Labels) hineinpassen - "Datum" als Kopfzeile ist kürzer als "dd.MM.yyyy" und wurde bei
|
||||||
|
// 31 zu schmal bemessen, wodurch das Datum am rechten Rand abgeschnitten wurde.
|
||||||
|
var dateColumnX = 2 * millimeterScale; var dateColumnWidth = 38 * millimeterScale;
|
||||||
|
var extentColumnX = 44 * millimeterScale; var extentColumnWidth = 66 * millimeterScale;
|
||||||
|
var statusColumnX = 114 * millimeterScale; var statusColumnWidth = 54 * millimeterScale;
|
||||||
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#CBD5E1", .4f, "#F3F4F6"));
|
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#CBD5E1", .4f, "#F3F4F6"));
|
||||||
commands.Add(new DrawStringEx(2 * scale, y + scale, rowHeight - 2 * scale, 31 * scale, "Datum",
|
commands.Add(new DrawStringEx(dateColumnX, y + scale * millimeterScale, rowHeight - scale * millimeterScale,
|
||||||
DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
|
dateColumnWidth, "Datum", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
|
||||||
commands.Add(new DrawStringEx(36 * scale, y + scale, rowHeight - 2 * scale, 75 * scale, "Umfang",
|
commands.Add(new DrawStringEx(extentColumnX, y + scale * millimeterScale, rowHeight - scale * millimeterScale,
|
||||||
DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
|
extentColumnWidth, "Umfang", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
|
||||||
commands.Add(new DrawStringEx(113 * scale, y + scale, rowHeight - 2 * scale, 55 * scale, "Status",
|
commands.Add(new DrawStringEx(statusColumnX, y + scale * millimeterScale, rowHeight - scale * millimeterScale,
|
||||||
DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
|
statusColumnWidth, "Status", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
|
||||||
y += rowHeight;
|
y += rowHeight;
|
||||||
|
|
||||||
if (rows.Count == 0)
|
if (rows.Count == 0)
|
||||||
{
|
{
|
||||||
commands.Add(new DrawStringEx(2 * scale, y + 2 * scale, rowHeight, contentWidth - 4 * scale,
|
commands.Add(new DrawStringEx(2 * millimeterScale, y + 2 * scale * millimeterScale, rowHeight,
|
||||||
"Keine Fehltage im gewählten Zeitraum", DrawingTextAlignment.AlignLeft, 8 * scale,
|
contentWidth - 4 * millimeterScale, "Keine Fehltage im gewählten Zeitraum",
|
||||||
Color: "#6B7280", Italic: true));
|
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280", Italic: true));
|
||||||
y += rowHeight + 3 * scale;
|
y += rowHeight + 3 * scale * millimeterScale;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -157,12 +220,15 @@ public static class StudentAbsenceDayListDrawingBuilder
|
|||||||
: row.FriendlyStatusLabel;
|
: row.FriendlyStatusLabel;
|
||||||
var statusColor = row.IsUnexcused ? "#C62828" : "#2E7D32";
|
var statusColor = row.IsUnexcused ? "#C62828" : "#2E7D32";
|
||||||
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#E5E7EB", .3f, fill));
|
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#E5E7EB", .3f, fill));
|
||||||
commands.Add(new DrawStringEx(2 * scale, y + scale, rowHeight - 2 * scale, 31 * scale,
|
commands.Add(new DrawStringEx(dateColumnX, y + scale * millimeterScale,
|
||||||
|
rowHeight - scale * millimeterScale, dateColumnWidth,
|
||||||
row.Date.ToString("dd.MM.yyyy"), DrawingTextAlignment.AlignLeft, 7.5f * scale,
|
row.Date.ToString("dd.MM.yyyy"), DrawingTextAlignment.AlignLeft, 7.5f * scale,
|
||||||
Color: "#374151"));
|
Color: "#374151"));
|
||||||
commands.Add(new DrawStringEx(36 * scale, y + scale, rowHeight - 2 * scale, 75 * scale,
|
commands.Add(new DrawStringEx(extentColumnX, y + scale * millimeterScale,
|
||||||
|
rowHeight - scale * millimeterScale, extentColumnWidth,
|
||||||
extent, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151"));
|
extent, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151"));
|
||||||
commands.Add(new DrawStringEx(113 * scale, y + scale, rowHeight - 2 * scale, 55 * scale,
|
commands.Add(new DrawStringEx(statusColumnX, y + scale * millimeterScale,
|
||||||
|
rowHeight - scale * millimeterScale, statusColumnWidth,
|
||||||
status, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: statusColor,
|
status, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: statusColor,
|
||||||
Bold: row.IsUnexcused));
|
Bold: row.IsUnexcused));
|
||||||
y += rowHeight;
|
y += rowHeight;
|
||||||
@@ -183,16 +249,18 @@ public sealed class StudentAttendanceCalendarService(
|
|||||||
public DrawingValue Build(Student student, DateOnly month) =>
|
public DrawingValue Build(Student student, DateOnly month) =>
|
||||||
Build(student, new AttendanceCalendarOptions(month, 1));
|
Build(student, new AttendanceCalendarOptions(month, 1));
|
||||||
|
|
||||||
public DrawingValue Build(Student student, AttendanceCalendarOptions options)
|
public DrawingValue Build(Student student, AttendanceCalendarOptions options, float contentWidth = 170,
|
||||||
|
float millimeterScale = 1, float contentHeight = float.PositiveInfinity)
|
||||||
{
|
{
|
||||||
var className = settings.HomeroomClassName;
|
var className = settings.HomeroomClassName;
|
||||||
if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0);
|
if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0);
|
||||||
|
|
||||||
var first = options.NormalizedStartMonth;
|
var first = options.NormalizedStartMonth;
|
||||||
var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
||||||
|
var matchName = $"{student.FirstName} {student.LastName}";
|
||||||
var rosterName = rosterCache.GetByClass(className)
|
var rosterName = rosterCache.GetByClass(className)
|
||||||
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, student.FullName))?.DisplayName
|
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, matchName))?.DisplayName
|
||||||
?? student.FullName;
|
?? matchName;
|
||||||
var start = first.Year * 10000 + first.Month * 100 + first.Day;
|
var start = first.Year * 10000 + first.Month * 100 + first.Day;
|
||||||
var end = last.Year * 10000 + last.Month * 100 + last.Day;
|
var end = last.Year * 10000 + last.Month * 100 + last.Day;
|
||||||
var absences = absenceCache.GetByClassAndRange(className, start, end)
|
var absences = absenceCache.GetByClassAndRange(className, start, end)
|
||||||
@@ -205,19 +273,22 @@ public sealed class StudentAttendanceCalendarService(
|
|||||||
e.TeacherUsername, e.CategoryName, e.CategoryGroup, e.Text))
|
e.TeacherUsername, e.CategoryName, e.CategoryGroup, e.Text))
|
||||||
.ToList();
|
.ToList();
|
||||||
return StudentAttendanceCalendarDrawingBuilder.Build(rosterName, options,
|
return StudentAttendanceCalendarDrawingBuilder.Build(rosterName, options,
|
||||||
ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences), register);
|
ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences), register, contentWidth, millimeterScale,
|
||||||
|
contentHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
public DrawingValue BuildAbsenceDayList(Student student, AttendanceCalendarOptions options)
|
public DrawingValue BuildAbsenceDayList(Student student, AttendanceCalendarOptions options, float contentWidth = 170,
|
||||||
|
float millimeterScale = 1)
|
||||||
{
|
{
|
||||||
var className = settings.HomeroomClassName;
|
var className = settings.HomeroomClassName;
|
||||||
if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0);
|
if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0);
|
||||||
|
|
||||||
var first = options.NormalizedStartMonth;
|
var first = options.NormalizedStartMonth;
|
||||||
var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
||||||
|
var matchName = $"{student.FirstName} {student.LastName}";
|
||||||
var rosterName = rosterCache.GetByClass(className)
|
var rosterName = rosterCache.GetByClass(className)
|
||||||
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, student.FullName))?.DisplayName
|
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, matchName))?.DisplayName
|
||||||
?? student.FullName;
|
?? matchName;
|
||||||
var start = first.Year * 10000 + first.Month * 100 + first.Day;
|
var start = first.Year * 10000 + first.Month * 100 + first.Day;
|
||||||
var end = last.Year * 10000 + last.Month * 100 + last.Day;
|
var end = last.Year * 10000 + last.Month * 100 + last.Day;
|
||||||
var absences = absenceCache.GetByClassAndRange(className, start, end)
|
var absences = absenceCache.GetByClassAndRange(className, start, end)
|
||||||
@@ -225,6 +296,6 @@ public sealed class StudentAttendanceCalendarService(
|
|||||||
e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames, e.Subject, e.AbsenceReason, e.Note,
|
e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames, e.Subject, e.AbsenceReason, e.Note,
|
||||||
e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber, e.Status, e.CountsAsFullDay));
|
e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber, e.Status, e.CountsAsFullDay));
|
||||||
return StudentAbsenceDayListDrawingBuilder.Build(rosterName, options,
|
return StudentAbsenceDayListDrawingBuilder.Build(rosterName, options,
|
||||||
ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences));
|
ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences), contentWidth, millimeterScale);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
|||||||
private readonly Student _student;
|
private readonly Student _student;
|
||||||
private readonly TemplateStore _templates;
|
private readonly TemplateStore _templates;
|
||||||
private readonly ITemplateRenderer _renderer;
|
private readonly ITemplateRenderer _renderer;
|
||||||
private readonly Func<AttendanceCalendarOptions, DrawingValue>? _attendanceCalendarFactory;
|
private readonly Func<AttendanceCalendarOptions, float, float, float, DrawingValue>? _attendanceCalendarFactory;
|
||||||
private readonly Func<AttendanceCalendarOptions, DrawingValue>? _absenceDayListFactory;
|
private readonly Func<AttendanceCalendarOptions, float, float, float, DrawingValue>? _absenceDayListFactory;
|
||||||
|
private readonly Func<AttendanceCalendarOptions, CancellationToken, Task>? _attendanceDataRefresher;
|
||||||
private AttendanceCalendarOptions _attendanceCalendarOptions = new(
|
private AttendanceCalendarOptions _attendanceCalendarOptions = new(
|
||||||
new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1), 1);
|
new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1), 1);
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private LetterContactChoice? _selectedContact;
|
[ObservableProperty] private LetterContactChoice? _selectedContact;
|
||||||
[ObservableProperty] private LetterGroupChoice? _selectedGroup;
|
[ObservableProperty] private LetterGroupChoice? _selectedGroup;
|
||||||
[ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now;
|
[ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now;
|
||||||
|
[ObservableProperty] private string _anrede = "";
|
||||||
[ObservableProperty] private string _letterText = "";
|
[ObservableProperty] private string _letterText = "";
|
||||||
[ObservableProperty] private string _teacherName = "";
|
[ObservableProperty] private string _teacherName = "";
|
||||||
[ObservableProperty] private string _generationError = "";
|
[ObservableProperty] private string _generationError = "";
|
||||||
@@ -29,27 +31,35 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private bool _usesAbsenceDayList;
|
[ObservableProperty] private bool _usesAbsenceDayList;
|
||||||
[ObservableProperty] private bool _attendanceCalendarConfigured;
|
[ObservableProperty] private bool _attendanceCalendarConfigured;
|
||||||
[ObservableProperty] private string _attendanceCalendarSummary = "1 Monat · Standardgröße";
|
[ObservableProperty] private string _attendanceCalendarSummary = "1 Monat · Standardgröße";
|
||||||
|
[ObservableProperty] private bool _isRefreshingAttendanceData;
|
||||||
|
[ObservableProperty] private string _attendanceRefreshError = "";
|
||||||
|
|
||||||
public string StudentName => _student.FullName;
|
public string StudentName => _student.FullName;
|
||||||
public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
|
public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
|
||||||
public ObservableCollection<LetterContactChoice> Contacts { get; } = [];
|
public ObservableCollection<LetterContactChoice> Contacts { get; } = [];
|
||||||
public ObservableCollection<LetterGroupChoice> Groups { get; } = [];
|
public ObservableCollection<LetterGroupChoice> Groups { get; } = [];
|
||||||
public ObservableCollection<LetterGenerationIssue> Issues { get; } = [];
|
public ObservableCollection<LetterGenerationIssue> Issues { get; } = [];
|
||||||
|
public ObservableCollection<LetterPlaceholderInput> CustomPlaceholders { get; } = [];
|
||||||
public bool HasIssues => Issues.Count > 0;
|
public bool HasIssues => Issues.Count > 0;
|
||||||
|
public bool HasCustomPlaceholders => CustomPlaceholders.Count > 0;
|
||||||
public bool HasNoTemplates => Templates.Count == 0;
|
public bool HasNoTemplates => Templates.Count == 0;
|
||||||
public bool HasNoContacts => Contacts.Count == 0;
|
public bool HasNoContacts => Contacts.Count == 0;
|
||||||
|
public string AddressPreview => LetterPlaceholderBuilder.FormatAddress(SelectedContact?.Model);
|
||||||
|
public bool HasAddressPreview => !string.IsNullOrWhiteSpace(AddressPreview);
|
||||||
public bool UsesAttendanceAdvancedContent => UsesAttendanceCalendar || UsesAbsenceDayList;
|
public bool UsesAttendanceAdvancedContent => UsesAttendanceCalendar || UsesAbsenceDayList;
|
||||||
public string SuggestedFileName => SanitizeFileName(
|
public string SuggestedFileName => SanitizeFileName(
|
||||||
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf");
|
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf");
|
||||||
|
|
||||||
public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer,
|
public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer,
|
||||||
IGroupMembershipRepository memberships, IGroupRepository groups,
|
IGroupMembershipRepository memberships, IGroupRepository groups,
|
||||||
Func<AttendanceCalendarOptions, DrawingValue>? attendanceCalendarFactory = null,
|
Func<AttendanceCalendarOptions, float, float, float, DrawingValue>? attendanceCalendarFactory = null,
|
||||||
Func<AttendanceCalendarOptions, DrawingValue>? absenceDayListFactory = null)
|
Func<AttendanceCalendarOptions, float, float, float, DrawingValue>? absenceDayListFactory = null,
|
||||||
|
Func<AttendanceCalendarOptions, CancellationToken, Task>? attendanceDataRefresher = null)
|
||||||
{
|
{
|
||||||
_student = student; _templates = templates; _renderer = renderer;
|
_student = student; _templates = templates; _renderer = renderer;
|
||||||
_attendanceCalendarFactory = attendanceCalendarFactory;
|
_attendanceCalendarFactory = attendanceCalendarFactory;
|
||||||
_absenceDayListFactory = absenceDayListFactory;
|
_absenceDayListFactory = absenceDayListFactory;
|
||||||
|
_attendanceDataRefresher = attendanceDataRefresher;
|
||||||
foreach (var template in templates.GetTemplates()) Templates.Add(new(template));
|
foreach (var template in templates.GetTemplates()) Templates.Add(new(template));
|
||||||
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact));
|
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact));
|
||||||
foreach (var membership in memberships.GetByStudent(student.Id))
|
foreach (var membership in memberships.GetByStudent(student.Id))
|
||||||
@@ -68,9 +78,17 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(UsesAttendanceAdvancedContent));
|
OnPropertyChanged(nameof(UsesAttendanceAdvancedContent));
|
||||||
AttendanceCalendarConfigured = false;
|
AttendanceCalendarConfigured = false;
|
||||||
ResetAttendanceCalendarOptions();
|
ResetAttendanceCalendarOptions();
|
||||||
|
RebuildCustomPlaceholders(value);
|
||||||
RefreshValidation();
|
RefreshValidation();
|
||||||
}
|
}
|
||||||
partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation();
|
partial void OnSelectedContactChanged(LetterContactChoice? value)
|
||||||
|
{
|
||||||
|
Anrede = value?.Model.LetterSalutation ?? "";
|
||||||
|
OnPropertyChanged(nameof(AddressPreview));
|
||||||
|
OnPropertyChanged(nameof(HasAddressPreview));
|
||||||
|
RefreshValidation();
|
||||||
|
}
|
||||||
|
partial void OnAnredeChanged(string value) => RefreshValidation();
|
||||||
partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation();
|
partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation();
|
||||||
partial void OnLetterDateChanged(DateTimeOffset? value)
|
partial void OnLetterDateChanged(DateTimeOffset? value)
|
||||||
{
|
{
|
||||||
@@ -117,20 +135,86 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(HasIssues));
|
OnPropertyChanged(nameof(HasIssues));
|
||||||
}
|
}
|
||||||
|
|
||||||
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues() => LetterPlaceholderBuilder.BuildStandardValues(
|
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues()
|
||||||
_student, SelectedContact?.Model, SelectedGroup?.Model,
|
{
|
||||||
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName,
|
var calendarBox = DeclaredDrawingBox(StudentAttendanceCalendarDrawingBuilder.PlaceholderName);
|
||||||
UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions) : null,
|
var absenceDayListBox = DeclaredDrawingBox(StudentAbsenceDayListDrawingBuilder.PlaceholderName);
|
||||||
UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions) : null);
|
var values = LetterPlaceholderBuilder.BuildStandardValues(
|
||||||
|
_student, SelectedContact?.Model, SelectedGroup?.Model,
|
||||||
|
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName,
|
||||||
|
UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions,
|
||||||
|
calendarBox.Width, calendarBox.MillimeterScale,
|
||||||
|
calendarBox.IsFixed ? calendarBox.Height : float.PositiveInfinity) : null,
|
||||||
|
UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tatsächliche DRAWBOX/FLOWDRAWBOX-Größe der aktuell gewählten Vorlage für diesen
|
||||||
|
/// Platzhalter, damit der Kalender/die Fehltagesliste die real verfügbare Fläche ausfüllen statt
|
||||||
|
/// eine feste Breite zu raten (siehe LetterPlaceholderBuilder.FindDeclaredDrawingBox). Fällt auf
|
||||||
|
/// 170mm zurück, wenn keine Vorlage gewählt ist oder die Box nicht gefunden wird.</summary>
|
||||||
|
private LetterPlaceholderBuilder.DeclaredDrawingBox DeclaredDrawingBox(string placeholderName)
|
||||||
|
{
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildCustomPlaceholders(LetterTemplateChoice? choice)
|
||||||
|
{
|
||||||
|
foreach (var existing in CustomPlaceholders) existing.PropertyChanged -= OnCustomPlaceholderChanged;
|
||||||
|
CustomPlaceholders.Clear();
|
||||||
|
if (choice is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var loaded = _templates.Load(choice.Model);
|
||||||
|
foreach (var placeholder in loaded.Manifest.Placeholders.Where(p => !p.IsConstant
|
||||||
|
&& !StandardPlaceholderNames.Contains(p.Name) && p.Type is PlaceholderType.Text
|
||||||
|
or PlaceholderType.Multiline or PlaceholderType.Date or PlaceholderType.Number))
|
||||||
|
{
|
||||||
|
var input = new LetterPlaceholderInput(placeholder.Name, placeholder.Type);
|
||||||
|
input.PropertyChanged += OnCustomPlaceholderChanged;
|
||||||
|
CustomPlaceholders.Add(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { }
|
||||||
|
}
|
||||||
|
OnPropertyChanged(nameof(HasCustomPlaceholders));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCustomPlaceholderChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => RefreshValidation();
|
||||||
|
|
||||||
|
private static readonly HashSet<string> StandardPlaceholderNames = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"Datum", "CurrentDate", "Empfaenger", "Anrede", "Brieftext", "LehrerName",
|
||||||
|
"Student.FirstName", "Student.LastName", "Student.Name", "Contact.Name", "Contact.Address", "Contact.Street",
|
||||||
|
"Contact.PostalCode", "Contact.City", "Letter.Salutation", "Group.Name", "SchoolYear",
|
||||||
|
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, StudentAbsenceDayListDrawingBuilder.PlaceholderName,
|
||||||
|
};
|
||||||
|
|
||||||
public AttendanceCalendarOptions GetAttendanceCalendarOptions() => _attendanceCalendarOptions;
|
public AttendanceCalendarOptions GetAttendanceCalendarOptions() => _attendanceCalendarOptions;
|
||||||
|
|
||||||
public void SetAttendanceCalendarOptions(AttendanceCalendarOptions options)
|
public async Task SetAttendanceCalendarOptionsAsync(AttendanceCalendarOptions options, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
_attendanceCalendarOptions = options with { StartMonth = options.NormalizedStartMonth,
|
_attendanceCalendarOptions = options with { StartMonth = options.NormalizedStartMonth,
|
||||||
MonthCount = options.NormalizedMonthCount };
|
MonthCount = options.NormalizedMonthCount };
|
||||||
AttendanceCalendarConfigured = true;
|
AttendanceCalendarConfigured = true;
|
||||||
AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions);
|
AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions);
|
||||||
|
AttendanceRefreshError = "";
|
||||||
|
if (_attendanceDataRefresher is not null)
|
||||||
|
{
|
||||||
|
IsRefreshingAttendanceData = true;
|
||||||
|
try { await _attendanceDataRefresher(_attendanceCalendarOptions, token); }
|
||||||
|
catch (WebUntisIntegrationException ex)
|
||||||
|
{ AttendanceRefreshError = $"WebUntis-Daten konnten nicht aktualisiert werden: {ex.Message}"; }
|
||||||
|
finally { IsRefreshingAttendanceData = false; }
|
||||||
|
}
|
||||||
RefreshValidation();
|
RefreshValidation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,3 +271,33 @@ public sealed class LetterContactChoice(Contact model) { public Contact Model {
|
|||||||
public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; }
|
public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; }
|
||||||
public sealed class LetterGenerationIssue(string message, bool isStrong)
|
public sealed class LetterGenerationIssue(string message, bool isStrong)
|
||||||
{ public string Icon { get; } = isStrong ? "⚠" : "ⓘ"; public string Message { get; } = message; public string Color { get; } = isStrong ? "#D97706" : "#6B7280"; }
|
{ public string Icon { get; } = isStrong ? "⚠" : "ⓘ"; public string Message { get; } = message; public string Color { get; } = isStrong ? "#D97706" : "#6B7280"; }
|
||||||
|
|
||||||
|
public sealed partial class LetterPlaceholderInput : ObservableObject
|
||||||
|
{
|
||||||
|
public string Name { get; }
|
||||||
|
public PlaceholderType Type { get; }
|
||||||
|
public string Label => Type switch
|
||||||
|
{
|
||||||
|
PlaceholderType.Date => $"{Name} (Datum)",
|
||||||
|
PlaceholderType.Number => $"{Name} (Zahl)",
|
||||||
|
_ => Name,
|
||||||
|
};
|
||||||
|
public bool IsTextType => Type == PlaceholderType.Text;
|
||||||
|
public bool IsMultilineType => Type == PlaceholderType.Multiline;
|
||||||
|
public bool IsDateType => Type == PlaceholderType.Date;
|
||||||
|
public bool IsNumberType => Type == PlaceholderType.Number;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _textValue = "";
|
||||||
|
[ObservableProperty] private DateTimeOffset? _dateValue;
|
||||||
|
[ObservableProperty] private decimal? _numberValue;
|
||||||
|
|
||||||
|
public LetterPlaceholderInput(string name, PlaceholderType type) { Name = name; Type = type; }
|
||||||
|
|
||||||
|
public PlaceholderValue ToPlaceholderValue() => Type switch
|
||||||
|
{
|
||||||
|
PlaceholderType.Multiline => new MultilineValue(TextValue),
|
||||||
|
PlaceholderType.Date => new DateValue(DateValue.HasValue ? DateOnly.FromDateTime(DateValue.Value.LocalDateTime) : default),
|
||||||
|
PlaceholderType.Number => new NumberValue(NumberValue ?? 0),
|
||||||
|
_ => new TextValue(TextValue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
|
||||||
|
public partial class SelectContactDialogViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
[ObservableProperty] private LetterContactChoice? _selectedContact;
|
||||||
|
|
||||||
|
public IReadOnlyList<LetterContactChoice> Contacts { get; }
|
||||||
|
public string AddressPreview => LetterPlaceholderBuilder.FormatAddress(SelectedContact?.Model);
|
||||||
|
public bool HasAddressPreview => !string.IsNullOrWhiteSpace(AddressPreview);
|
||||||
|
|
||||||
|
public SelectContactDialogViewModel(IReadOnlyList<LetterContactChoice> contacts, LetterContactChoice? current)
|
||||||
|
{
|
||||||
|
Contacts = contacts;
|
||||||
|
SelectedContact = current ?? contacts.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedContactChanged(LetterContactChoice? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(AddressPreview));
|
||||||
|
OnPropertyChanged(nameof(HasAddressPreview));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,11 +26,21 @@
|
|||||||
Classes="emptyhint" IsVisible="{Binding HasNoTemplates}"/>
|
Classes="emptyhint" IsVisible="{Binding HasNoTemplates}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="Kontakt *" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Anschrift *" FontSize="12" Opacity="0.7"/>
|
||||||
<ComboBox ItemsSource="{Binding Contacts}" SelectedItem="{Binding SelectedContact}"
|
<Border BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" CornerRadius="6" Padding="10">
|
||||||
DisplayMemberBinding="{Binding Display}" HorizontalAlignment="Stretch"/>
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<TextBlock Text="Kein aktueller Kontakt vorhanden." Classes="emptyhint"
|
<TextBlock Grid.Column="0" Text="{Binding AddressPreview}" TextWrapping="Wrap"
|
||||||
IsVisible="{Binding HasNoContacts}"/>
|
VerticalAlignment="Center" IsVisible="{Binding HasAddressPreview}"/>
|
||||||
|
<TextBlock Grid.Column="0" Text="Kein aktueller Kontakt vorhanden." Classes="emptyhint"
|
||||||
|
VerticalAlignment="Center" IsVisible="{Binding HasNoContacts}"/>
|
||||||
|
<Button Grid.Column="1" Content="Kontakt wechseln …" Click="OnSelectContact"
|
||||||
|
IsEnabled="{Binding !HasNoContacts}" VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Anrede *" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding Anrede}" PlaceholderText="z.B. Sehr geehrte Frau Mustermann,"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Grid ColumnDefinitions="*,12,*">
|
<Grid ColumnDefinitions="*,12,*">
|
||||||
<StackPanel Grid.Column="0" Spacing="4">
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
@@ -46,14 +56,20 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Border IsVisible="{Binding UsesAttendanceAdvancedContent}" BorderBrush="{DynamicResource AppCardBorderBrush}"
|
<Border IsVisible="{Binding UsesAttendanceAdvancedContent}" BorderBrush="{DynamicResource AppCardBorderBrush}"
|
||||||
BorderThickness="1" CornerRadius="6" Padding="12">
|
BorderThickness="1" CornerRadius="6" Padding="12">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<StackPanel Spacing="6">
|
||||||
<StackPanel Spacing="3">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<TextBlock Text="Anwesenheitsdaten im Brief" FontWeight="SemiBold"/>
|
<StackPanel Spacing="3">
|
||||||
<TextBlock Text="{Binding AttendanceCalendarSummary}" FontSize="12" Opacity="0.65"/>
|
<TextBlock Text="Anwesenheitsdaten im Brief" FontWeight="SemiBold"/>
|
||||||
</StackPanel>
|
<TextBlock Text="{Binding AttendanceCalendarSummary}" FontSize="12" Opacity="0.65"/>
|
||||||
<Button Grid.Column="1" Content="Konfigurieren …" Click="OnConfigureAttendanceCalendar"
|
</StackPanel>
|
||||||
VerticalAlignment="Center"/>
|
<Button Grid.Column="1" Content="Konfigurieren …" Click="OnConfigureAttendanceCalendar"
|
||||||
</Grid>
|
IsEnabled="{Binding !IsRefreshingAttendanceData}" VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Text="Aktualisiere WebUntis-Daten für den gewählten Zeitraum …" FontSize="12"
|
||||||
|
Foreground="#6B7280" IsVisible="{Binding IsRefreshingAttendanceData}"/>
|
||||||
|
<TextBlock Text="{Binding AttendanceRefreshError}" FontSize="12" Foreground="#D97706" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding AttendanceRefreshError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="Brieftext" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Brieftext" FontSize="12" Opacity="0.7"/>
|
||||||
@@ -65,6 +81,26 @@
|
|||||||
<TextBox Text="{Binding TeacherName}" PlaceholderText="Name der Lehrkraft"/>
|
<TextBox Text="{Binding TeacherName}" PlaceholderText="Name der Lehrkraft"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="8" IsVisible="{Binding HasCustomPlaceholders}">
|
||||||
|
<TextBlock Text="Zusätzliche Felder der Vorlage" FontSize="12" Opacity="0.7"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding CustomPlaceholders}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:LetterPlaceholderInput">
|
||||||
|
<StackPanel Spacing="4" Margin="0,0,0,8">
|
||||||
|
<TextBlock Text="{Binding Label}" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding TextValue}" IsVisible="{Binding IsTextType}"/>
|
||||||
|
<TextBox Text="{Binding TextValue}" AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"
|
||||||
|
IsVisible="{Binding IsMultilineType}"/>
|
||||||
|
<CalendarDatePicker SelectedDate="{Binding DateValue, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||||
|
HorizontalAlignment="Stretch" IsVisible="{Binding IsDateType}"/>
|
||||||
|
<NumericUpDown Value="{Binding NumberValue}" FormatString="0.##" HorizontalAlignment="Stretch"
|
||||||
|
IsVisible="{Binding IsNumberType}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<Border BorderBrush="#D97706" BorderThickness="1" CornerRadius="6" Padding="10"
|
<Border BorderBrush="#D97706" BorderThickness="1" CornerRadius="6" Padding="10"
|
||||||
IsVisible="{Binding HasIssues}">
|
IsVisible="{Binding HasIssues}">
|
||||||
<StackPanel Spacing="5">
|
<StackPanel Spacing="5">
|
||||||
|
|||||||
@@ -30,12 +30,20 @@ public partial class CreateLetterDialog : Window
|
|||||||
if (DataContext is CreateLetterDialogViewModel vm) await ConfigureAttendanceCalendar(vm);
|
if (DataContext is CreateLetterDialogViewModel vm) await ConfigureAttendanceCalendar(vm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnSelectContact(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not CreateLetterDialogViewModel vm) return;
|
||||||
|
var dialogVm = new SelectContactDialogViewModel(vm.Contacts, vm.SelectedContact);
|
||||||
|
var dialog = new SelectContactDialog { DataContext = dialogVm };
|
||||||
|
if (await dialog.ShowDialog<bool>(this)) vm.SelectedContact = dialogVm.SelectedContact;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<bool> ConfigureAttendanceCalendar(CreateLetterDialogViewModel vm)
|
private async Task<bool> ConfigureAttendanceCalendar(CreateLetterDialogViewModel vm)
|
||||||
{
|
{
|
||||||
var configVm = new AttendanceCalendarConfigurationViewModel(vm.GetAttendanceCalendarOptions());
|
var configVm = new AttendanceCalendarConfigurationViewModel(vm.GetAttendanceCalendarOptions());
|
||||||
var dialog = new AttendanceCalendarConfigurationDialog { DataContext = configVm };
|
var dialog = new AttendanceCalendarConfigurationDialog { DataContext = configVm };
|
||||||
if (!await dialog.ShowDialog<bool>(this)) return false;
|
if (!await dialog.ShowDialog<bool>(this)) return false;
|
||||||
vm.SetAttendanceCalendarOptions(configVm.BuildResult());
|
await vm.SetAttendanceCalendarOptionsAsync(configVm.BuildResult());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
CanResize="True" WindowStartupLocation="CenterOwner">
|
CanResize="True" WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
<Grid RowDefinitions="*,Auto" Margin="24">
|
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||||
<ScrollViewer Grid.Row="0">
|
<ScrollViewer Grid.Row="0" MaxHeight="560">
|
||||||
<StackPanel Spacing="12">
|
<StackPanel Spacing="12">
|
||||||
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
|
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Students.SelectContactDialog"
|
||||||
|
x:DataType="vm:SelectContactDialogViewModel"
|
||||||
|
Title="Kontakt wählen" Width="380" Height="440"
|
||||||
|
CanResize="False" WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="24">
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Text="Kontakt für Anrede und Anschrift" Classes="dialogtitle" Margin="0,0,0,12"/>
|
||||||
|
|
||||||
|
<ListBox Grid.Row="1" ItemsSource="{Binding Contacts}" SelectedItem="{Binding SelectedContact}"
|
||||||
|
BorderThickness="1">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:LetterContactChoice">
|
||||||
|
<TextBlock Text="{Binding Display}" Padding="4,2"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
|
||||||
|
<Border Grid.Row="2" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" CornerRadius="6"
|
||||||
|
Padding="10" Margin="0,12,0,0" IsVisible="{Binding HasAddressPreview}">
|
||||||
|
<TextBlock Text="{Binding AddressPreview}" TextWrapping="Wrap" FontSize="12"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid Grid.Row="3" ColumnDefinitions="*,10,*" Margin="0,16,0,0">
|
||||||
|
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
|
||||||
|
<Button Grid.Column="2" Content="Übernehmen" HorizontalAlignment="Stretch" Click="OnApply"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Views.Students;
|
||||||
|
|
||||||
|
public partial class SelectContactDialog : Window
|
||||||
|
{
|
||||||
|
public SelectContactDialog() => InitializeComponent();
|
||||||
|
|
||||||
|
private void OnApply(object? sender, RoutedEventArgs e) => Close(true);
|
||||||
|
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ public partial class DesignerViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _selectedPageTemplate = "first";
|
[ObservableProperty] private string _selectedPageTemplate = "first";
|
||||||
[ObservableProperty] private DesignerPreviewPage? _selectedPreviewPage;
|
[ObservableProperty] private DesignerPreviewPage? _selectedPreviewPage;
|
||||||
[ObservableProperty] private SpecialContentItem? _selectedSpecialContent;
|
[ObservableProperty] private SpecialContentItem? _selectedSpecialContent;
|
||||||
|
[ObservableProperty] private bool _isLegacyContinuationSupported = true;
|
||||||
|
|
||||||
public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"];
|
public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"];
|
||||||
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
|
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
|
||||||
@@ -586,6 +587,8 @@ public partial class DesignerViewModel : ObservableObject
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var page = new LayoutParser().Parse(value);
|
var page = new LayoutParser().Parse(value);
|
||||||
|
IsLegacyContinuationSupported = !page.UsesPageTemplates;
|
||||||
|
if (page.UsesPageTemplates) UseContinuationLayout = false;
|
||||||
OverlayPageWidth = page.Width; OverlayPageHeight = page.Height; OverlayPageUnit = page.Unit;
|
OverlayPageWidth = page.Width; OverlayPageHeight = page.Height; OverlayPageUnit = page.Unit;
|
||||||
var names = page.PageTemplates.Select(x => x.Name).ToList();
|
var names = page.PageTemplates.Select(x => x.Name).ToList();
|
||||||
if (names.Count == 0) names.Add("legacy");
|
if (names.Count == 0) names.Add("legacy");
|
||||||
|
|||||||
@@ -271,12 +271,12 @@
|
|||||||
<Button Grid.Column="1" Content="Prüfen & Vorschau" Click="OnPreview"/>
|
<Button Grid.Column="1" Content="Prüfen & Vorschau" Click="OnPreview"/>
|
||||||
<Button Grid.Column="3" Content="Als PDF exportieren …" Click="OnExportCurrentPdf"/></Grid>
|
<Button Grid.Column="3" Content="Als PDF exportieren …" Click="OnExportCurrentPdf"/></Grid>
|
||||||
<TabControl Grid.Row="1" Margin="12">
|
<TabControl Grid.Row="1" Margin="12">
|
||||||
<TabItem Header="Seite 1">
|
<TabItem Header="Skript">
|
||||||
<TextBox Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap"
|
<TextBox Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap"
|
||||||
FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top"
|
FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top"
|
||||||
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem Header="Folgeseiten">
|
<TabItem Header="Folgeseiten (Legacy)" IsVisible="{Binding IsLegacyContinuationSupported}">
|
||||||
<Grid RowDefinitions="Auto,*">
|
<Grid RowDefinitions="Auto,*">
|
||||||
<CheckBox Margin="8" Content="Eigenes Layout für Seite 2 und alle weiteren Seiten im Paket speichern"
|
<CheckBox Margin="8" Content="Eigenes Layout für Seite 2 und alle weiteren Seiten im Paket speichern"
|
||||||
IsChecked="{Binding UseContinuationLayout}"/>
|
IsChecked="{Binding UseContinuationLayout}"/>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ public sealed class PdfImportPipeline
|
|||||||
var blank = templatePath is null ? null : Extract(templatePath);
|
var blank = templatePath is null ? null : Extract(templatePath);
|
||||||
EnsureCompatible(example, blank);
|
EnsureCompatible(example, blank);
|
||||||
if (example.Pages.Count > 1)
|
if (example.Pages.Count > 1)
|
||||||
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend als Folgeseitenlayout ergänzt werden.");
|
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend im Reiter „Seiten & Flows“ als zusätzliche Seitenvorlage (page-template continuation) ergänzt werden.");
|
||||||
var candidates = FindCandidates(example, blank);
|
var candidates = FindCandidates(example, blank);
|
||||||
if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password))
|
if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password))
|
||||||
{
|
{
|
||||||
@@ -124,7 +124,7 @@ public sealed class PdfImportPipeline
|
|||||||
{
|
{
|
||||||
if (document.Pages.Count == 0) throw new InvalidDataException("Das PDF enthält keine Seiten.");
|
if (document.Pages.Count == 0) throw new InvalidDataException("Das PDF enthält keine Seiten.");
|
||||||
if (document.Pages.Count > 1)
|
if (document.Pages.Count > 1)
|
||||||
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend als Folgeseitenlayout ergänzt werden.");
|
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend im Reiter „Seiten & Flows“ als zusätzliche Seitenvorlage (page-template continuation) ergänzt werden.");
|
||||||
var first = document.Pages[0];
|
var first = document.Pages[0];
|
||||||
var assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
|
var assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
@@ -134,6 +134,8 @@ public sealed class PdfImportPipeline
|
|||||||
var lines = new List<string>
|
var lines = new List<string>
|
||||||
{
|
{
|
||||||
$"PAGE {N(first.Width)} {N(first.Height)} pt",
|
$"PAGE {N(first.Width)} {N(first.Height)} pt",
|
||||||
|
"#pragma format-version 3",
|
||||||
|
"#pragma page-template first",
|
||||||
"BG pdf-import-background.png",
|
"BG pdf-import-background.png",
|
||||||
};
|
};
|
||||||
var definitions = new List<PlaceholderDefinition>();
|
var definitions = new List<PlaceholderDefinition>();
|
||||||
@@ -157,6 +159,7 @@ public sealed class PdfImportPipeline
|
|||||||
definitions.Add(new(name, multiline ? PlaceholderType.Multiline : candidate.Type, false));
|
definitions.Add(new(name, multiline ? PlaceholderType.Multiline : candidate.Type, false));
|
||||||
candidate.Name = name;
|
candidate.Name = name;
|
||||||
}
|
}
|
||||||
|
lines.Add("#pragma end-page-template");
|
||||||
var manifest = new TemplateManifest
|
var manifest = new TemplateManifest
|
||||||
{
|
{
|
||||||
Id = "pdf-import", Name = "PDF-Import", Description = "Automatisch aus einem PDF rekonstruiert",
|
Id = "pdf-import", Name = "PDF-Import", Description = "Automatisch aus einem PDF rekonstruiert",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
|
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="PdfPig" />
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio"><PrivateAssets>all</PrivateAssets></PackageReference>
|
<PackageReference Include="xunit.runner.visualstudio"><PrivateAssets>all</PrivateAssets></PackageReference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ public sealed class TemplatingTests : IDisposable
|
|||||||
new Dictionary<string, byte[]>());
|
new Dictionary<string, byte[]>());
|
||||||
var drawing = new DrawingValue(
|
var drawing = new DrawingValue(
|
||||||
[new DrawRectangle(0, 0, 100, 60, "#1D4ED8", 1, "#EFF6FF"),
|
[new DrawRectangle(0, 0, 100, 60, "#1D4ED8", 1, "#EFF6FF"),
|
||||||
|
new DrawRoundedRectangle(2, 2, 30, 15, 3, "#1D4ED8", 1, "#EFF6FF"),
|
||||||
|
new DrawCircle(90, 10, 6, "#1D4ED8", 1, "#EFF6FF"),
|
||||||
new DrawString(5, 5, "Externer Inhalt", 11, Bold: true),
|
new DrawString(5, 5, "Externer Inhalt", 11, Bold: true),
|
||||||
new MoveTo(5, 25), new LineTo(95, 25, "#DC2626", 1.5f),
|
new MoveTo(5, 25), new LineTo(95, 25, "#DC2626", 1.5f),
|
||||||
new DrawLine(5, 35, 95, 50, "#059669", 1)], 60);
|
new DrawLine(5, 35, 95, 50, "#059669", 1)], 60);
|
||||||
@@ -96,6 +98,43 @@ public sealed class TemplatingTests : IDisposable
|
|||||||
System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b"));
|
System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression: RenderFlow fensterte Seiten bisher nur über einen SVG-viewBox-Y-Offset, den
|
||||||
|
// QuestPDFs Svg()-Renderer nicht respektiert - dadurch landete auf jeder Folgeseite (nahezu)
|
||||||
|
// der komplette Zeicheninhalt erneut, statt nur des jeweiligen Ausschnitts (siehe Slice() in
|
||||||
|
// QuestTemplateRenderer.DrawingElementRenderer). Prüft per echter PDF-Textextraktion, dass
|
||||||
|
// seitenspezifische Marker nur auf ihrer jeweiligen Seite auftauchen.
|
||||||
|
[Fact]
|
||||||
|
public void FlowDrawBox_ZerschneidetInhaltEchtStattIhnAufFolgeseitenZuWiederholen()
|
||||||
|
{
|
||||||
|
var template = new LoadedTemplate(new TemplateManifest
|
||||||
|
{
|
||||||
|
Placeholders = [new("Marker", PlaceholderType.Drawing, true)],
|
||||||
|
}, new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 100 $Marker"),
|
||||||
|
new Dictionary<string, byte[]>());
|
||||||
|
var drawing = new DrawingValue(
|
||||||
|
[
|
||||||
|
new DrawStringEx(0, 0, 12, 170, "MARKERTOP", DrawingTextAlignment.AlignLeft, 10),
|
||||||
|
new DrawStringEx(0, 90, 12, 170, "MARKERNEARBOTTOM", DrawingTextAlignment.AlignLeft, 10),
|
||||||
|
new DrawStringEx(0, 105, 12, 170, "MARKERAFTERBOUNDARY", DrawingTextAlignment.AlignLeft, 10),
|
||||||
|
new DrawStringEx(0, 190, 12, 170, "MARKERBOTTOM", DrawingTextAlignment.AlignLeft, 10),
|
||||||
|
], 200);
|
||||||
|
|
||||||
|
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
|
||||||
|
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Marker"] = drawing }));
|
||||||
|
using var document = UglyToad.PdfPig.PdfDocument.Open(pdf);
|
||||||
|
var pages = document.GetPages().Select(p => p.Text).ToList();
|
||||||
|
|
||||||
|
Assert.Equal(2, pages.Count);
|
||||||
|
Assert.Contains("MARKERTOP", pages[0]);
|
||||||
|
Assert.Contains("MARKERNEARBOTTOM", pages[0]);
|
||||||
|
Assert.DoesNotContain("MARKERAFTERBOUNDARY", pages[0]);
|
||||||
|
Assert.DoesNotContain("MARKERBOTTOM", pages[0]);
|
||||||
|
Assert.DoesNotContain("MARKERTOP", pages[1]);
|
||||||
|
Assert.DoesNotContain("MARKERNEARBOTTOM", pages[1]);
|
||||||
|
Assert.Contains("MARKERAFTERBOUNDARY", pages[1]);
|
||||||
|
Assert.Contains("MARKERBOTTOM", pages[1]);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FlowDrawBox_PaginertHohenDeklarativenZeichenraum()
|
public void FlowDrawBox_PaginertHohenDeklarativenZeichenraum()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ public sealed record DrawLine(float X1, float Y1, float X2, float Y2,
|
|||||||
string Color = "#000000", float StrokeWidth = 1) : DrawingCommand;
|
string Color = "#000000", float StrokeWidth = 1) : DrawingCommand;
|
||||||
public sealed record DrawRectangle(float X, float Y, float Width, float Height,
|
public sealed record DrawRectangle(float X, float Y, float Width, float Height,
|
||||||
string StrokeColor = "#000000", float StrokeWidth = 1, string FillColor = "none") : DrawingCommand;
|
string StrokeColor = "#000000", float StrokeWidth = 1, string FillColor = "none") : DrawingCommand;
|
||||||
|
public sealed record DrawRoundedRectangle(float X, float Y, float Width, float Height, float CornerRadius,
|
||||||
|
string StrokeColor = "#000000", float StrokeWidth = 1, string FillColor = "none") : DrawingCommand;
|
||||||
|
public sealed record DrawCircle(float CenterX, float CenterY, float Radius,
|
||||||
|
string StrokeColor = "#000000", float StrokeWidth = 1, string FillColor = "none") : DrawingCommand;
|
||||||
public sealed record DrawImage(float X, float Y, float Width, float Height, byte[] Data, string MimeType) : DrawingCommand;
|
public sealed record DrawImage(float X, float Y, float Width, float Height, byte[] Data, string MimeType) : DrawingCommand;
|
||||||
/// <summary>Declarative drawing supplied by an external data provider. Coordinates use the template layout unit; font sizes use points.</summary>
|
/// <summary>Declarative drawing supplied by an external data provider. Coordinates use the template layout unit; font sizes use points.</summary>
|
||||||
public sealed record DrawingValue(IReadOnlyList<DrawingCommand> Commands, float ContentHeight)
|
public sealed record DrawingValue(IReadOnlyList<DrawingCommand> Commands, float ContentHeight)
|
||||||
@@ -56,6 +60,13 @@ public sealed class DrawingCanvas
|
|||||||
public void DrawRectangle(float x, float y, float width, float height, string strokeColor = "#000000",
|
public void DrawRectangle(float x, float y, float width, float height, string strokeColor = "#000000",
|
||||||
float strokeWidth = 1, string fillColor = "none") =>
|
float strokeWidth = 1, string fillColor = "none") =>
|
||||||
_commands.Add(new LehrerApp.Templating.DrawRectangle(x, y, width, height, strokeColor, strokeWidth, fillColor));
|
_commands.Add(new LehrerApp.Templating.DrawRectangle(x, y, width, height, strokeColor, strokeWidth, fillColor));
|
||||||
|
public void DrawRoundedRectangle(float x, float y, float width, float height, float cornerRadius,
|
||||||
|
string strokeColor = "#000000", float strokeWidth = 1, string fillColor = "none") =>
|
||||||
|
_commands.Add(new LehrerApp.Templating.DrawRoundedRectangle(x, y, width, height, cornerRadius,
|
||||||
|
strokeColor, strokeWidth, fillColor));
|
||||||
|
public void DrawCircle(float centerX, float centerY, float radius, string strokeColor = "#000000",
|
||||||
|
float strokeWidth = 1, string fillColor = "none") =>
|
||||||
|
_commands.Add(new LehrerApp.Templating.DrawCircle(centerX, centerY, radius, strokeColor, strokeWidth, fillColor));
|
||||||
public void DrawImage(float x, float y, float width, float height, byte[] data, string mimeType) =>
|
public void DrawImage(float x, float y, float width, float height, byte[] data, string mimeType) =>
|
||||||
_commands.Add(new LehrerApp.Templating.DrawImage(x, y, width, height, data, mimeType));
|
_commands.Add(new LehrerApp.Templating.DrawImage(x, y, width, height, data, mimeType));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -531,22 +531,67 @@ internal static class DrawingElementRenderer
|
|||||||
};
|
};
|
||||||
container.Column(column =>
|
container.Column(column =>
|
||||||
{
|
{
|
||||||
for (var page = 0; page < pages.Count; page++)
|
// Kein manueller PageBreak() zwischen den Ausschnitten: RenderFlow sitzt in einer
|
||||||
|
// ohnehin automatisch paginierenden Column (Flow-Dokument) bzw. füllt im Legacy-Modell
|
||||||
|
// fast die ganze Seite aus. Ein erzwungener Break hier sprang unabhängig vom
|
||||||
|
// tatsächlich verbleibenden Platz auf der aktuellen physischen Seite auf eine neue -
|
||||||
|
// z.B. begann der zweite Monat der Fehlzeitenliste auf Seite 3, obwohl auf Seite 2
|
||||||
|
// noch reichlich Platz war. QuestPDF schiebt einen Ausschnitt, der nicht mehr passt,
|
||||||
|
// ohnehin automatisch auf die nächste Seite.
|
||||||
|
foreach (var page in pages)
|
||||||
{
|
{
|
||||||
if (page > 0) column.Item().PageBreak();
|
// Koordinaten sind nach Slice()/RecordPages() bereits seitenlokal (bei 0 beginnend) -
|
||||||
var offset = value is DrawingValue ? page * pageHeight : 0;
|
// kein zusätzlicher viewBox-Y-Offset nötig oder sinnvoll (siehe Slice()).
|
||||||
column.Item().Height(UnitConverter.Points(pageHeight, unit)).Svg(
|
column.Item().Height(UnitConverter.Points(pageHeight, unit)).Svg(
|
||||||
BuildSvg(pages[page], width, pageHeight, offset, unit));
|
BuildSvg(page, width, pageHeight, 0, unit));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schneidet die Zeichenbefehle anhand ihrer Y-Koordinate in Seiten auf und verschiebt sie auf
|
||||||
|
// seitenlokale Koordinaten (Y=0 am Seitenanfang). Ein SVG-Element mit "viewBox" und
|
||||||
|
// "overflow=hidden" wird von QuestPDFs Svg()-Renderer NICHT zuverlässig geclippt - ein
|
||||||
|
// (Y-)Offset im viewBox allein reicht nicht, um pro Seite nur den jeweiligen Ausschnitt zu
|
||||||
|
// zeigen; ohne dieses echte Zerschneiden landet auf jeder Seite (nahezu) der komplette
|
||||||
|
// Zeicheninhalt erneut (Bug: doppelte/überlappende Fehlzeitenliste über mehrere Seiten).
|
||||||
private static List<DrawingValue> Slice(DrawingValue value, float pageHeight)
|
private static List<DrawingValue> Slice(DrawingValue value, float pageHeight)
|
||||||
{
|
{
|
||||||
var pageCount = Math.Max(1, (int)Math.Ceiling(Math.Max(0, value.ContentHeight) / pageHeight));
|
var pageCount = Math.Max(1, (int)Math.Ceiling(Math.Max(0, value.ContentHeight) / pageHeight));
|
||||||
return Enumerable.Repeat(value, pageCount).ToList();
|
var pages = new List<List<DrawingCommand>>();
|
||||||
|
for (var i = 0; i < pageCount; i++) pages.Add([]);
|
||||||
|
var pendingLinePage = 0;
|
||||||
|
foreach (var command in value.Commands)
|
||||||
|
{
|
||||||
|
var y = CommandY(command);
|
||||||
|
if (y is null) { pages[0].Add(command); continue; }
|
||||||
|
var page = Math.Clamp((int)(y.Value / pageHeight), 0, pageCount - 1);
|
||||||
|
// LineTo gehört inhaltlich zum vorherigen MoveTo (eine Linie) - beide müssen auf
|
||||||
|
// derselben Seite landen, sonst fehlt beim Rendern der Linie der Startpunkt.
|
||||||
|
if (command is MoveTo) pendingLinePage = page;
|
||||||
|
else if (command is LineTo) page = pendingLinePage;
|
||||||
|
pages[page].Add(ShiftY(command, page * pageHeight));
|
||||||
|
}
|
||||||
|
return pages.Select(commands => new DrawingValue(commands, pageHeight)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static float? CommandY(DrawingCommand command) => command switch
|
||||||
|
{
|
||||||
|
DrawString s => s.Y, DrawStringEx s => s.Y, MoveTo m => m.Y, LineTo l => l.Y,
|
||||||
|
DrawRectangle r => r.Y, DrawRoundedRectangle r => r.Y, DrawImage i => i.Y,
|
||||||
|
DrawLine ln => Math.Min(ln.Y1, ln.Y2), DrawCircle c => c.CenterY - c.Radius, _ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static DrawingCommand ShiftY(DrawingCommand command, float dy) => command switch
|
||||||
|
{
|
||||||
|
DrawString s => s with { Y = s.Y - dy }, DrawStringEx s => s with { Y = s.Y - dy },
|
||||||
|
MoveTo m => m with { Y = m.Y - dy }, LineTo l => l with { Y = l.Y - dy },
|
||||||
|
DrawRectangle r => r with { Y = r.Y - dy }, DrawRoundedRectangle r => r with { Y = r.Y - dy },
|
||||||
|
DrawImage i => i with { Y = i.Y - dy },
|
||||||
|
DrawLine ln => ln with { Y1 = ln.Y1 - dy, Y2 = ln.Y2 - dy },
|
||||||
|
DrawCircle c => c with { CenterY = c.CenterY - dy },
|
||||||
|
_ => command,
|
||||||
|
};
|
||||||
|
|
||||||
internal static List<IReadOnlyList<DrawingCommand>> RecordPages(PagedDrawingValue value,
|
internal static List<IReadOnlyList<DrawingCommand>> RecordPages(PagedDrawingValue value,
|
||||||
float width, float height, int maxPages)
|
float width, float height, int maxPages)
|
||||||
{
|
{
|
||||||
@@ -607,6 +652,19 @@ internal static class DrawingElementRenderer
|
|||||||
svg.Append(CultureInfo.InvariantCulture,
|
svg.Append(CultureInfo.InvariantCulture,
|
||||||
$"<rect x=\"{rectangle.X}\" y=\"{rectangle.Y}\" width=\"{rectangle.Width}\" height=\"{rectangle.Height}\" stroke=\"{Attribute(rectangle.StrokeColor)}\" stroke-width=\"{rectangle.StrokeWidth}\" fill=\"{Attribute(rectangle.FillColor)}\"/>");
|
$"<rect x=\"{rectangle.X}\" y=\"{rectangle.Y}\" width=\"{rectangle.Width}\" height=\"{rectangle.Height}\" stroke=\"{Attribute(rectangle.StrokeColor)}\" stroke-width=\"{rectangle.StrokeWidth}\" fill=\"{Attribute(rectangle.FillColor)}\"/>");
|
||||||
break;
|
break;
|
||||||
|
case DrawRoundedRectangle rectangle:
|
||||||
|
Validate(rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height, rectangle.CornerRadius,
|
||||||
|
rectangle.StrokeWidth);
|
||||||
|
Positive(rectangle.Width, rectangle.Height, rectangle.CornerRadius, rectangle.StrokeWidth);
|
||||||
|
svg.Append(CultureInfo.InvariantCulture,
|
||||||
|
$"<rect x=\"{rectangle.X}\" y=\"{rectangle.Y}\" width=\"{rectangle.Width}\" height=\"{rectangle.Height}\" rx=\"{rectangle.CornerRadius}\" ry=\"{rectangle.CornerRadius}\" stroke=\"{Attribute(rectangle.StrokeColor)}\" stroke-width=\"{rectangle.StrokeWidth}\" fill=\"{Attribute(rectangle.FillColor)}\"/>");
|
||||||
|
break;
|
||||||
|
case DrawCircle circle:
|
||||||
|
Validate(circle.CenterX, circle.CenterY, circle.Radius, circle.StrokeWidth);
|
||||||
|
Positive(circle.Radius, circle.StrokeWidth);
|
||||||
|
svg.Append(CultureInfo.InvariantCulture,
|
||||||
|
$"<circle cx=\"{circle.CenterX}\" cy=\"{circle.CenterY}\" r=\"{circle.Radius}\" stroke=\"{Attribute(circle.StrokeColor)}\" stroke-width=\"{circle.StrokeWidth}\" fill=\"{Attribute(circle.FillColor)}\"/>");
|
||||||
|
break;
|
||||||
case DrawString text:
|
case DrawString text:
|
||||||
Validate(text.X, text.Y, text.FontSize);
|
Validate(text.X, text.Y, text.FontSize);
|
||||||
Positive(text.FontSize);
|
Positive(text.FontSize);
|
||||||
|
|||||||
Reference in New Issue
Block a user