Author SHA1 Message Date
adminandClaude Sonnet 5 2ccc3201d2 DatePicker durch CalendarDatePicker ersetzen (Zeitraum-Datumsfelder)
Avalonias Spinner-DatePicker übernimmt Änderungen nur über einen separaten
Häkchen-Button im Flyout und verwirft sie sonst stillschweigend. Betrifft
alle verbliebenen Zeitraum-Datumsfelder mit diesem Muster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 14:46:53 +02:00
admin ab1feba0f0 Merge remote-tracking branch 'origin/main'
CI / build-and-test (push) Canceled after 0s
# Conflicts:
#	LehrerApp.TemplateDesigner.Tests/ProjectLifecycleTests.cs
#	LehrerApp.TemplateDesigner/DesignerViewModel.cs
#	LehrerApp.TemplateDesigner/MainWindow.axaml
#	LehrerApp.Templating/LayoutParser.cs
#	LehrerApp.Templating/QuestTemplateRenderer.cs
2026-09-01 10:15:54 +02:00
admin b2d0ccd30d TemplateDesigner: Redesign des TemplateSkripts 2026-09-01 10:07:25 +02:00
admin c6efdab9ef TemplateDesigner: SystemVars
CI / build-and-test (push) Canceled after 0s
2026-08-31 23:47:48 +02:00
admin 11ef73ab1f TemplateDesigner: pdf-Export
CI / build-and-test (push) Canceled after 0s
2026-08-31 23:28:08 +02:00
admin 4b4a746e17 Update im KI-Backend für pdf-import
CI / build-and-test (push) Canceled after 0s
2026-08-31 23:20:19 +02:00
admin e2e2bfb854 feat: add external drawing boxes and paged callbacks
CI / build-and-test (push) Canceled after 0s
2026-08-31 22:10:06 +02:00
admin 3e5f197bdb feat: add flowing templates and PDF import pipeline
CI / build-and-test (push) Canceled after 0s
2026-08-31 19:56:25 +02:00
adminandClaude Sonnet 5 95e3f677e4 Klassenlehrer: Fehlquote nutzt echten WebUntis-Ferienkalender + Sommerferien-Näherung, Diagnose-Logging
CI / build-and-test (push) Canceled after 0s
Nutzer-Verifikation deckte auf, dass WebUntis' getHolidays für dieses Konto nie einen
Sommerferien-Eintrag liefert (11 Jahre Kontohistorie geprüft, kein einziger Juli-/August-Zeitraum
dabei) - vermutlich weil die Sommerferien WebUntis-intern zwischen zwei Schuljahres-Datensätzen
liegen statt in einem. Die reine Ferienkalender-Abfrage aus dem letzten Commit kann diese Lücke
also nicht schließen: der frühere heuristische Ansatz (frühester Fehlzeiten-Eintrag der Klasse als
Näherung für den ersten Unterrichtstag) ist deshalb zusätzlich wieder da, kombiniert mit dem echten
Ferienkalender für den Rest des Jahres.

Herkunft des Nenners jetzt im Tooltip sichtbar (angenommener Unterrichtsbeginn, abgezogene
Ferientage) statt nur intern verrechnet, plus Logging jedes Ferienabrufs (Erfolg oder Fehler) -
sonst wäre der falsche Wert wieder unbemerkt geblieben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 13:21:16 +02:00
adminandClaude Sonnet 5 cdbed69d8b Klassenlehrer: Verspätungen nicht mehr als fehlende Entschuldigung, echter Ferienkalender für Fehlquote
CI / build-and-test (push) Canceled after 0s
Verspätungen sind i.d.R. nicht entschuldigungsfähig - "Offene Entschuldigungen" erinnert dafür
nicht mehr, stattdessen eskaliert die Mustererkennung ab 5 Verspätungen im Schuljahr zu einem
Elterngespräch/Brief-Hinweis mit Wiedervorlage-Option.

Die Jahresfehlquote nutzte bislang den 1. August als fest verdrahteten Schuljahresbeginn und
zählte damit noch laufende Sommerferien als Schultage mit, was die Quote kurz nach
Schuljahresbeginn stark verfälschte (~57% statt ~100% bei durchgehend fehlenden Schülern). Nutzt
jetzt den echten WebUntis-Ferienkalender (GetHolidaysAsync, gecacht in WebUntisSettingsService).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 11:56:35 +02:00
admin 527c186090 Add constant rich text placeholders
CI / build-and-test (push) Canceled after 0s
2026-08-30 23:23:48 +02:00
adminandClaude Sonnet 5 28469bf547 E-Mail-Ordner-Triage als offenen Punkt in TODO.md skizzieren
CI / build-and-test (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:55:57 +02:00
admin 9dce6576e2 TemplateDesigner: UI update
CI / build-and-test (push) Canceled after 0s
2026-08-30 22:47:53 +02:00
admin 9f28081931 Fix Platzhalter Editer im TemplateDesigner
CI / build-and-test (push) Canceled after 0s
2026-08-30 22:30:26 +02:00
admin 6468596bf3 Add designer file menu and package metadata
CI / build-and-test (push) Canceled after 0s
2026-08-30 20:49:13 +02:00
50 changed files with 4093 additions and 260 deletions
+1
View File
@@ -20,6 +20,7 @@
<!-- PDF-Erzeugung: Desktop-Exporte und die unabhängige Templating-Library. --> <!-- PDF-Erzeugung: Desktop-Exporte und die unabhängige Templating-Library. -->
<PackageVersion Include="QuestPDF" Version="2025.7.0" /> <PackageVersion Include="QuestPDF" Version="2025.7.0" />
<PackageVersion Include="PDFtoImage" Version="5.4.0" /> <PackageVersion Include="PDFtoImage" Version="5.4.0" />
<PackageVersion Include="PdfPig" Version="0.1.13" />
<!-- API --> <!-- API -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
@@ -184,7 +184,8 @@ public sealed class ClassTeacherViewModelsTests
}; };
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20)); new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20,
termStart: new DateOnly(2026, 8, 1)));
Assert.True(row.HasYearSummary); Assert.True(row.HasYearSummary);
Assert.Equal(2, row.YearAbsenceDayCount); Assert.Equal(2, row.YearAbsenceDayCount);
@@ -192,9 +193,29 @@ public sealed class ClassTeacherViewModelsTests
Assert.Equal(10, row.YearAbsenceRatePercent); Assert.Equal(10, row.YearAbsenceRatePercent);
Assert.Contains("10 %", row.YearSummaryLabel); Assert.Contains("10 %", row.YearSummaryLabel);
Assert.Contains("2 von 20", row.YearSummaryTooltip); Assert.Contains("2 von 20", row.YearSummaryTooltip);
Assert.Contains("seit 01.08.", row.YearSummaryTooltip);
Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip); Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip);
} }
[Fact]
public void RosterBuild_ZeigtAbgezogeneFerientageImTooltip()
{
// Nutzer-Nachfrage: die Herkunft des Nenners soll sich ohne Blick in die WebUntis-Ferienliste
// direkt in der App nachvollziehen lassen, nachdem er zeitweise Ferientage mitzählte.
var students = new[] { Student(1001, "Ada Müller") };
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 2, 90,
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 14, holidayWeekdaysExcluded: 7,
termStart: new DateOnly(2026, 8, 1)));
Assert.Contains("7 Ferientage abgezogen", row.YearSummaryTooltip);
}
[Fact] [Fact]
public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote() public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote()
{ {
@@ -264,6 +285,79 @@ public sealed class ClassTeacherViewModelsTests
Assert.Equal(row.StatusText, row.StatusTextWithGlyph); Assert.Equal(row.StatusText, row.StatusTextWithGlyph);
} }
// ── Nenner der Jahresfehlquote: echter WebUntis-Ferienkalender statt grober Werktagszählung ──
[Fact]
public void CountSchoolWeekdays_SchliesstFerientageAus()
{
// 1.-31.08.2026 hat 21 Werktage; die (fiktiven) Sommerferien bis 11.08. nehmen davon 7
// Werktage weg (Mo 03. - Fr 07., Mo 10. - Di 11.) - Nutzer-Feedback: Schüler, die seit
// Unterrichtsbeginn jeden Tag fehlten, kamen wegen dieser mitgezählten Ferienzeit nur auf
// ~57 % statt ~100 % Fehlquote.
var holidays = new[] { new CachedUntisHoliday("Sommerferien", new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 11)) };
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), holidays);
Assert.Equal(14, schoolDays);
}
[Fact]
public void CountSchoolWeekdays_OhneFerienVerhaeltSichWieReineWerktagszaehlung()
{
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), []);
Assert.Equal(21, schoolDays);
}
[Fact]
public void EstimateTermStart_NutztFruehestenFehlzeitEintragDerGanzenKlasse()
{
// Realer Befund (Nutzer-Feedback): WebUntis' getHolidays liefert für Bremen nie einen
// Sommerferien-Eintrag (über 11 Jahre Kontohistorie geprüft, kein einziger Juli-/August-
// Zeitraum dabei) - vermutlich weil die Sommerferien WebUntis-intern zwischen zwei
// Schuljahres-Datensätzen liegen (1.8./31.7.-Grenze), nicht "in" einem davon. Für den
// Schuljahresbeginn bleibt deshalb weiterhin der früheste Fehlzeiten-Eintrag der Klasse
// nötig statt CountSchoolWeekdays allein.
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 13), "Fehlt Cem", 1001, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 19), "Andere Ada", 1002, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var termStart = ClassTeacherOverviewViewModel.EstimateTermStart(absences, new DateOnly(2026, 8, 1));
Assert.Equal(new DateOnly(2026, 8, 13), termStart);
}
[Fact]
public void EstimateTermStart_FaelltOhneFehlzeitenAufSchuljahresbeginnZurueck()
{
var termStart = ClassTeacherOverviewViewModel.EstimateTermStart([], new DateOnly(2026, 8, 1));
Assert.Equal(new DateOnly(2026, 8, 1), termStart);
}
[Fact]
public void CountSchoolWeekdays_AbEchtemTerminstartOhneSommerferienEintragStimmtMitBeobachtungUeberein()
{
// Regressionstest für den konkret gemeldeten Fall: 13 tatsächliche Schultage seit
// Unterrichtsbeginn (13.08.2026, ein Donnerstag) bis 31.08.2026, keine WebUntis-Ferien im
// Bereich (die nächste, "Herbstferien", liegt erst im Oktober) - Kombination aus
// EstimateTermStart (Sommerferien-Lücke) und CountSchoolWeekdays (übrige Ferien) muss auf
// die vom Nutzer nachgezählte Zahl kommen, nicht auf die volle Werktagszahl ab 1.8. (21).
var termStart = new DateOnly(2026, 8, 13);
var noHolidaysInRange = Array.Empty<CachedUntisHoliday>();
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
termStart, new DateOnly(2026, 8, 31), noHolidaysInRange);
Assert.Equal(13, schoolDays);
}
// ── Offene Entschuldigungen (Feature-Idee 2) ───────────────────────────── // ── Offene Entschuldigungen (Feature-Idee 2) ─────────────────────────────
[Fact] [Fact]
@@ -290,6 +384,87 @@ public sealed class ClassTeacherViewModelsTests
Assert.False(rows[1].IsOverdue); Assert.False(rows[1].IsOverdue);
} }
[Fact]
public void OpenExcuseBuild_IgnoriertUnentschuldigteVerspaetungen()
{
// Verspätungen sind i.d.R. nicht entschuldigungsfähig - "unentschuldigt" darf hier keine
// Erinnerung an eine fehlende Entschuldigung auslösen (Nutzer-Feedback).
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 0, 15,
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1003, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
};
var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26));
Assert.Equal(["Cem Fehlt"], rows.Select(r => r.StudentName));
}
// ── Verspätungsmuster: Elterngespräch-Eskalation (Nutzer-Feedback) ───────
[Fact]
public void DetectLatePatterns_EskaliertBeiHoherJahressummeMitWiedervorlageOption()
{
var absences = Enumerable.Range(0, 5)
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Spaet Timo", 1001, 0, 10,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false))
.ToArray();
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
absences, names, sevenDayStart: new DateOnly(2026, 8, 31));
var notice = Assert.Single(notices);
Assert.Equal("Timo Spät", notice.StudentName);
Assert.Contains("5 Verspätungen seit Schuljahresbeginn", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
Assert.True(notice.CanCreateReminder);
}
[Fact]
public void DetectLatePatterns_KurzfristigesClusterBleibtHinweisOhneAktion()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Spaet Timo", 1001, 0, 10,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Spaet Timo", 1001, 0, 10,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
var notice = Assert.Single(notices);
Assert.Equal("2-mal verspätet in 7 Tagen", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Warning, notice.Kind);
Assert.False(notice.CanCreateReminder);
}
[Fact]
public void DetectLatePatterns_UnentschuldigteFehltageGehenVorVerspaetungseskalation()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1001, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Fehlt Cem", 1001, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Fehlt Cem")] = "Cem Fehlt" };
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
var notice = Assert.Single(notices);
Assert.Contains("unentschuldigte Fehltage", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
Assert.False(notice.CanCreateReminder);
}
// ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ────────────────── // ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ──────────────────
[Fact] [Fact]
@@ -41,6 +41,19 @@ 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));
} }
[Fact]
public void KonstanterPflichttext_BenoetigtKeineExterneEingabe()
{
var store = StoreWithTemplate(new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline,
Required: true, IsConstant: true, ConstantValue: "Fest im Vorlagenpaket"));
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
var output = Path.Combine(_directory, "Konstant.pdf");
Assert.True(vm.CanGenerate);
Assert.True(vm.Generate(output));
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
}
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([]));
@@ -5,6 +5,7 @@ namespace LehrerApp.Desktop.Services;
public sealed class WebUntisIntegrationException(string message) : Exception(message); public sealed class WebUntisIntegrationException(string message) : Exception(message);
public sealed record UntisSchoolYearDto(int UntisId, string Name, int StartDate, int EndDate); public sealed record UntisSchoolYearDto(int UntisId, string Name, int StartDate, int EndDate);
public sealed record UntisHolidayDto(int UntisId, string Name, string? LongName, int StartDate, int EndDate);
public sealed record UntisClassDto(int UntisId, string Name, string? LongName); public sealed record UntisClassDto(int UntisId, string Name, string? LongName);
public sealed record UntisTeacherDto(int UntisId, string Name, string? ForeName, string? LongName, string? Title, public sealed record UntisTeacherDto(int UntisId, string Name, string? ForeName, string? LongName, string? Title,
bool Active, IReadOnlyList<int> DepartmentUntisIds) bool Active, IReadOnlyList<int> DepartmentUntisIds)
@@ -81,6 +82,10 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
async client => (IReadOnlyList<UntisSchoolYearDto>)(await client.GetSchoolYearsAsync(token)) async client => (IReadOnlyList<UntisSchoolYearDto>)(await client.GetSchoolYearsAsync(token))
.Select(x => new UntisSchoolYearDto(x.UntisId, x.Name, x.StartDate, x.EndDate)).ToList(), token); .Select(x => new UntisSchoolYearDto(x.UntisId, x.Name, x.StartDate, x.EndDate)).ToList(), token);
public Task<IReadOnlyList<UntisHolidayDto>> GetHolidaysAsync(CancellationToken token = default) => ExecuteAsync(
async client => (IReadOnlyList<UntisHolidayDto>)(await client.GetHolidaysAsync(token))
.Select(x => new UntisHolidayDto(x.UntisId, x.Name, x.LongName, x.StartDate, x.EndDate)).ToList(), token);
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) => ExecuteAsync( public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) => ExecuteAsync(
async client => (IReadOnlyList<UntisClassDto>)(await client.GetClassesAsync(schoolYearId, token)) async client => (IReadOnlyList<UntisClassDto>)(await client.GetClassesAsync(schoolYearId, token))
.Select(x => new UntisClassDto(x.UntisId, x.Name, x.LongName)).ToList(), token); .Select(x => new UntisClassDto(x.UntisId, x.Name, x.LongName)).ToList(), token);
@@ -13,10 +13,17 @@ internal class WebUntisSettingsConfig
public int? TeacherUntisId { get; set; } public int? TeacherUntisId { get; set; }
public int? HomeroomClassUntisId { get; set; } public int? HomeroomClassUntisId { get; set; }
public string? HomeroomClassName { get; set; } public string? HomeroomClassName { get; set; }
public List<CachedUntisHoliday>? CachedHolidays { get; set; }
public DateTime? HolidaysFetchedAt { get; set; }
} }
public sealed record WebUntisCredentials(string School, string Host, string Username, string Password); public sealed record WebUntisCredentials(string School, string Host, string Username, string Password);
/// <summary>Ferienzeitraum aus WebUntis' <c>getHolidays</c>-Bericht, hier auf die für die
/// Fehlquoten-Berechnung ("Klassenlehrer"-Feature) relevanten Felder reduziert. Kein Geheimnis
/// (anders als iCal-URL/API-Zugangsdaten in dieser Datei), deshalb unverschlüsselt gecacht.</summary>
public sealed record CachedUntisHoliday(string Name, DateOnly Start, DateOnly End);
/// <summary> /// <summary>
/// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie /// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie
/// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die /// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die
@@ -115,6 +122,16 @@ public class WebUntisSettingsService
Save(); Save();
} }
public DateTime? HolidaysFetchedAt => _config.HolidaysFetchedAt;
public IReadOnlyList<CachedUntisHoliday>? GetCachedHolidays() => _config.CachedHolidays;
public void SetCachedHolidays(IReadOnlyList<CachedUntisHoliday> holidays, DateTime at)
{
_config.CachedHolidays = holidays.ToList();
_config.HolidaysFetchedAt = at;
Save();
}
private byte[] GenerateAndSaveKey() private byte[] GenerateAndSaveKey()
{ {
var key = SyncCrypto.GenerateKey(); var key = SyncCrypto.GenerateKey();
@@ -71,19 +71,29 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
}; };
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein /// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt nur /// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt Werktage
/// Werktage, ohne Ferienkalender — eine bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag. /// abzüglich der über WebUntis geladenen Ferien (<see cref="ClassTeacherOverviewViewModel.CountSchoolWeekdays"/>).
public int YearAbsenceDayCount { get; init; } public int YearAbsenceDayCount { get; init; }
public int YearUnexcusedDayCount { get; init; } public int YearUnexcusedDayCount { get; init; }
public int SchoolDaysElapsed { get; init; } public int SchoolDaysElapsed { get; init; }
/// Nutzer-Feedback: nachdem der Nenner zeitweise Ferientage mitzählte (siehe TODO.md), soll die
/// Herkunft der Zahl nachvollziehbar bleiben, ohne dafür die WebUntis-Ferienliste separat
/// nachschlagen zu müssen — deshalb hier sichtbar im Tooltip statt nur intern verrechnet.
public int HolidayWeekdaysExcluded { get; init; }
/// Angenommener erster Unterrichtstag (<see cref="ClassTeacherOverviewViewModel.EstimateTermStart"/>),
/// aus demselben Nachvollziehbarkeits-Grund wie <see cref="HolidayWeekdaysExcluded"/> im Tooltip
/// sichtbar — WebUntis' Ferienkalender deckt die Sommerferien selbst nicht ab (siehe TODO.md),
/// die Korrektur passiert also am Startpunkt, nicht an abgezogenen Tagen mittendrin.
public DateOnly TermStart { get; init; }
public bool HasYearSummary => SchoolDaysElapsed > 0 && YearAbsenceDayCount > 0; public bool HasYearSummary => SchoolDaysElapsed > 0 && YearAbsenceDayCount > 0;
public int YearAbsenceRatePercent => public int YearAbsenceRatePercent =>
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed); SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
public string YearSummaryLabel => HasYearSummary public string YearSummaryLabel => HasYearSummary
? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : ""; ? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : "";
public string? YearSummaryTooltip => !HasYearSummary ? null : public string? YearSummaryTooltip => !HasYearSummary ? null :
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen mit Fehlzeit" + $"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} mit Fehlzeit" +
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : ""); (YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "") +
(HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : "");
private static bool IsLateReason(string reason) => private static bool IsLateReason(string reason) =>
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase); reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
@@ -94,7 +104,9 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries, IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries,
DateOnly? today = null, DateOnly? today = null,
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null, IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
int schoolDaysElapsed = 0) int schoolDaysElapsed = 0,
int holidayWeekdaysExcluded = 0,
DateOnly termStart = default)
{ {
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today); var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null) var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
@@ -126,6 +138,8 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
YearAbsenceDayCount = yearEntries.Count, YearAbsenceDayCount = yearEntries.Count,
YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused), YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused),
SchoolDaysElapsed = schoolDaysElapsed, SchoolDaysElapsed = schoolDaysElapsed,
HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
TermStart = termStart,
}; };
}) })
.OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList(); .OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList();
@@ -157,7 +171,7 @@ public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int U
$"{UnexcusedCount} unentschuldigt · {LateExcusedCount} verspätet · {ExcusedCount} entschuldigt"; $"{UnexcusedCount} unentschuldigt · {LateExcusedCount} verspätet · {ExcusedCount} entschuldigt";
} }
public sealed record ClassTeacherPatternNotice(string StudentName, string Message, public sealed record ClassTeacherPatternNotice(string StudentName, string Message,
ClassTeacherStatusKind Kind) ClassTeacherStatusKind Kind, bool CanCreateReminder = false)
{ {
public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger; public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger;
public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning; public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning;
@@ -182,7 +196,10 @@ public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date
public static IReadOnlyList<ClassTeacherOpenExcuseRow> Build( public static IReadOnlyList<ClassTeacherOpenExcuseRow> Build(
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today) => IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today) =>
absenceDays.Where(a => a.IsUnexcused) // Verspätungen ausgeschlossen: dafür gibt es i.d.R. keine Entschuldigungspflicht, eine
// "Entschuldigung fehlt"-Erinnerung wäre hier gegenstandslos. Wiederholte Verspätungen
// laufen stattdessen über die Mustererkennung (siehe BuildPatternNotices).
absenceDays.Where(a => a.IsUnexcused && !a.IsLate)
.Select(a => new ClassTeacherOpenExcuseRow(a.StudentDisplayName, a.Date, .Select(a => new ClassTeacherOpenExcuseRow(a.StudentDisplayName, a.Date,
today.DayNumber - a.Date.DayNumber)) today.DayNumber - a.Date.DayNumber))
.OrderByDescending(r => r.DaysOpen).ThenBy(r => r.StudentName) .OrderByDescending(r => r.DaysOpen).ThenBy(r => r.StudentName)
@@ -192,12 +209,14 @@ public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date
public partial class ClassTeacherOverviewViewModel : ObservableObject public partial class ClassTeacherOverviewViewModel : ObservableObject
{ {
private readonly WebUntisSettingsService _settings; private readonly WebUntisSettingsService _settings;
private readonly WebUntisIntegrationService _untis;
private readonly UntisReportCacheService _cache; private readonly UntisReportCacheService _cache;
private readonly SchoolYearService _schoolYear; private readonly SchoolYearService _schoolYear;
private readonly IWorkTaskRepository _workTasks; private readonly IWorkTaskRepository _workTasks;
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IParticipationRepository _participation; private readonly IParticipationRepository _participation;
private readonly IParticipationSessionRepository _participationSessions; private readonly IParticipationSessionRepository _participationSessions;
private readonly AppLogger? _logger;
public ClassTeacherDetailsViewModel DetailsTab { get; } public ClassTeacherDetailsViewModel DetailsTab { get; }
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = []; public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
@@ -268,18 +287,21 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public Func<Task>? OnNavigateToSettings { get; set; } public Func<Task>? OnNavigateToSettings { get; set; }
public Func<Task>? OnNavigateToWorkload { get; set; } public Func<Task>? OnNavigateToWorkload { get; set; }
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks, UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
IStudentRepository students, IParticipationRepository participation, IStudentRepository students, IParticipationRepository participation,
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab) IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
AppLogger? logger = null)
{ {
_settings = settings; _settings = settings;
_untis = untis;
_cache = cache; _cache = cache;
_schoolYear = schoolYear; _schoolYear = schoolYear;
_workTasks = workTasks; _workTasks = workTasks;
_students = students; _students = students;
_participation = participation; _participation = participation;
_participationSessions = participationSessions; _participationSessions = participationSessions;
_logger = logger;
DetailsTab = detailsTab; DetailsTab = detailsTab;
} }
@@ -325,14 +347,18 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
var studentsTask = _cache.GetStudentRosterAsync(className); var studentsTask = _cache.GetStudentRosterAsync(className);
var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today); var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, sevenDayStart, today); var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, sevenDayStart, today);
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask); var holidaysTask = GetHolidaysAsync();
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask);
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result); var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList(); var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
var weekAbsenceDays = absenceDaysYear.Where(a => a.Date >= sevenDayStart).ToList(); var termStart = EstimateTermStart(absenceDaysYear, yearStart);
var schoolDaysElapsed = CountWeekdays(yearStart, today); var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result);
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences, foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed)) Roster.Add(row); classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed,
holidayWeekdaysExcluded, termStart)) Roster.Add(row);
StudentCount = Roster.Count; StudentCount = Roster.Count;
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday); TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
@@ -343,7 +369,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused); UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
RecentClassRegisterCount = classRegisterTask.Result.Count; RecentClassRegisterCount = classRegisterTask.Result.Count;
BuildTrend(absenceDaysYear, trendDays); BuildTrend(absenceDaysYear, trendDays);
BuildPatternNotices(weekAbsenceDays); BuildPatternNotices(absenceDaysYear, sevenDayStart);
BuildWeekdayPatternNotices(absenceDaysYear); BuildWeekdayPatternNotices(absenceDaysYear);
BuildAttendanceParticipationNotices(); BuildAttendanceParticipationNotices();
BuildOpenExcuses(absenceDaysYear, today); BuildOpenExcuses(absenceDaysYear, today);
@@ -451,34 +477,123 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
return days; return days;
} }
/// Nenner für <see cref="ClassTeacherRosterRow.YearAbsenceRatePercent"/> — Werktage zwischen /// Nutzer-Feedback: Schüler*innen, die seit Unterrichtsbeginn nachweislich an jedem Tag
/// Schuljahresbeginn und heute, ebenfalls ohne Ferienkalender. /// fehlten, zeigten trotzdem nur ~57 % Fehlquote statt der erwarteten ~100 %. Ursache: der
private static int CountWeekdays(DateOnly start, DateOnly end) /// Nenner zählte bislang jeden Werktag ab dem fest verdrahteten 1. August
/// (<see cref="SchoolYearService.SchoolYearStart"/>) als "Schultag" mit — die Sommerferien
/// enden je nach Bundesland/Jahr aber erst Wochen später, und genau zu Schuljahresbeginn macht
/// diese Ferienzeit einen großen Teil des bis dahin "verstrichenen" Zeitraums aus. WebUntis
/// kennt einen Teil des echten Ferienkalenders (<c>getHolidays</c>-Bericht, hier zwischen den
/// Werktagen ausgeschlossen) und behebt damit die kleineren Verzerrungen durch Herbst-/
/// Weihnachts-/Osterferien im weiteren Jahresverlauf. Die Sommerferien selbst liefert
/// <c>getHolidays</c> nach Prüfung der echten Antwort für dieses Konto aber NIE (über 11 Jahre
/// zurück kein einziger Sommerferien-Eintrag, siehe <see cref="EstimateTermStart"/>) — sie
/// liegen WebUntis-intern vermutlich außerhalb jedes Schuljahres-Datensatzes (der bei 1.8./31.7.
/// endet), nicht "in" einem davon. Für die Sommerferien bleibt deshalb weiterhin
/// <see cref="EstimateTermStart"/> nötig, das den Startpunkt selbst korrigiert statt Tage
/// innerhalb des Zeitraums abzuziehen.
public static int CountSchoolWeekdays(DateOnly start, DateOnly end, IReadOnlyList<CachedUntisHoliday> holidays)
{ {
if (end < start) return 0; if (end < start) return 0;
var count = 0; var count = 0;
for (var d = start; d <= end; d = d.AddDays(1)) for (var d = start; d <= end; d = d.AddDays(1))
if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)) count++; if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday) &&
!holidays.Any(h => d >= h.Start && d <= h.End))
count++;
return count; return count;
} }
private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays) /// Ergänzt <see cref="CountSchoolWeekdays"/> um genau die Lücke, die WebUntis' echter
/// Ferienkalender nicht schließt: die Sommerferien. Nimmt den frühesten Tag mit irgendeinem
/// Fehlzeiten-Eintrag der ganzen Klasse als Näherung für den tatsächlichen ersten
/// Unterrichtstag (ein solcher Eintrag kann nur an einem Tag mit tatsächlich stattfindendem
/// Unterricht entstehen) — Rückfall auf <paramref name="fallback"/>, wenn noch keine
/// Fehlzeiten vorliegen (dann bleibt <see cref="ClassTeacherRosterRow.HasYearSummary"/>
/// ohnehin ausgeblendet).
public static DateOnly EstimateTermStart(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear, DateOnly fallback) =>
absenceDaysYear.Count > 0 ? absenceDaysYear.Min(a => a.Date) : fallback;
/// Ferien ändern sich innerhalb eines Schuljahrs praktisch nie (anders als die
/// Fehlzeiten-/Klassenbuchberichte, deshalb hier keine der stündlichen "heißes Fenster"-Logik
/// aus <see cref="UntisReportCacheService"/>, sondern ein einfacher tagesgenauer Cache über
/// <see cref="WebUntisSettingsService"/> — kein zusätzliches LiteDB-Repository nötig). Schlägt
/// der Live-Abruf fehl (z. B. kurzzeitig kein Netz), wird der zuletzt bekannte Stand
/// weiterverwendet statt die ganze Übersicht mit einem Fehler zu blockieren; ist noch nie
/// erfolgreich abgerufen worden, bleibt die Liste leer und <see cref="CountSchoolWeekdays"/>
/// verhält sich wie vorher (reine Werktagszählung ohne Ferienabzug).
private static readonly TimeSpan HolidaysRefreshInterval = TimeSpan.FromDays(1);
private async Task<IReadOnlyList<CachedUntisHoliday>> GetHolidaysAsync()
{ {
foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName))) var cached = _settings.GetCachedHolidays();
if (cached is not null && _settings.HolidaysFetchedAt is { } fetchedAt &&
DateTime.UtcNow - fetchedAt < HolidaysRefreshInterval)
return cached;
try
{
var fresh = (await _untis.GetHolidaysAsync())
.Select(h => new CachedUntisHoliday(h.Name, ParseDate(h.StartDate), ParseDate(h.EndDate)))
.ToList();
_settings.SetCachedHolidays(fresh, DateTime.UtcNow);
_logger?.Info("Klassenlehrer: WebUntis-Ferien geladen — " +
string.Join("; ", fresh.Select(h => $"{h.Name} {h.Start:yyyy-MM-dd}..{h.End:yyyy-MM-dd}")));
return fresh;
}
catch (WebUntisIntegrationException ex)
{
_logger?.Error("Klassenlehrer: WebUntis-Ferienabruf fehlgeschlagen", ex);
return cached ?? [];
}
}
private static DateOnly ParseDate(int value) => DateOnly.ParseExact(value.ToString(), "yyyyMMdd");
private const int LateYearEscalationThreshold = 5;
private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear, DateOnly sevenDayStart)
{
var displayNames = Roster.GroupBy(r => UntisNameMatching.NameKey(r.StudentName))
.ToDictionary(g => g.Key, g => g.First().StudentName);
foreach (var notice in DetectLatePatterns(absenceDaysYear, displayNames, sevenDayStart))
PatternNotices.Add(notice);
OnPropertyChanged(nameof(HasPatternNotices));
}
/// Verspätungen sind i.d.R. nicht entschuldigungsfähig (siehe <see cref="ClassTeacherOpenExcuseRow.Build"/>)
/// — eine Häufung ist trotzdem ein Signal, nur eines für ein Elterngespräch/einen Brief statt
/// für eine fehlende Entschuldigung. Zwei Stufen, Nutzer-Feedback: ein kurzfristiges Cluster
/// (<paramref name="sevenDayStart"/>..heute) bleibt ein sanfter Hinweis ohne Aktion, eine hohe
/// Jahressumme eskaliert zu Danger mit Wiedervorlage-Option
/// (<see cref="CreateReminderForPatternNoticeCommand"/>). Reine, ohne ViewModel-Zustand
/// testbare Kernlogik (gleiches Muster wie <see cref="DetectWeekdayPatterns"/>).
public static IReadOnlyList<ClassTeacherPatternNotice> DetectLatePatterns(
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear,
IReadOnlyDictionary<string, string> displayNamesByKey,
DateOnly sevenDayStart,
int lateYearEscalationThreshold = LateYearEscalationThreshold)
{
var notices = new List<ClassTeacherPatternNotice>();
foreach (var group in absenceDaysYear.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
{ {
var rows = group.ToList(); var rows = group.ToList();
var displayName = Roster.FirstOrDefault(row => var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName;
UntisNameMatching.NameKey(row.StudentName) == group.Key)?.StudentName ?? rows[0].StudentDisplayName; var weekRows = rows.Where(row => row.Date >= sevenDayStart).ToList();
var lateDays = rows.Count(row => row.IsLate); var lateDaysWeek = weekRows.Count(row => row.IsLate);
var unexcusedDays = rows.Count(row => row.IsUnexcused && !row.IsLate); var unexcusedDaysWeek = weekRows.Count(row => row.IsUnexcused && !row.IsLate);
if (unexcusedDays >= 2) var lateDaysYear = rows.Count(row => row.IsLate);
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
$"{unexcusedDays} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger)); if (unexcusedDaysWeek >= 2)
else if (lateDays >= 2) notices.Add(new ClassTeacherPatternNotice(displayName,
PatternNotices.Add(new ClassTeacherPatternNotice(displayName, $"{unexcusedDaysWeek} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger));
$"{lateDays}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning)); else if (lateDaysYear >= lateYearEscalationThreshold)
notices.Add(new ClassTeacherPatternNotice(displayName,
$"{lateDaysYear} Verspätungen seit Schuljahresbeginn Elterngespräch oder Brief erwägen",
ClassTeacherStatusKind.Danger, CanCreateReminder: true));
else if (lateDaysWeek >= 2)
notices.Add(new ClassTeacherPatternNotice(displayName,
$"{lateDaysWeek}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning));
} }
OnPropertyChanged(nameof(HasPatternNotices)); return notices;
} }
/// Erweiterte Mustererkennung (Nutzer-Feedback): die obigen Regeln schauen nur auf die letzten /// Erweiterte Mustererkennung (Nutzer-Feedback): die obigen Regeln schauen nur auf die letzten
@@ -635,6 +750,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
urgent: row.IsOverdue); urgent: row.IsOverdue);
} }
[RelayCommand]
private void CreateReminderForPatternNotice(ClassTeacherPatternNotice? notice)
{
if (notice is null || !notice.CanCreateReminder) return;
SaveReminder(notice.StudentName, notice.Message, urgent: true);
}
private void SaveReminder(string studentName, string notes, bool urgent) private void SaveReminder(string studentName, string notes, bool urgent)
{ {
var task = new WorkTask var task = new WorkTask
@@ -54,8 +54,8 @@ public partial class SettingsViewModel
private LetterTemplateListItem CreateItem(InstalledTemplate template) private LetterTemplateListItem CreateItem(InstalledTemplate template)
{ {
var loaded = _letterTemplates.Load(template); var loaded = _letterTemplates.Load(template);
return new(template, loaded.Manifest.SchemaVersion, loaded.Manifest.Placeholders.Count, return new(template, loaded.Manifest.SchemaVersion, loaded.Manifest.Placeholders.Count(x => !x.IsConstant),
loaded.Manifest.Placeholders.Count(x => x.Required)); loaded.Manifest.Placeholders.Count(x => !x.IsConstant && x.Required));
} }
} }
@@ -77,7 +77,8 @@ public partial class CreateLetterDialogViewModel : ObservableObject
var loaded = _templates.Load(SelectedTemplate.Model); var values = BuildValues(); var loaded = _templates.Load(SelectedTemplate.Model); var values = BuildValues();
var validation = new TemplateLoader().Validate(loaded, values.ToDictionary(x => x.Key, x => x.Value.Type)); var validation = new TemplateLoader().Validate(loaded, values.ToDictionary(x => x.Key, x => x.Value.Type));
foreach (var issue in validation.Issues) Issues.Add(new(issue.Message, issue.Severity == ValidationSeverity.Error)); foreach (var issue in validation.Issues) Issues.Add(new(issue.Message, issue.Severity == ValidationSeverity.Error));
foreach (var required in loaded.Manifest.Placeholders.Where(x => x.Required && values.TryGetValue(x.Name, out var value) && IsEmpty(value))) foreach (var required in loaded.Manifest.Placeholders.Where(x => !x.IsConstant && x.Required
&& values.TryGetValue(x.Name, out var value) && IsEmpty(value)))
Issues.Add(new($"Für das Pflichtfeld „{required.Name}“ ist kein Wert vorhanden.", true)); Issues.Add(new($"Für das Pflichtfeld „{required.Name}“ ist kein Wert vorhanden.", true));
} }
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
@@ -45,9 +45,9 @@
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/> <ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
</ComboBox> </ComboBox>
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/> <TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
<DatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/> <CalendarDatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/>
<TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/> <TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/>
<DatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/> <CalendarDatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/>
</Grid> </Grid>
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8"> <Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/> <TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
@@ -439,7 +439,7 @@
<ItemsControl ItemsSource="{Binding PatternNotices}"> <ItemsControl ItemsSource="{Binding PatternNotices}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherPatternNotice"> <DataTemplate x:DataType="vm:ClassTeacherPatternNotice">
<Grid ColumnDefinitions="4,*" Margin="0,3"> <Grid ColumnDefinitions="4,*,Auto" Margin="0,3">
<Border CornerRadius="2" Margin="0,1,8,1" Classes="statusFill" <Border CornerRadius="2" Margin="0,1,8,1" Classes="statusFill"
Classes.info="{Binding IsInfoStatus}" Classes.info="{Binding IsInfoStatus}"
Classes.warning="{Binding IsWarningStatus}" Classes.warning="{Binding IsWarningStatus}"
@@ -448,6 +448,11 @@
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/> <TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Message}" FontSize="11" Opacity="0.62" TextWrapping="Wrap"/> <TextBlock Text="{Binding Message}" FontSize="11" Opacity="0.62" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<Button Grid.Column="2" Classes="rosterRowAction" Content="+"
IsVisible="{Binding CanCreateReminder}"
ToolTip.Tip="Wiedervorlage „Eltern kontaktieren&#8220; anlegen"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).CreateReminderForPatternNoticeCommand}"
CommandParameter="{Binding}"/>
</Grid> </Grid>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
@@ -45,9 +45,9 @@
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/> <ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
</ComboBox> </ComboBox>
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/> <TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
<DatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/> <CalendarDatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/>
<TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/> <TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/>
<DatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/> <CalendarDatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/>
</Grid> </Grid>
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8"> <Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/> <TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
@@ -18,7 +18,7 @@
<DataTemplate x:DataType="svc:UntisTeacherDto"><TextBlock Text="{Binding DisplayName}"/></DataTemplate> <DataTemplate x:DataType="svc:UntisTeacherDto"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
<DatePicker Grid.Column="1" SelectedDate="{Binding WeekDate}"/> <CalendarDatePicker Grid.Column="1" SelectedDate="{Binding WeekDate}"/>
<Button Grid.Column="2" Content="Woche laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/> <Button Grid.Column="2" Content="Woche laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</Grid> </Grid>
<ScrollViewer Grid.Row="2"> <ScrollViewer Grid.Row="2">
@@ -12,9 +12,9 @@
Text="Nur eigene WebUntis-Einträge (Benutzer = eigener Login). Zeilen ohne automatische Zuordnung bitte manuell einem/einer Schüler*in zuweisen. Bereits lokal vorhandene Einträge sind gesperrt."/> Text="Nur eigene WebUntis-Einträge (Benutzer = eigener Login). Zeilen ohne automatische Zuordnung bitte manuell einem/einer Schüler*in zuweisen. Bereits lokal vorhandene Einträge sind gesperrt."/>
</StackPanel> </StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8"> <StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
<DatePicker SelectedDate="{Binding StartDate}"/> <CalendarDatePicker SelectedDate="{Binding StartDate}"/>
<TextBlock Text="bis" VerticalAlignment="Center"/> <TextBlock Text="bis" VerticalAlignment="Center"/>
<DatePicker SelectedDate="{Binding EndDate}"/> <CalendarDatePicker SelectedDate="{Binding EndDate}"/>
<Button Content="Klassenbucheinträge laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/> <Button Content="Klassenbucheinträge laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel> </StackPanel>
@@ -70,4 +70,40 @@ public sealed class OverlayEditorTests
Assert.Equal(200, viewModel.OverlayPageHeight); Assert.Equal(200, viewModel.OverlayPageHeight);
Assert.Equal("pt", viewModel.OverlayPageUnit); Assert.Equal("pt", viewModel.OverlayPageUnit);
} }
[Fact]
public void FlowSlotVerschieben_AendertNurDasPragmaDerAusgewaehltenSeite()
{
var viewModel = new DesignerViewModel();
var layout = new LehrerApp.Templating.LayoutParser().Parse(viewModel.LayoutSource);
var firstSlot = layout.PageTemplates.Single(x => x.Name == "first").FlowSlots.Single();
var continuationSlot = layout.PageTemplates.Single(x => x.Name == "continuation").FlowSlots.Single();
viewModel.ApplyElementGeometry(new(firstSlot.Line, "FLOW", 25, 95, 160, 165));
var updated = new LehrerApp.Templating.LayoutParser().Parse(viewModel.LayoutSource);
var updatedFirst = updated.PageTemplates.Single(x => x.Name == "first").FlowSlots.Single();
var updatedContinuation = updated.PageTemplates.Single(x => x.Name == "continuation").FlowSlots.Single();
Assert.Equal(25, updatedFirst.X);
Assert.Equal(95, updatedFirst.Y);
Assert.Equal(continuationSlot.X, updatedContinuation.X);
Assert.Equal(continuationSlot.Y, updatedContinuation.Y);
}
[Fact]
public void ElementKannGezieltInContentFlowEingefuegtWerden()
{
var viewModel = new DesignerViewModel
{
NewElementScope = "Content-Flow", SelectedContentFlow = "body",
NewElementType = "TEXT", NewContent = "Nachsatz", NewAttributes = "gap=5",
};
viewModel.AddElement();
var flow = new LehrerApp.Templating.LayoutParser().Parse(viewModel.LayoutSource).ContentFlows.Single();
var text = Assert.IsType<LehrerApp.Templating.TextElement>(flow.Elements.Last());
Assert.Equal("Nachsatz", text.Content);
Assert.Equal("5", text.Attributes["gap"]);
}
} }
@@ -0,0 +1,93 @@
using LehrerApp.TemplateDesigner;
using LehrerApp.Templating;
using Xunit;
using System.Runtime.Versioning;
using QuestPDF.Fluent;
using QuestPDF.Infrastructure;
namespace LehrerApp.TemplateDesigner.Tests;
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macos")]
public sealed class PdfImportPipelineTests
{
[Fact]
public void TemplateDiff_LaesstIdentischenTextStatischUndMarkiertAbweichung()
{
var pipeline = new PdfImportPipeline();
var template = Document(
Block("a", 40, 30, "Schule am Park"),
Block("b", 40, 100, "Max Mustermann"));
var example = Document(
Block("a2", 40, 30, "Schule am Park"),
Block("b2", 40, 100, "Erika Beispiel"));
var candidate = Assert.Single(pipeline.FindCandidates(example, template));
Assert.Equal("Erika Beispiel", candidate.OriginalText);
Assert.Equal(PdfImportConfidence.High, candidate.Confidence);
}
[Fact]
public void EinzelPdf_ErkenntDatumTypDeterministisch()
{
var pipeline = new PdfImportPipeline();
var candidate = Assert.Single(pipeline.FindCandidates(Document(Block("d", 400, 60, "31.08.2026")), null));
Assert.Equal("Datum", candidate.Name);
Assert.Equal(PlaceholderType.Date, candidate.Type);
Assert.Equal(PdfImportConfidence.High, candidate.Confidence);
}
[Fact]
public void UebernahmeErsetztDasAktuelleDesignerprojekt()
{
var result = new PdfImportResult("PAGE 595 842 pt\nTEXT 40 100 $Name\n",
new TemplateManifest
{
Id = "pdf-import", Name = "PDF-Import", PageSize = new(595, 842, "pt"),
Placeholders = [new("Name", PlaceholderType.Text)],
}, new Dictionary<string, byte[]>(),
[new PdfImportCandidate { Id = "x", BlockIds = ["x"], OriginalText = "Erika Beispiel", Name = "Name", Confidence = PdfImportConfidence.High }]);
var viewModel = new DesignerViewModel();
viewModel.ApplyPdfImport(result);
Assert.Equal("pt", viewModel.Unit);
Assert.Equal("Erika Beispiel", Assert.Single(viewModel.Placeholders).Sample);
Assert.Contains("$Name", viewModel.LayoutSource);
Assert.False(viewModel.CanExport);
}
[Fact]
public async Task EchtesPdf_WirdExtrahiertUndAlsRenderbareVorlageErzeugt()
{
QuestPDF.Settings.License = LicenseType.Community;
var path = Path.Combine(Path.GetTempPath(), $"pdf-import-{Guid.NewGuid():N}.pdf");
try
{
QuestPDF.Fluent.Document.Create(document => document.Page(page =>
{
page.Size(595, 842); page.Margin(40); page.Content().Text("Erika Beispiel").FontSize(11);
})).GeneratePdf(path);
var result = await new PdfImportPipeline().BuildAsync(path, null, null, null);
var viewModel = new DesignerViewModel();
viewModel.ApplyPdfImport(result);
var png = new QuestTemplateRenderer().RenderFirstPageToPng(viewModel.BuildLoaded(), viewModel.BuildDataProvider());
Assert.NotEmpty(result.Candidates);
Assert.Contains("pdf-import-background.png", result.Assets.Keys);
Assert.True(png.Length > 1_000);
}
finally { if (File.Exists(path)) File.Delete(path); }
}
private static PdfImportDocument Document(params PdfTextBlock[] blocks) =>
new([new PdfImportPage(1, 595, 842, blocks)]);
private static PdfTextBlock Block(string id, double x, double y, string text) =>
new(id, 1, x, y, 120, 12, 11, "Arial", false, false, text);
}
@@ -0,0 +1,122 @@
using LehrerApp.TemplateDesigner;
using Xunit;
namespace LehrerApp.TemplateDesigner.Tests;
public sealed class ProjectLifecycleTests
{
[Fact]
public void Reset_ErzeugtEinVollstaendigLeeresUnabhaengigesProjekt()
{
var viewModel = new DesignerViewModel();
viewModel.ImportAsset("logo.png", OnePixelPng);
viewModel.Placeholders.Add(new("Test", LehrerApp.Templating.PlaceholderType.Text, false, "Wert"));
viewModel.NewElementType = "IMG";
viewModel.NewContent = "logo.png";
viewModel.Reset();
Assert.Equal("neue-vorlage", viewModel.TemplateId);
Assert.Contains("#pragma page-template first", viewModel.LayoutSource);
Assert.Contains("#pragma page-template continuation", viewModel.LayoutSource);
Assert.Contains("#pragma content-flow body", viewModel.LayoutSource);
Assert.Empty(viewModel.Placeholders);
Assert.Empty(viewModel.Assets);
Assert.Empty(viewModel.AssetItems);
Assert.Equal("de-DE", viewModel.MetadataItems.Single(x => x.Name == "language").Value);
Assert.Equal("letter", viewModel.MetadataItems.Single(x => x.Name == "report-type").Value);
Assert.Null(viewModel.SelectedAsset);
Assert.Null(viewModel.PreviewImage);
Assert.Equal("TEXT", viewModel.NewElementType);
Assert.Equal("$Brieftext", viewModel.NewContent);
Assert.False(viewModel.CanExport);
}
[Fact]
public void FreieMetadaten_WerdenInDasManifestUebernommen()
{
var viewModel = new DesignerViewModel();
viewModel.MetadataItems.Add(new("school-year", "2026/27"));
var manifest = viewModel.BuildManifest();
Assert.Equal("de-DE", manifest.Metadata["language"]);
Assert.Equal("2026/27", manifest.Metadata["school-year"]);
}
[Fact]
public void PlatzhalterAnlegen_WaehltIhnAusUndUebernimmtBearbeiteteDefinition()
{
var viewModel = new DesignerViewModel();
viewModel.Placeholders.Clear();
var placeholder = viewModel.AddPlaceholder();
placeholder.Name = "Sprache";
placeholder.Type = LehrerApp.Templating.PlaceholderType.Text;
placeholder.Required = true;
placeholder.Sample = "Deutsch";
placeholder.IsConstant = true;
placeholder.Bold = true;
placeholder.Italic = true;
placeholder.Underline = true;
Assert.Same(placeholder, viewModel.SelectedPlaceholder);
Assert.True(viewModel.HasSelectedPlaceholder);
Assert.False(viewModel.HasNoSelectedPlaceholder);
var definition = Assert.Single(viewModel.BuildManifest().Placeholders);
Assert.Equal("Sprache", definition.Name);
Assert.Equal(LehrerApp.Templating.PlaceholderType.Text, definition.Type);
Assert.True(definition.Required);
Assert.True(definition.IsConstant);
Assert.Equal("Deutsch", definition.ConstantValue);
Assert.True(definition.Bold);
Assert.True(definition.Italic);
Assert.True(definition.Underline);
viewModel.RemoveSelectedPlaceholder();
Assert.Empty(viewModel.Placeholders);
Assert.Null(viewModel.SelectedPlaceholder);
Assert.False(viewModel.HasSelectedPlaceholder);
Assert.True(viewModel.HasNoSelectedPlaceholder);
}
[Fact]
public void DoppeltePlatzhalternamen_WerdenVerstaendlichAbgelehnt()
{
var viewModel = new DesignerViewModel();
viewModel.Placeholders.Clear();
viewModel.Placeholders.Add(new("Name", LehrerApp.Templating.PlaceholderType.Text, false, "A"));
viewModel.Placeholders.Add(new("Name", LehrerApp.Templating.PlaceholderType.Text, false, "B"));
var exception = Assert.Throws<InvalidDataException>(() => viewModel.BuildManifest());
Assert.Contains("mehrfach", exception.Message);
}
[Fact]
public void AlteAbsoluteLayouts_WerdenBeimOeffnenVerlustfreiInSeitentypEingebettet()
{
const string source = "PAGE 210 297 mm\nTEXT 20 30 \"Altbestand\" size=12\n";
var layout = new LehrerApp.Templating.LayoutParser().Parse(source);
var migrated = DesignerViewModel.MigrateLegacyLayout(source, layout);
var parsed = new LehrerApp.Templating.LayoutParser().Parse(migrated);
Assert.True(parsed.UsesPageTemplates);
Assert.Equal("Altbestand", Assert.IsType<LehrerApp.Templating.TextElement>(
parsed.PageTemplates.Single(x => x.Name == "first").Elements.Single()).Content);
Assert.Empty(parsed.ContentFlows.Single().Elements);
}
[Fact]
public void AktuelleAnsicht_KannAlsPdfGerendertWerden()
{
var pdf = new DesignerViewModel().RenderCurrentPdf();
Assert.True(pdf.Length > 100);
Assert.Equal("%PDF-", System.Text.Encoding.ASCII.GetString(pdf, 0, 5));
}
private static readonly byte[] OnePixelPng = Convert.FromBase64String(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=");
}
+1
View File
@@ -2,6 +2,7 @@
x:Class="LehrerApp.TemplateDesigner.App" RequestedThemeVariant="Default"> x:Class="LehrerApp.TemplateDesigner.App" RequestedThemeVariant="Default">
<Application.Styles> <Application.Styles>
<FluentTheme/> <FluentTheme/>
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
<Style Selector="TextBlock.section"><Setter Property="FontWeight" Value="SemiBold"/><Setter Property="FontSize" Value="15"/></Style> <Style Selector="TextBlock.section"><Setter Property="FontWeight" Value="SemiBold"/><Setter Property="FontSize" Value="15"/></Style>
<Style Selector="TextBlock.label"><Setter Property="Opacity" Value="0.7"/><Setter Property="FontSize" Value="12"/></Style> <Style Selector="TextBlock.label"><Setter Property="Opacity" Value="0.7"/><Setter Property="FontSize" Value="12"/></Style>
</Application.Styles> </Application.Styles>
+420 -29
View File
@@ -8,6 +8,7 @@ namespace LehrerApp.TemplateDesigner;
public partial class DesignerViewModel : ObservableObject public partial class DesignerViewModel : ObservableObject
{ {
private Bitmap? _pageTemplatePreview;
[ObservableProperty] private string _templateId = "elternbrief-standard"; [ObservableProperty] private string _templateId = "elternbrief-standard";
[ObservableProperty] private string _templateName = "Elternbrief Standard"; [ObservableProperty] private string _templateName = "Elternbrief Standard";
[ObservableProperty] private string _description = "Briefvorlage mit Schul-Briefkopf"; [ObservableProperty] private string _description = "Briefvorlage mit Schul-Briefkopf";
@@ -15,6 +16,8 @@ public partial class DesignerViewModel : ObservableObject
[ObservableProperty] private decimal _pageHeight = 297; [ObservableProperty] private decimal _pageHeight = 297;
[ObservableProperty] private string _unit = "mm"; [ObservableProperty] private string _unit = "mm";
[ObservableProperty] private string _layoutSource = DefaultLayout; [ObservableProperty] private string _layoutSource = DefaultLayout;
[ObservableProperty] private bool _useContinuationLayout;
[ObservableProperty] private string _continuationLayoutSource = "PAGE 210 297 mm\nFLOWBOX 20 20 170 257 $Brieftext size=11\n";
[ObservableProperty] private string _newElementType = "TEXT"; [ObservableProperty] private string _newElementType = "TEXT";
[ObservableProperty] private string _newX = "20"; [ObservableProperty] private string _newX = "20";
[ObservableProperty] private string _newY = "50"; [ObservableProperty] private string _newY = "50";
@@ -23,11 +26,16 @@ public partial class DesignerViewModel : ObservableObject
[ObservableProperty] private string _newImageScale = "100"; [ObservableProperty] private string _newImageScale = "100";
[ObservableProperty] private string _newContent = "$Brieftext"; [ObservableProperty] private string _newContent = "$Brieftext";
[ObservableProperty] private string _newAttributes = "size=11"; [ObservableProperty] private string _newAttributes = "size=11";
[ObservableProperty] private string _newElementScope = "Seitenvorlage (fest)";
[ObservableProperty] private string _selectedContentFlow = "body";
[ObservableProperty] private string _newPageTemplateName = "continuation";
[ObservableProperty] private string _newFlowName = "body";
[ObservableProperty] private Bitmap? _previewImage; [ObservableProperty] private Bitmap? _previewImage;
[ObservableProperty] private string _status = "Bereit."; [ObservableProperty] private string _status = "Bereit.";
[ObservableProperty] private string _statusColor = "#475569"; [ObservableProperty] private string _statusColor = "#475569";
[ObservableProperty] private bool _canExport; [ObservableProperty] private bool _canExport;
[ObservableProperty] private DesignerPlaceholder? _selectedPlaceholder; [ObservableProperty] private DesignerPlaceholder? _selectedPlaceholder;
[ObservableProperty] private DesignerMetadata? _selectedMetadata;
[ObservableProperty] private DesignerAsset? _selectedAsset; [ObservableProperty] private DesignerAsset? _selectedAsset;
[ObservableProperty] private StarterTemplateItem? _selectedStarterTemplate; [ObservableProperty] private StarterTemplateItem? _selectedStarterTemplate;
[ObservableProperty] private OverlayEditorMode _overlayMode = OverlayEditorMode.Measure; [ObservableProperty] private OverlayEditorMode _overlayMode = OverlayEditorMode.Measure;
@@ -38,10 +46,14 @@ public partial class DesignerViewModel : ObservableObject
[ObservableProperty] private double _overlayPageWidth = 210; [ObservableProperty] private double _overlayPageWidth = 210;
[ObservableProperty] private double _overlayPageHeight = 297; [ObservableProperty] private double _overlayPageHeight = 297;
[ObservableProperty] private string _overlayPageUnit = "mm"; [ObservableProperty] private string _overlayPageUnit = "mm";
[ObservableProperty] private string _selectedPageTemplate = "first";
[ObservableProperty] private DesignerPreviewPage? _selectedPreviewPage;
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>();
public IReadOnlyList<string> ElementTypes { get; } = ["TEXT", "TEXTBOX", "IMG", "TABLE", "CHART"]; public IReadOnlyList<string> ElementTypes { get; } =
["TEXT", "TEXTBOX", "FLOWBOX", "DRAWBOX", "FLOWDRAWBOX", "IMG", "TABLE", "CHART"];
public IReadOnlyList<string> ElementScopes { get; } = ["Seitenvorlage (fest)", "Content-Flow"];
public ObservableCollection<DesignerPlaceholder> Placeholders { get; } = public ObservableCollection<DesignerPlaceholder> Placeholders { get; } =
[ [
new("Datum", PlaceholderType.Date, true, DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), new("Datum", PlaceholderType.Date, true, DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
@@ -50,16 +62,46 @@ public partial class DesignerViewModel : ObservableObject
new("Brieftext", PlaceholderType.Multiline, true, "hiermit informieren wir Sie über einen wichtigen Termin.\n\nMit freundlichen Grüßen"), new("Brieftext", PlaceholderType.Multiline, true, "hiermit informieren wir Sie über einen wichtigen Termin.\n\nMit freundlichen Grüßen"),
new("LehrerName", PlaceholderType.Text, true, "M. Mustermann"), new("LehrerName", PlaceholderType.Text, true, "M. Mustermann"),
]; ];
public ObservableCollection<DesignerMetadata> MetadataItems { get; } =
[
new(TemplateMetadataKeys.Language, "de-DE"),
new(TemplateMetadataKeys.ReportType, "letter"),
];
public Dictionary<string, byte[]> Assets { get; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary<string, byte[]> Assets { get; } = new(StringComparer.OrdinalIgnoreCase);
public ObservableCollection<DesignerAsset> AssetItems { get; } = []; public ObservableCollection<DesignerAsset> AssetItems { get; } = [];
public ObservableCollection<StarterTemplateItem> StarterTemplates { get; } = []; public ObservableCollection<StarterTemplateItem> StarterTemplates { get; } = [];
public ObservableCollection<string> PageTemplateNames { get; } = ["first"];
public ObservableCollection<string> ContentFlowNames { get; } = ["body"];
public ObservableCollection<DesignerPreviewPage> PreviewPages { get; } = [];
public bool HasSelectedPlaceholder => SelectedPlaceholder is not null;
public bool HasNoSelectedPlaceholder => SelectedPlaceholder is null;
public DesignerViewModel() => SelectedPlaceholder = Placeholders.FirstOrDefault();
partial void OnSelectedPlaceholderChanged(DesignerPlaceholder? value)
{
OnPropertyChanged(nameof(HasSelectedPlaceholder));
OnPropertyChanged(nameof(HasNoSelectedPlaceholder));
}
partial void OnSelectedPreviewPageChanged(DesignerPreviewPage? value) => PreviewImage = value?.Image;
public void Reset() public void Reset()
{ {
TemplateId = "neue-vorlage"; TemplateName = "Neue Vorlage"; Description = ""; TemplateId = "neue-vorlage"; TemplateName = "Neue Vorlage"; Description = "";
PageWidth = 210; PageHeight = 297; Unit = "mm"; LayoutSource = "PAGE 210 297 mm\n"; PageWidth = 210; PageHeight = 297; Unit = "mm"; LayoutSource = EmptyStructuredLayout;
Placeholders.Clear(); Assets.Clear(); AssetItems.Clear(); SelectedAsset = null; UseContinuationLayout = false;
PreviewImage = null; CanExport = false; ContinuationLayoutSource = "PAGE 210 297 mm\nFLOWBOX 20 20 170 257 $Brieftext size=11\n";
Placeholders.Clear(); SelectedPlaceholder = null;
MetadataItems.Clear(); MetadataItems.Add(new(TemplateMetadataKeys.Language, "de-DE"));
MetadataItems.Add(new(TemplateMetadataKeys.ReportType, "letter"));
SelectedMetadata = null;
Assets.Clear(); AssetItems.Clear(); SelectedAsset = null;
NewElementType = "TEXT"; NewX = "20"; NewY = "50"; NewWidth = "170"; NewHeight = "30";
NewImageScale = "100"; NewContent = "$Brieftext"; NewAttributes = "size=11";
ClearPreviewPages(); CanExport = false;
OverlayMode = OverlayEditorMode.Measure; OverlayCoordinates = "x= · y=";
SelectedOverlayElement = "Kein Element ausgewählt";
SetStatus("Neues Projekt angelegt.", false); SetStatus("Neues Projekt angelegt.", false);
} }
@@ -68,7 +110,9 @@ public partial class DesignerViewModel : ObservableObject
SchemaVersion = TemplateLoader.CurrentSchemaVersion, SchemaVersion = TemplateLoader.CurrentSchemaVersion,
Id = TemplateId.Trim(), Name = TemplateName.Trim(), Description = Description.Trim(), Id = TemplateId.Trim(), Name = TemplateName.Trim(), Description = Description.Trim(),
PageSize = new((float)PageWidth, (float)PageHeight, Unit), LayoutFile = "layout.tpl", PageSize = new((float)PageWidth, (float)PageHeight, Unit), LayoutFile = "layout.tpl",
Placeholders = Placeholders.Select(x => new PlaceholderDefinition(x.Name.Trim(), x.Type, x.Required)).ToList(), ContinuationLayoutFile = UseContinuationLayout ? "continuation.tpl" : null,
Metadata = BuildMetadata(),
Placeholders = BuildPlaceholders(),
}; };
public LoadedTemplate BuildLoaded() public LoadedTemplate BuildLoaded()
@@ -78,36 +122,146 @@ public partial class DesignerViewModel : ObservableObject
throw new InvalidDataException("Die ID darf nur ASCII-Buchstaben, Ziffern und Bindestriche enthalten."); throw new InvalidDataException("Die ID darf nur ASCII-Buchstaben, Ziffern und Bindestriche enthalten.");
if (string.IsNullOrWhiteSpace(manifest.Name)) throw new InvalidDataException("Der Vorlagenname fehlt."); if (string.IsNullOrWhiteSpace(manifest.Name)) throw new InvalidDataException("Der Vorlagenname fehlt.");
var layout = new LayoutParser().Parse(LayoutSource); var layout = new LayoutParser().Parse(LayoutSource);
var continuationLayout = UseContinuationLayout ? new LayoutParser().Parse(ContinuationLayoutSource) : null;
var issues = new List<ValidationIssue>(); var issues = new List<ValidationIssue>();
var layouts = continuationLayout is null ? new[] { layout } : new[] { layout, continuationLayout };
var declared = manifest.Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal); var declared = manifest.Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal);
foreach (var used in TemplateLoader.UsedPlaceholders(layout).Where(x => !declared.Contains(x))) foreach (var used in layouts.SelectMany(TemplateLoader.UsedPlaceholders).Distinct(StringComparer.Ordinal)
.Where(x => !declared.Contains(x)))
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{used}“ ist nicht deklariert.")); issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{used}“ ist nicht deklariert."));
foreach (var path in layout.Elements.Select(x => x switch { BackgroundElement b => b.Path, ImageElement i => i.Path, _ => null }).Where(x => x is not null)) foreach (var path in layouts.SelectMany(x => x.Elements).Select(x => x switch { BackgroundElement b => b.Path, ImageElement i => i.Path, _ => null }).Where(x => x is not null))
if (!Assets.ContainsKey(path!)) issues.Add(new(ValidationSeverity.Error, $"Asset „{path}“ fehlt.")); if (!Assets.ContainsKey(path!)) issues.Add(new(ValidationSeverity.Error, $"Asset „{path}“ fehlt."));
if (layout.UsesPageTemplates && continuationLayout is not null)
issues.Add(new(ValidationSeverity.Error,
"Layoutformat 3 enthält Folgeseiten als page-template und kann nicht zusätzlich continuation.tpl verwenden."));
if (!layout.UsesPageTemplates)
{
var firstFlows = layout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement).ToList();
if (firstFlows.Count > 1)
issues.Add(new(ValidationSeverity.Error, "Ein Legacy-Layout darf höchstens eine FLOWBOX enthalten."));
if (continuationLayout is not null)
{
var continuationFlows = continuationLayout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement).ToList();
if (firstFlows.Count != 1 || continuationFlows.Count != 1)
issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen jeweils genau ein fließendes Element enthalten."));
else if (firstFlows[0].GetType() != continuationFlows[0].GetType())
issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen denselben fließenden Elementtyp verwenden."));
else if (firstFlows[0].X != continuationFlows[0].X || firstFlows[0].Y != continuationFlows[0].Y
|| firstFlows[0].Width != continuationFlows[0].Width || firstFlows[0].Height != continuationFlows[0].Height)
issues.Add(new(ValidationSeverity.Error, "Die FLOWBOX muss auf Haupt- und Folgeseiten dieselbe Position und Größe haben."));
}
}
foreach (var issue in TemplateDataResolver.ValidateConstants(manifest))
issues.Add(new(ValidationSeverity.Error, issue));
if (issues.Count > 0) throw new TemplateValidationException(new(issues)); if (issues.Count > 0) throw new TemplateValidationException(new(issues));
return new(manifest, layout, new Dictionary<string, byte[]>(Assets)); return new(manifest, layout, new Dictionary<string, byte[]>(Assets), ContinuationLayout: continuationLayout);
} }
public ITemplateDataProvider BuildDataProvider() => new DesignerDataProvider(Placeholders.ToDictionary( public ITemplateDataProvider BuildDataProvider() => new DesignerDataProvider(Placeholders.ToDictionary(
x => x.Name.Trim(), x => x.ToValue(), StringComparer.Ordinal)); x => x.Name.Trim(), x => x.ToValue(), StringComparer.Ordinal));
public void Load(LoadedTemplate template, string layoutSource) public byte[] RenderCurrentPdf() =>
new QuestTemplateRenderer().RenderToPdf(BuildLoaded(), BuildDataProvider());
public void ApplyPdfImport(PdfImportResult import)
{ {
TemplateId = import.Manifest.Id; TemplateName = import.Manifest.Name; Description = import.Manifest.Description;
PageWidth = (decimal)import.Manifest.PageSize.Width; PageHeight = (decimal)import.Manifest.PageSize.Height;
Unit = import.Manifest.PageSize.Unit; LayoutSource = import.LayoutSource;
UseContinuationLayout = false;
Placeholders.Clear();
foreach (var definition in import.Manifest.Placeholders)
{
var candidate = import.Candidates.FirstOrDefault(x => x.Name.Equals(definition.Name, StringComparison.Ordinal));
Placeholders.Add(new(definition.Name, definition.Type, definition.Required,
candidate?.OriginalText ?? DesignerPlaceholder.SampleFor(definition.Type)));
}
SelectedPlaceholder = Placeholders.FirstOrDefault();
Assets.Clear(); AssetItems.Clear();
foreach (var asset in import.Assets) AddOrReplaceAsset(asset.Key, asset.Value, keepName: true);
MetadataItems.Clear();
foreach (var metadata in import.Manifest.Metadata) MetadataItems.Add(new(metadata.Key, metadata.Value));
CanExport = false;
SetStatus("PDF-Import übernommen. Bitte Vorschau, Platzhalter und Layout vor dem Speichern prüfen.", false);
}
public DesignerPlaceholder AddPlaceholder()
{
var existing = Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal);
var index = Placeholders.Count + 1;
while (existing.Contains($"Feld{index}")) index++;
var placeholder = new DesignerPlaceholder($"Feld{index}", PlaceholderType.Text, false, "Beispiel");
Placeholders.Add(placeholder); SelectedPlaceholder = placeholder; CanExport = false;
SetStatus($"Platzhalter „{placeholder.Name}“ angelegt. Details können jetzt bearbeitet werden.", false);
return placeholder;
}
public void RemoveSelectedPlaceholder()
{
if (SelectedPlaceholder is not { } selected) return;
Placeholders.Remove(selected); SelectedPlaceholder = null; CanExport = false;
SetStatus($"Platzhalter „{selected.Name}“ entfernt.", false);
}
public void Load(LoadedTemplate template, string layoutSource, string? continuationLayoutSource = null)
{
if (!template.Layout.UsesPageTemplates && continuationLayoutSource is null)
layoutSource = MigrateLegacyLayout(layoutSource, template.Layout);
TemplateId = template.Manifest.Id; TemplateName = template.Manifest.Name; Description = template.Manifest.Description; TemplateId = template.Manifest.Id; TemplateName = template.Manifest.Name; Description = template.Manifest.Description;
PageWidth = (decimal)template.Manifest.PageSize.Width; PageHeight = (decimal)template.Manifest.PageSize.Height; PageWidth = (decimal)template.Manifest.PageSize.Width; PageHeight = (decimal)template.Manifest.PageSize.Height;
Unit = template.Manifest.PageSize.Unit; LayoutSource = layoutSource; Unit = template.Manifest.PageSize.Unit; LayoutSource = layoutSource;
UseContinuationLayout = !template.Layout.UsesPageTemplates
&& template.Manifest.ContinuationLayoutFile is not null;
ContinuationLayoutSource = continuationLayoutSource ?? "PAGE 210 297 mm\n";
MetadataItems.Clear();
foreach (var item in template.Manifest.Metadata.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase))
MetadataItems.Add(new(item.Key, item.Value));
SelectedMetadata = null;
Placeholders.Clear(); Placeholders.Clear();
foreach (var placeholder in template.Manifest.Placeholders) foreach (var placeholder in template.Manifest.Placeholders)
Placeholders.Add(new(placeholder.Name, placeholder.Type, placeholder.Required, DesignerPlaceholder.SampleFor(placeholder.Type))); Placeholders.Add(new(placeholder.Name, placeholder.Type, placeholder.Required,
placeholder.IsConstant ? placeholder.ConstantValue ?? "" : DesignerPlaceholder.SampleFor(placeholder.Type),
placeholder.IsConstant, placeholder.Bold, placeholder.Italic, placeholder.Underline));
SelectedPlaceholder = Placeholders.FirstOrDefault();
Assets.Clear(); AssetItems.Clear(); Assets.Clear(); AssetItems.Clear();
foreach (var asset in template.Assets) AddOrReplaceAsset(asset.Key, asset.Value, keepName: true); foreach (var asset in template.Assets) AddOrReplaceAsset(asset.Key, asset.Value, keepName: true);
RefreshAssetUsage(); RefreshAssetUsage();
CanExport = true; SetStatus($"„{template.Manifest.Name}“ geladen.", false); CanExport = true; SetStatus($"„{template.Manifest.Name}“ geladen.", false);
} }
public void LoadAsNewProject(LoadedTemplate template, string layoutSource, string? newId = null) public static string MigrateLegacyLayout(string source, TemplateLayout layout)
{ {
Load(template, layoutSource); if (layout.UsesPageTemplates) return source;
var statements = source.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n')
.Where(line =>
{
var trimmed = line.Trim();
return trimmed.Length > 0 && !trimmed.StartsWith('#')
&& !trimmed.StartsWith("PAGE ", StringComparison.OrdinalIgnoreCase);
}).Select(line => line.Trim()).ToList();
var margin = layout.Unit.Equals("mm", StringComparison.OrdinalIgnoreCase) ? 20f : layout.Width * 0.08f;
var x = FormatNumber(margin); var y = FormatNumber(margin);
var width = FormatNumber(Math.Max(1, layout.Width - margin * 2));
var height = FormatNumber(Math.Max(1, layout.Height - margin * 2));
var result = new List<string>
{
$"PAGE {FormatNumber(layout.Width)} {FormatNumber(layout.Height)} {layout.Unit}",
"#pragma format-version 3", "", "#pragma page-template first",
};
result.AddRange(statements);
result.AddRange([
$"#pragma flow-slot body x={x} y={y} w={width} h={height}",
"#pragma end-page-template", "", "#pragma page-template continuation",
$"#pragma flow-slot body x={x} y={y} w={width} h={height}",
"#pragma end-page-template", "", "#pragma content-flow body",
"#pragma end-content-flow", "",
]);
return string.Join('\n', result);
}
public void LoadAsNewProject(LoadedTemplate template, string layoutSource, string? continuationLayoutSource = null, string? newId = null)
{
Load(template, layoutSource, continuationLayoutSource);
TemplateId = newId ?? template.Manifest.Id + "-neu"; TemplateId = newId ?? template.Manifest.Id + "-neu";
TemplateName = template.Manifest.Name + " - Neu"; TemplateName = template.Manifest.Name + " - Neu";
CanExport = false; CanExport = false;
@@ -117,10 +271,44 @@ public partial class DesignerViewModel : ObservableObject
public void PreviewTemplate(LoadedTemplate template) public void PreviewTemplate(LoadedTemplate template)
{ {
var provider = BuildSampleDataProvider(template.Manifest); var provider = BuildSampleDataProvider(template.Manifest);
var bytes = new QuestTemplateRenderer().RenderFirstPageToPng(template, provider); RenderPreviewPages(template, provider);
SetStatus($"Vorschau von „{template.Manifest.Name}“ mit {PreviewPages.Count} Seite(n).", false);
}
public void RenderPreviewPages(LoadedTemplate template, ITemplateDataProvider provider)
{
var pages = new QuestTemplateRenderer().RenderPagesToPng(template, provider);
ClearPreviewPages();
for (var index = 0; index < pages.Count; index++)
{
using var stream = new MemoryStream(pages[index]);
PreviewPages.Add(new(index + 1, new Bitmap(stream)));
}
SelectedPreviewPage = PreviewPages.FirstOrDefault();
}
public void PreviewSelectedPageTemplateCanvas()
{
var loaded = BuildLoaded();
var pageTemplate = loaded.Layout.PageTemplates.FirstOrDefault(x =>
x.Name.Equals(SelectedPageTemplate, StringComparison.OrdinalIgnoreCase));
if (pageTemplate is null) return;
var canvasLayout = new TemplateLayout(loaded.Layout.Width, loaded.Layout.Height, loaded.Layout.Unit,
pageTemplate.Elements);
var canvasTemplate = new LoadedTemplate(loaded.Manifest, canvasLayout, loaded.Assets, loaded.SourceName);
var bytes = new QuestTemplateRenderer().RenderFirstPageToPng(canvasTemplate, BuildDataProvider());
using var stream = new MemoryStream(bytes); using var stream = new MemoryStream(bytes);
PreviewImage?.Dispose(); PreviewImage = new Bitmap(stream); _pageTemplatePreview?.Dispose(); _pageTemplatePreview = new Bitmap(stream);
SetStatus($"Vorschau von „{template.Manifest.Name}“.", false); SelectedPreviewPage = null; PreviewImage = _pageTemplatePreview;
SetStatus($"Bearbeitungsansicht des Seitentyps „{SelectedPageTemplate}“.", false);
}
private void ClearPreviewPages()
{
_pageTemplatePreview?.Dispose(); _pageTemplatePreview = null;
PreviewImage = null; SelectedPreviewPage = null;
foreach (var page in PreviewPages) page.Image.Dispose();
PreviewPages.Clear();
} }
public void SetStarterTemplates(IEnumerable<StarterTemplateItem> templates, string? selectedId = null) public void SetStarterTemplates(IEnumerable<StarterTemplateItem> templates, string? selectedId = null)
@@ -131,6 +319,35 @@ public partial class DesignerViewModel : ObservableObject
?? StarterTemplates.FirstOrDefault(); ?? StarterTemplates.FirstOrDefault();
} }
private Dictionary<string, string> BuildMetadata()
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in MetadataItems)
{
var key = item.Name.Trim(); var value = item.Value.Trim();
if (!result.TryAdd(key, value))
throw new InvalidDataException($"Metadatenschlüssel „{key}“ ist mehrfach vorhanden.");
}
TemplateMetadataText.Serialize(result);
return result;
}
private List<PlaceholderDefinition> BuildPlaceholders()
{
var names = new HashSet<string>(StringComparer.Ordinal);
var result = new List<PlaceholderDefinition>(Placeholders.Count);
foreach (var placeholder in Placeholders)
{
var name = placeholder.Name.Trim();
if (name.Length == 0) throw new InvalidDataException("Ein Platzhaltername darf nicht leer sein.");
if (!names.Add(name)) throw new InvalidDataException($"Platzhalter „{name}“ ist mehrfach definiert.");
result.Add(new(name, placeholder.Type, placeholder.Required, placeholder.IsConstant,
placeholder.IsConstant ? placeholder.Sample : null,
placeholder.Bold, placeholder.Italic, placeholder.Underline));
}
return result;
}
private static ITemplateDataProvider BuildSampleDataProvider(TemplateManifest manifest) => private static ITemplateDataProvider BuildSampleDataProvider(TemplateManifest manifest) =>
new DesignerDataProvider(manifest.Placeholders.ToDictionary(x => x.Name, new DesignerDataProvider(manifest.Placeholders.ToDictionary(x => x.Name,
x => new DesignerPlaceholder(x.Name, x.Type, x.Required, DesignerPlaceholder.SampleFor(x.Type)).ToValue(), x => new DesignerPlaceholder(x.Name, x.Type, x.Required, DesignerPlaceholder.SampleFor(x.Type)).ToValue(),
@@ -139,19 +356,93 @@ public partial class DesignerViewModel : ObservableObject
public void AddElement() public void AddElement()
{ {
var attrs = string.IsNullOrWhiteSpace(NewAttributes) ? "" : " " + NewAttributes.Trim(); var attrs = string.IsNullOrWhiteSpace(NewAttributes) ? "" : " " + NewAttributes.Trim();
var line = NewElementType switch var flowElement = NewElementScope == "Content-Flow";
var line = (NewElementType, flowElement) switch
{ {
"TEXT" => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}", ("TEXT", false) => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}",
"TEXTBOX" => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}", ("TEXTBOX", false) => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
"IMG" => $"IMG {NewContent} {NewX} {NewY} {NewWidth} {NewHeight} scale={NormalizedImageScale()}%", ("FLOWBOX", false) => $"FLOWBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
"TABLE" => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}", ("DRAWBOX", false) => $"DRAWBOX {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
"CHART" => $"CHART {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}", ("FLOWDRAWBOX", false) => $"FLOWDRAWBOX {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
("IMG", false) => $"IMG {NewContent} {NewX} {NewY} {NewWidth} {NewHeight} scale={NormalizedImageScale()}%",
("TABLE", false) => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
("CHART", false) => $"CHART {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
("TEXT", true) => $"TEXT {QuoteIfLiteral(NewContent)}{attrs}",
("TEXTBOX", true) => $"TEXTBOX {QuoteIfLiteral(NewContent)}{attrs}",
("FLOWBOX", true) => $"FLOWBOX {QuoteIfLiteral(NewContent)}{attrs}",
("DRAWBOX", true) => $"DRAWBOX {NewContent} w={NewWidth} h={NewHeight}{attrs}",
("FLOWDRAWBOX", true) => $"FLOWDRAWBOX {NewContent} w={NewWidth} h={NewHeight}{attrs}",
("IMG", true) => $"IMG {NewContent} w={NewWidth} h={NewHeight} scale={NormalizedImageScale()}%{attrs}",
("TABLE", true) => $"TABLE {NewContent}{attrs}",
("CHART", true) => $"CHART {NewContent} h={NewHeight}{attrs}",
_ => throw new InvalidOperationException("Unbekannter Elementtyp."), _ => throw new InvalidOperationException("Unbekannter Elementtyp."),
}; };
LayoutSource = LayoutSource.TrimEnd() + Environment.NewLine + line + Environment.NewLine; var parsedLayout = new LayoutParser().Parse(LayoutSource);
LayoutSource = parsedLayout.UsesPageTemplates
? InsertIntoSection(LayoutSource, line, flowElement ? "content-flow" : "page-template",
flowElement ? SelectedContentFlow : SelectedPageTemplate)
: LayoutSource.TrimEnd() + Environment.NewLine + line + Environment.NewLine;
CanExport = false; SetStatus("Element ergänzt. Vorschau zur Prüfung aktualisieren.", false); CanExport = false; SetStatus("Element ergänzt. Vorschau zur Prüfung aktualisieren.", false);
} }
public void AddPageTemplate()
{
var name = ValidateSectionName(NewPageTemplateName, "Seitentyp");
if (PageTemplateNames.Contains(name, StringComparer.OrdinalIgnoreCase))
throw new InvalidDataException($"Seitentyp „{name}“ existiert bereits.");
var block = $"#pragma page-template {name}\n#pragma end-page-template\n";
var marker = LayoutSource.IndexOf("#pragma content-flow", StringComparison.OrdinalIgnoreCase);
LayoutSource = marker < 0 ? LayoutSource.TrimEnd() + "\n\n" + block
: LayoutSource.Insert(marker, block + "\n");
SelectedPageTemplate = name; CanExport = false;
SetStatus($"Seitentyp „{name}“ angelegt.", false);
}
public void AddContentFlow()
{
var name = ValidateSectionName(NewFlowName, "Flow");
if (ContentFlowNames.Contains(name, StringComparer.OrdinalIgnoreCase))
throw new InvalidDataException($"Content-Flow „{name}“ existiert bereits.");
LayoutSource = LayoutSource.TrimEnd() + $"\n\n#pragma content-flow {name}\n#pragma end-content-flow\n";
SelectedContentFlow = name; CanExport = false;
SetStatus($"Content-Flow „{name}“ angelegt. Lege nun gleichnamige Slots auf den Seitentypen an.", false);
}
public void AddFlowSlot()
{
var name = ValidateSectionName(NewFlowName, "Flow-Slot");
var line = $"#pragma flow-slot {name} x={NewX} y={NewY} w={NewWidth} h={NewHeight}";
LayoutSource = InsertIntoSection(LayoutSource, line, "page-template", SelectedPageTemplate);
CanExport = false;
SetStatus($"Flow-Slot „{name}“ auf „{SelectedPageTemplate}“ angelegt.", false);
}
private static string ValidateSectionName(string value, string label)
{
var name = value.Trim();
if (name.Length == 0 || name.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '-' or '_')))
throw new InvalidDataException($"{label}-Namen dürfen nur Buchstaben, Ziffern, - und _ enthalten.");
return name;
}
private static string InsertIntoSection(string source, string line, string section, string name)
{
var lines = source.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList();
var start = lines.FindIndex(x =>
{
var tokens = x.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
return tokens.Length >= 3 && tokens[0].Equals("#pragma", StringComparison.OrdinalIgnoreCase)
&& tokens[1].Equals(section, StringComparison.OrdinalIgnoreCase)
&& tokens[2].Equals(name, StringComparison.OrdinalIgnoreCase);
});
if (start < 0) throw new InvalidDataException($"{section} „{name}“ wurde nicht gefunden.");
var endDirective = section == "content-flow" ? "end-content-flow" : "end-page-template";
var end = lines.FindIndex(start + 1, x => x.Trim().Equals($"#pragma {endDirective}", StringComparison.OrdinalIgnoreCase));
if (end < 0) throw new InvalidDataException($"{section} „{name}“ ist nicht geschlossen.");
lines.Insert(end, line);
return string.Join('\n', lines);
}
public DesignerAsset ImportAsset(string sourceName, byte[] bytes) => AddOrReplaceAsset(sourceName, bytes, keepName: false); public DesignerAsset ImportAsset(string sourceName, byte[] bytes) => AddOrReplaceAsset(sourceName, bytes, keepName: false);
public DesignerAsset ImportBackground(string sourceName, byte[] bytes) public DesignerAsset ImportBackground(string sourceName, byte[] bytes)
@@ -212,6 +503,19 @@ public partial class DesignerViewModel : ObservableObject
public void ApplyElementGeometry(OverlayElementGeometry geometry) public void ApplyElementGeometry(OverlayElementGeometry geometry)
{ {
var layout = new LayoutParser().Parse(LayoutSource); var layout = new LayoutParser().Parse(LayoutSource);
if (geometry.Keyword == "FLOW")
{
var slot = layout.PageTemplates.SelectMany(x => x.FlowSlots)
.FirstOrDefault(x => x.Line == geometry.Line)
?? throw new InvalidDataException($"Flow-Slot in Zeile {geometry.Line} wurde nicht gefunden.");
var slotLines = LayoutSource.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList();
slotLines[geometry.Line - 1] = $"#pragma flow-slot {slot.Name} x={FormatNumber(geometry.X)} "
+ $"y={FormatNumber(geometry.Y)} w={FormatNumber(geometry.Width)} h={FormatNumber(geometry.Height)}";
LayoutSource = string.Join('\n', slotLines); CanExport = false;
SelectedOverlayElement = $"Flow {slot.Name} · Zeile {geometry.Line}";
SetStatus($"Flow-Slot „{slot.Name}“ verschoben/skaliert.", false);
return;
}
var element = layout.Elements.FirstOrDefault(x => x.Line == geometry.Line) var element = layout.Elements.FirstOrDefault(x => x.Line == geometry.Line)
?? throw new InvalidDataException($"Element in Zeile {geometry.Line} wurde nicht gefunden."); ?? throw new InvalidDataException($"Element in Zeile {geometry.Line} wurde nicht gefunden.");
var lines = LayoutSource.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList(); var lines = LayoutSource.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList();
@@ -225,6 +529,12 @@ public partial class DesignerViewModel : ObservableObject
TextElement text => $"TEXT {x} {y} {Content(text.Content, text.Placeholder, text.Format)}{Attributes(text.Attributes)}", TextElement text => $"TEXT {x} {y} {Content(text.Content, text.Placeholder, text.Format)}{Attributes(text.Attributes)}",
TextBoxElement box => $"TEXTBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} " TextBoxElement box => $"TEXTBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"{Content(box.Content, box.Placeholder, box.Format)}{Attributes(box.Attributes)}", + $"{Content(box.Content, box.Placeholder, box.Format)}{Attributes(box.Attributes)}",
FlowBoxElement box => $"FLOWBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"{Content(box.Content, box.Placeholder, box.Format)}{Attributes(box.Attributes)}",
DrawBoxElement box => $"DRAWBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"${box.Placeholder}{Attributes(box.Attributes)}",
FlowDrawBoxElement box => $"FLOWDRAWBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"${box.Placeholder}{Attributes(box.Attributes)}",
TableElement table => $"TABLE {x} {y} {FormatNumber(width)} {FormatNumber(height)} " TableElement table => $"TABLE {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"${table.Placeholder}{Attributes(table.Attributes)}", + $"${table.Placeholder}{Attributes(table.Attributes)}",
ChartElement chart => $"CHART {x} {y} {FormatNumber(width)} {FormatNumber(height)} " ChartElement chart => $"CHART {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
@@ -243,6 +553,20 @@ public partial class DesignerViewModel : ObservableObject
{ {
var page = new LayoutParser().Parse(value); var page = new LayoutParser().Parse(value);
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();
if (names.Count == 0) names.Add("legacy");
var selected = names.Contains(SelectedPageTemplate, StringComparer.OrdinalIgnoreCase)
? SelectedPageTemplate : names[0];
PageTemplateNames.Clear();
foreach (var name in names) PageTemplateNames.Add(name);
SelectedPageTemplate = selected;
var flows = page.ContentFlows.Select(x => x.Name).ToList();
if (flows.Count == 0) flows.Add("body");
var selectedFlow = flows.Contains(SelectedContentFlow, StringComparer.OrdinalIgnoreCase)
? SelectedContentFlow : flows[0];
ContentFlowNames.Clear();
foreach (var flow in flows) ContentFlowNames.Add(flow);
SelectedContentFlow = selectedFlow;
} }
catch (TemplateValidationException) { } catch (TemplateValidationException) { }
} }
@@ -325,18 +649,58 @@ public partial class DesignerViewModel : ObservableObject
{ Status = text; StatusColor = error ? "#B91C1C" : "#166534"; } { Status = text; StatusColor = error ? "#B91C1C" : "#166534"; }
private static string QuoteIfLiteral(string value) => value.StartsWith('$') ? value : $"\"{value.Replace("\"", "\\\"")}\""; private static string QuoteIfLiteral(string value) => value.StartsWith('$') ? value : $"\"{value.Replace("\"", "\\\"")}\"";
private const string DefaultLayout = """ private const string EmptyStructuredLayout = """
# Elternbrief Standard
PAGE 210 297 mm PAGE 210 297 mm
#pragma format-version 3
#pragma page-template first
#pragma flow-slot body x=20 y=30 w=170 h=247
#pragma end-page-template
#pragma page-template continuation
#pragma flow-slot body x=20 y=20 w=170 h=257
#pragma end-page-template
#pragma content-flow body
#pragma end-content-flow
""";
private const string DefaultLayout = """
# Elternbrief Standard · Format 3
PAGE 210 297 mm
#pragma format-version 3
#pragma page-template first
TEXT 20 25 "Elternbrief" size=18 bold=true color=#1E3A8A TEXT 20 25 "Elternbrief" size=18 bold=true color=#1E3A8A
TEXT 20 43 $Datum|dd.MM.yyyy size=10 TEXT 20 43 $Datum|dd.MM.yyyy size=10
TEXT 20 55 $Empfaenger size=11 TEXT 20 55 $Empfaenger size=11
TEXT 20 75 "Sehr geehrte/r $Anrede," size=11 TEXT 20 75 "Sehr geehrte/r $Anrede," size=11
TEXTBOX 20 90 170 155 $Brieftext size=11 wrap=true #pragma flow-slot body x=20 y=90 w=170 h=175
TEXT 20 265 $LehrerName size=10 italic=true #pragma end-page-template
#pragma page-template continuation
#pragma flow-slot body x=20 y=25 w=170 h=240
#pragma end-page-template
#pragma content-flow body
TEXTBOX $Brieftext size=11 overflow=continue
TEXT "Mit freundlichen Grüßen" size=10 gap=8 keep-with-next=true
TEXT $LehrerName size=10 italic=true gap=4
#pragma end-content-flow
"""; """;
} }
public partial class DesignerMetadata(string name, string value) : ObservableObject
{
[ObservableProperty] private string _name = name;
[ObservableProperty] private string _value = value;
}
public sealed record DesignerPreviewPage(int Number, Bitmap Image)
{
public string Display => $"Dokumentseite {Number}";
}
public partial class DesignerAsset(string name, int pixelWidth, int pixelHeight, long byteCount) : ObservableObject public partial class DesignerAsset(string name, int pixelWidth, int pixelHeight, long byteCount) : ObservableObject
{ {
[ObservableProperty] private int _usageCount; [ObservableProperty] private int _usageCount;
@@ -358,8 +722,29 @@ public partial class DesignerPlaceholder : ObservableObject
[ObservableProperty] private PlaceholderType _type; [ObservableProperty] private PlaceholderType _type;
[ObservableProperty] private bool _required; [ObservableProperty] private bool _required;
[ObservableProperty] private string _sample; [ObservableProperty] private string _sample;
public DesignerPlaceholder(string name, PlaceholderType type, bool required, string sample) [ObservableProperty] private bool _isConstant;
{ _name = name; _type = type; _required = required; _sample = sample; } [ObservableProperty] private bool _bold;
[ObservableProperty] private bool _italic;
[ObservableProperty] private bool _underline;
public bool SupportsConstantValue => Type is PlaceholderType.Text or PlaceholderType.Multiline
or PlaceholderType.Date or PlaceholderType.Number;
public bool SupportsRichText => IsConstant && Type is PlaceholderType.Text or PlaceholderType.Multiline;
public DesignerPlaceholder(string name, PlaceholderType type, bool required, string sample,
bool isConstant = false, bool bold = false, bool italic = false, bool underline = false)
{
_name = name; _type = type; _required = required; _sample = sample;
_isConstant = isConstant; _bold = bold; _italic = italic; _underline = underline;
}
partial void OnTypeChanged(PlaceholderType value)
{
OnPropertyChanged(nameof(SupportsConstantValue));
OnPropertyChanged(nameof(SupportsRichText));
if (!SupportsConstantValue) IsConstant = false;
}
partial void OnIsConstantChanged(bool value) => OnPropertyChanged(nameof(SupportsRichText));
public PlaceholderValue ToValue() => Type switch public PlaceholderValue ToValue() => Type switch
{ {
@@ -370,11 +755,13 @@ public partial class DesignerPlaceholder : ObservableObject
PlaceholderType.Image => new ImageValue([], "image/png"), PlaceholderType.Image => new ImageValue([], "image/png"),
PlaceholderType.Table => ParseTable(Sample), PlaceholderType.Table => ParseTable(Sample),
PlaceholderType.Chart => ParseChart(Sample), PlaceholderType.Chart => ParseChart(Sample),
PlaceholderType.Drawing => SampleDrawing(),
_ => new TextValue(Sample), _ => new TextValue(Sample),
}; };
public static string SampleFor(PlaceholderType type) => type switch public static string SampleFor(PlaceholderType type) => type switch
{ PlaceholderType.Date => DateTime.Today.ToString("yyyy-MM-dd"), PlaceholderType.Number => "42,5", { PlaceholderType.Date => DateTime.Today.ToString("yyyy-MM-dd"), PlaceholderType.Number => "42,5",
PlaceholderType.Table => "Datum;Grund|01.09.;Krank", PlaceholderType.Chart => "Sep:2;Okt:3;Nov:1", _ => "Beispielwert" }; PlaceholderType.Table => "Datum;Grund|01.09.;Krank", PlaceholderType.Chart => "Sep:2;Okt:3;Nov:1",
PlaceholderType.Drawing => "Externe Zeichenbefehle", _ => "Beispielwert" };
private static TableValue ParseTable(string value) private static TableValue ParseTable(string value)
{ {
var lines = value.Split('|', StringSplitOptions.RemoveEmptyEntries); var lines = value.Split('|', StringSplitOptions.RemoveEmptyEntries);
@@ -383,6 +770,10 @@ public partial class DesignerPlaceholder : ObservableObject
} }
private static ChartValue ParseChart(string value) => new([new("Werte", value.Split(';', StringSplitOptions.RemoveEmptyEntries) private static ChartValue ParseChart(string value) => new([new("Werte", value.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Select((x, i) => { var parts = x.Split(':', 2); return new ChartPoint(parts[0], parts.Length == 2 && decimal.TryParse(parts[1], CultureInfo.InvariantCulture, out var y) ? y : i + 1); }).ToList())]); .Select((x, i) => { var parts = x.Split(':', 2); return new ChartPoint(parts[0], parts.Length == 2 && decimal.TryParse(parts[1], CultureInfo.InvariantCulture, out var y) ? y : i + 1); }).ToList())]);
private static DrawingValue SampleDrawing() => new DrawingValue(
[new DrawRectangle(0, 0, 80, 24, "#2563EB", 0.8f, "#EFF6FF"),
new DrawString(4, 4, "Dynamischer Inhalt", 10, "#1E3A8A", Bold: true),
new MoveTo(4, 19), new LineTo(76, 19, "#93C5FD", 0.6f)], 24);
} }
internal sealed class DesignerDataProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider internal sealed class DesignerDataProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
@@ -30,14 +30,18 @@ public sealed class LayoutOverlayEditor : Control
AvaloniaProperty.Register<LayoutOverlayEditor, bool>(nameof(SnapToGrid), true); AvaloniaProperty.Register<LayoutOverlayEditor, bool>(nameof(SnapToGrid), true);
public static readonly StyledProperty<double> GridSizeProperty = public static readonly StyledProperty<double> GridSizeProperty =
AvaloniaProperty.Register<LayoutOverlayEditor, double>(nameof(GridSize), 1); AvaloniaProperty.Register<LayoutOverlayEditor, double>(nameof(GridSize), 1);
public static readonly StyledProperty<string> PageTemplateNameProperty =
AvaloniaProperty.Register<LayoutOverlayEditor, string>(nameof(PageTemplateName), "first");
private readonly Pen _normalPen = new(new SolidColorBrush(Color.Parse("#2563EB")), 1.5); private readonly Pen _normalPen = new(new SolidColorBrush(Color.Parse("#2563EB")), 1.5);
private readonly Pen _selectedPen = new(new SolidColorBrush(Color.Parse("#DC2626")), 2.5); private readonly Pen _selectedPen = new(new SolidColorBrush(Color.Parse("#DC2626")), 2.5);
private readonly Pen _measurePen = new(new SolidColorBrush(Color.Parse("#D97706")), 2); private readonly Pen _measurePen = new(new SolidColorBrush(Color.Parse("#D97706")), 2);
private readonly Pen _gridPen = new(new SolidColorBrush(Color.FromArgb(55, 37, 99, 235)), 1); private readonly Pen _gridPen = new(new SolidColorBrush(Color.FromArgb(55, 37, 99, 235)), 1);
private readonly Pen _flowPen = new(new SolidColorBrush(Color.Parse("#7C3AED")), 2, dashStyle: DashStyle.Dash);
private readonly IBrush _normalFill = new SolidColorBrush(Color.FromArgb(30, 37, 99, 235)); private readonly IBrush _normalFill = new SolidColorBrush(Color.FromArgb(30, 37, 99, 235));
private readonly IBrush _selectedFill = new SolidColorBrush(Color.FromArgb(35, 220, 38, 38)); private readonly IBrush _selectedFill = new SolidColorBrush(Color.FromArgb(35, 220, 38, 38));
private readonly IBrush _handleFill = new SolidColorBrush(Color.Parse("#DC2626")); private readonly IBrush _handleFill = new SolidColorBrush(Color.Parse("#DC2626"));
private readonly IBrush _flowFill = new SolidColorBrush(Color.FromArgb(38, 124, 58, 237));
private readonly List<OverlayItem> _items = []; private readonly List<OverlayItem> _items = [];
private OverlayItem? _selected; private OverlayItem? _selected;
private Point? _pointerStartDsl; private Point? _pointerStartDsl;
@@ -52,6 +56,7 @@ public sealed class LayoutOverlayEditor : Control
public OverlayEditorMode Mode { get => GetValue(ModeProperty); set => SetValue(ModeProperty, value); } public OverlayEditorMode Mode { get => GetValue(ModeProperty); set => SetValue(ModeProperty, value); }
public bool SnapToGrid { get => GetValue(SnapToGridProperty); set => SetValue(SnapToGridProperty, value); } public bool SnapToGrid { get => GetValue(SnapToGridProperty); set => SetValue(SnapToGridProperty, value); }
public double GridSize { get => GetValue(GridSizeProperty); set => SetValue(GridSizeProperty, value); } public double GridSize { get => GetValue(GridSizeProperty); set => SetValue(GridSizeProperty, value); }
public string PageTemplateName { get => GetValue(PageTemplateNameProperty); set => SetValue(PageTemplateNameProperty, value); }
public event EventHandler<OverlayMeasurement>? MeasurementCompleted; public event EventHandler<OverlayMeasurement>? MeasurementCompleted;
public event EventHandler<OverlayElementGeometry>? ElementGeometryChanged; public event EventHandler<OverlayElementGeometry>? ElementGeometryChanged;
@@ -61,7 +66,8 @@ public sealed class LayoutOverlayEditor : Control
static LayoutOverlayEditor() static LayoutOverlayEditor()
{ {
AffectsRender<LayoutOverlayEditor>(PreviewImageProperty, LayoutSourceProperty, PageWidthProperty, AffectsRender<LayoutOverlayEditor>(PreviewImageProperty, LayoutSourceProperty, PageWidthProperty,
PageHeightProperty, PageUnitProperty, ModeProperty, SnapToGridProperty, GridSizeProperty); PageHeightProperty, PageUnitProperty, ModeProperty, SnapToGridProperty, GridSizeProperty,
PageTemplateNameProperty);
} }
public LayoutOverlayEditor() { Focusable = true; ClipToBounds = true; } public LayoutOverlayEditor() { Focusable = true; ClipToBounds = true; }
@@ -80,7 +86,9 @@ public sealed class LayoutOverlayEditor : Control
var geometry = ReferenceEquals(item, _selected) && _workingRectDsl is { } working ? working : item.Rect; var geometry = ReferenceEquals(item, _selected) && _workingRectDsl is { } working ? working : item.Rect;
var rect = ToControl(geometry, page); var rect = ToControl(geometry, page);
var selected = ReferenceEquals(item, _selected); var selected = ReferenceEquals(item, _selected);
context.DrawRectangle(selected ? _selectedFill : _normalFill, selected ? _selectedPen : _normalPen, var fill = selected ? _selectedFill : item.IsFlowSlot ? _flowFill : _normalFill;
var pen = selected ? _selectedPen : item.IsFlowSlot ? _flowPen : _normalPen;
context.DrawRectangle(fill, pen,
rect, 2, 2); rect, 2, 2);
if (selected && item.Resizable) if (selected && item.Resizable)
context.FillRectangle(_handleFill, new Rect(rect.Right - 6, rect.Bottom - 6, 12, 12), 2); context.FillRectangle(_handleFill, new Rect(rect.Right - 6, rect.Bottom - 6, 12, 12), 2);
@@ -188,11 +196,16 @@ public sealed class LayoutOverlayEditor : Control
try try
{ {
var layout = new LayoutParser().Parse(LayoutSource); var layout = new LayoutParser().Parse(LayoutSource);
foreach (var element in layout.Elements) var pageTemplate = layout.PageTemplates.FirstOrDefault(x =>
x.Name.Equals(PageTemplateName, StringComparison.OrdinalIgnoreCase))
?? layout.PageTemplates.FirstOrDefault();
var visibleElements = pageTemplate?.Elements ?? layout.Elements;
foreach (var element in visibleElements)
{ {
if (element is BackgroundElement) continue; if (element is BackgroundElement) continue;
var keyword = element switch var keyword = element switch
{ ImageElement => "IMG", TextElement => "TEXT", TextBoxElement => "TEXTBOX", { ImageElement => "IMG", TextElement => "TEXT", TextBoxElement => "TEXTBOX", FlowBoxElement => "FLOWBOX",
DrawBoxElement => "DRAWBOX", FlowDrawBoxElement => "FLOWDRAWBOX",
TableElement => "TABLE", ChartElement => "CHART", _ => "?" }; TableElement => "TABLE", ChartElement => "CHART", _ => "?" };
var (width, height, resizable) = element switch var (width, height, resizable) = element switch
{ {
@@ -201,8 +214,11 @@ public sealed class LayoutOverlayEditor : Control
ImageElement image => (image.Width * ImageScale(image), image.Height * ImageScale(image), true), ImageElement image => (image.Width * ImageScale(image), image.Height * ImageScale(image), true),
_ => ((double)element.Width, element.Height, true), _ => ((double)element.Width, element.Height, true),
}; };
_items.Add(new(element.Line, keyword, new Rect(element.X, element.Y, width, height), resizable)); _items.Add(new(element.Line, keyword, new Rect(element.X, element.Y, width, height), resizable, false));
} }
if (pageTemplate is not null)
foreach (var slot in pageTemplate.FlowSlots)
_items.Add(new(slot.Line, "FLOW", new Rect(slot.X, slot.Y, slot.Width, slot.Height), true, true));
if (_selected is not null) if (_selected is not null)
_selected = _items.FirstOrDefault(x => x.Line == _selected.Line); _selected = _items.FirstOrDefault(x => x.Line == _selected.Line);
} }
@@ -243,7 +259,7 @@ public sealed class LayoutOverlayEditor : Control
private double PointsToUnit(double points) => PageUnit.ToLowerInvariant() switch private double PointsToUnit(double points) => PageUnit.ToLowerInvariant() switch
{ "mm" => points * 25.4 / 72, "cm" => points * 2.54 / 72, "in" => points / 72, _ => points }; { "mm" => points * 25.4 / 72, "cm" => points * 2.54 / 72, "in" => points / 72, _ => points };
private sealed record OverlayItem(int Line, string Keyword, Rect Rect, bool Resizable); private sealed record OverlayItem(int Line, string Keyword, Rect Rect, bool Resizable, bool IsFlowSlot);
private enum DragKind { None, Measure, Move, Resize } private enum DragKind { None, Measure, Move, Resize }
} }
@@ -12,5 +12,6 @@
<PackageReference Include="Avalonia.Controls.DataGrid" /> <PackageReference Include="Avalonia.Controls.DataGrid" />
<PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="PDFtoImage" /> <PackageReference Include="PDFtoImage" />
<PackageReference Include="PdfPig" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+268 -98
View File
@@ -2,125 +2,294 @@
xmlns:local="clr-namespace:LehrerApp.TemplateDesigner" xmlns:local="clr-namespace:LehrerApp.TemplateDesigner"
x:Class="LehrerApp.TemplateDesigner.MainWindow" x:DataType="local:DesignerViewModel" Title="LehrerApp Vorlagen-Designer" x:Class="LehrerApp.TemplateDesigner.MainWindow" x:DataType="local:DesignerViewModel" Title="LehrerApp Vorlagen-Designer"
Width="1320" Height="860" MinWidth="1050" MinHeight="700" WindowStartupLocation="CenterScreen"> Width="1320" Height="860" MinWidth="1050" MinHeight="700" WindowStartupLocation="CenterScreen">
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,Auto,*">
<Border Padding="18,12" Background="#172554"> <Menu>
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto"> <MenuItem Header="_Datei">
<MenuItem Header="Neu (leeres Projekt)" InputGesture="Ctrl+N" Click="OnNew"/>
<MenuItem Header="Öffnen …" InputGesture="Ctrl+O" Click="OnOpenPackage"/>
<Separator/>
<MenuItem Header="Speichern" InputGesture="Ctrl+S" Click="OnSave"/>
<MenuItem Header="Speichern unter …" InputGesture="Ctrl+Shift+S" Click="OnSaveAs"/>
<MenuItem Header="Aktuelle Ansicht als PDF exportieren …" Click="OnExportCurrentPdf"/>
<Separator/>
<MenuItem Header="Ausgangsvorlage importieren …" Click="OnImportStarterTemplate"/>
<Separator/>
<MenuItem Header="Beenden" Click="OnExit"/>
</MenuItem>
<MenuItem Header="_Vorlage">
<MenuItem Header="Prüfen und Vorschau" InputGesture="F5" Click="OnPreview"/>
<MenuItem Header="Aktuelles Projekt als Ausgangsvorlage speichern" Click="OnSaveStarterTemplate"/>
<Separator/>
<MenuItem Header="Ausgewählte Ausgangsvorlage bearbeiten" Click="OnEditStarterTemplate"/>
<MenuItem Header="Ausgewählte Ausgangsvorlage als neues Projekt" Click="OnUseStarterTemplate"/>
<MenuItem Header="Ausgewählte Ausgangsvorlage duplizieren" Click="OnDuplicateStarterTemplate"/>
<MenuItem Header="Ausgewählte Ausgangsvorlage exportieren …" Click="OnExportStarterTemplate"/>
</MenuItem>
<MenuItem Header="_Einfügen">
<MenuItem Header="PDF als Vorlage importieren …" Click="OnImportPdfTemplate"/>
<Separator/>
<MenuItem Header="Bild-Asset …" Click="OnImportAsset"/>
<Separator/>
<MenuItem Header="PNG/JPEG als Hintergrund …" Click="OnImportBackground"/>
<MenuItem Header="PDF als Hintergrund …" Click="OnImportPdfBackground"/>
</MenuItem>
</Menu>
<Border Grid.Row="1" Padding="18,12" Background="#172554">
<Grid ColumnDefinitions="*,Auto">
<StackPanel><TextBlock Text="Vorlagen-Designer" Foreground="White" FontWeight="Bold" FontSize="20"/> <StackPanel><TextBlock Text="Vorlagen-Designer" Foreground="White" FontWeight="Bold" FontSize="20"/>
<TextBlock Text="Portable PDF-Briefpakete · Schema 1" Foreground="#BFDBFE" FontSize="12"/></StackPanel> <TextBlock Text="Portable PDF-Briefpakete · Layoutformat 3" Foreground="#BFDBFE" FontSize="12"/></StackPanel>
<Button Grid.Column="1" Content="Neues Projekt" Click="OnNew"/> <StackPanel Grid.Column="1" HorizontalAlignment="Right">
<Button Grid.Column="3" Content="Paket öffnen …" Click="OnOpenPackage"/> <TextBlock x:Name="DocumentNameText" Foreground="White" FontWeight="SemiBold" HorizontalAlignment="Right"/>
<Button Grid.Column="5" Content="Exportieren …" Click="OnExport" IsEnabled="{Binding CanExport}"/> <TextBlock x:Name="DocumentPathText" Foreground="#BFDBFE" FontSize="11" HorizontalAlignment="Right"/>
</StackPanel>
</Grid> </Grid>
</Border> </Border>
<Grid Grid.Row="1" ColumnDefinitions="460,*,390"> <Grid Grid.Row="2" ColumnDefinitions="460,*,390">
<ScrollViewer Grid.Column="0" Padding="18"> <TabControl Grid.Column="0" Margin="10">
<StackPanel Spacing="14"> <TabItem Header="Projekt">
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Meine Ausgangsvorlagen" Classes="section"/> <ScrollViewer Padding="8">
<Button Grid.Column="1" Content="Importieren …" Click="OnImportStarterTemplate"/></Grid> <StackPanel Spacing="14">
<ComboBox ItemsSource="{Binding StarterTemplates}" SelectedItem="{Binding SelectedStarterTemplate}" <Grid ColumnDefinitions="*,Auto"><TextBlock Text="Meine Ausgangsvorlagen" Classes="section"/>
DisplayMemberBinding="{Binding Display}" PlaceholderText="Noch keine Ausgangsvorlage"/> <Button Grid.Column="1" Content="Importieren …" Click="OnImportStarterTemplate"/></Grid>
<TextBlock Text="{Binding SelectedStarterTemplate.Description}" FontSize="12" Opacity="0.7" <ComboBox ItemsSource="{Binding StarterTemplates}" SelectedItem="{Binding SelectedStarterTemplate}"
TextWrapping="Wrap"/> DisplayMemberBinding="{Binding Display}" PlaceholderText="Noch keine Ausgangsvorlage"/>
<Grid ColumnDefinitions="*,8,*,8,*" RowDefinitions="Auto,8,Auto,8,Auto"> <TextBlock Text="{Binding SelectedStarterTemplate.Description}" FontSize="12" Opacity="0.7" TextWrapping="Wrap"/>
<Button Grid.Column="0" Content="Vorschau" Click="OnPreviewStarterTemplate"/> <Grid ColumnDefinitions="*,8,*,8,*" RowDefinitions="Auto,8,Auto,8,Auto">
<Button Grid.Column="2" Content="Bearbeiten" Click="OnEditStarterTemplate"/> <Button Grid.Column="0" Content="Vorschau" Click="OnPreviewStarterTemplate"/>
<Button Grid.Column="4" Content="Als neues Projekt" Click="OnUseStarterTemplate"/> <Button Grid.Column="2" Content="Bearbeiten" Click="OnEditStarterTemplate"/>
<Button Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" Content="Aktuelles Projekt speichern/aktualisieren" <Button Grid.Column="4" Content="Als neues Projekt" Click="OnUseStarterTemplate"/>
Click="OnSaveStarterTemplate"/> <Button Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" Content="Projekt speichern/aktualisieren" Click="OnSaveStarterTemplate"/>
<Button Grid.Row="2" Grid.Column="4" Content="Duplizieren" Click="OnDuplicateStarterTemplate"/> <Button Grid.Row="2" Grid.Column="4" Content="Duplizieren" Click="OnDuplicateStarterTemplate"/>
<Button Grid.Row="4" Grid.Column="0" Content="Exportieren …" Click="OnExportStarterTemplate"/> <Button Grid.Row="4" Grid.Column="0" Content="Exportieren …" Click="OnExportStarterTemplate"/>
<Button Grid.Row="4" Grid.Column="2" Content="Löschen" Click="OnDeleteStarterTemplate"/> <Button Grid.Row="4" Grid.Column="2" Content="Löschen" Click="OnDeleteStarterTemplate"/>
</Grid> </Grid>
<TextBlock Text="Ausgangsvorlagen werden lokal gespeichert. Beim Erstellen eines neuen Projekts bleiben Original und Briefkopf unverändert." <Separator/>
FontSize="11" Opacity="0.6" TextWrapping="Wrap"/> <TextBlock Text="Paketdaten" Classes="section"/>
<Separator/> <Grid ColumnDefinitions="*,10,*" RowDefinitions="Auto,Auto">
<StackPanel><TextBlock Text="Stabile ID" Classes="label"/><TextBox Text="{Binding TemplateId}"/></StackPanel>
<StackPanel Grid.Column="2"><TextBlock Text="Name" Classes="label"/><TextBox Text="{Binding TemplateName}"/></StackPanel>
<StackPanel Grid.Row="1" Grid.ColumnSpan="3" Margin="0,8,0,0"><TextBlock Text="Beschreibung" Classes="label"/><TextBox Text="{Binding Description}"/></StackPanel>
</Grid>
<Grid ColumnDefinitions="*,8,*,8,*">
<StackPanel><TextBlock Text="Breite" Classes="label"/><NumericUpDown Value="{Binding PageWidth}" Minimum="10" Maximum="2000"/></StackPanel>
<StackPanel Grid.Column="2"><TextBlock Text="Höhe" Classes="label"/><NumericUpDown Value="{Binding PageHeight}" Minimum="10" Maximum="2000"/></StackPanel>
<StackPanel Grid.Column="4"><TextBlock Text="Einheit" Classes="label"/><ComboBox ItemsSource="{Binding Units}" SelectedItem="{Binding Unit}"/></StackPanel>
</Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Freie Metadaten" Classes="section"/>
<Button Grid.Column="1" Content="" Click="OnAddMetadata"/></Grid>
<DataGrid ItemsSource="{Binding MetadataItems}" SelectedItem="{Binding SelectedMetadata}"
AutoGenerateColumns="False" Height="150" CanUserResizeColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="Name" Binding="{Binding Name, Mode=TwoWay}" Width="*"/>
<DataGridTextColumn Header="Wert" Binding="{Binding Value, Mode=TwoWay}" Width="1.4*"/>
</DataGrid.Columns>
</DataGrid>
<Button Content="Markierten Metadateneintrag entfernen" HorizontalAlignment="Left" Click="OnRemoveMetadata"/>
</StackPanel>
</ScrollViewer>
</TabItem>
<TextBlock Text="Paket" Classes="section"/> <TabItem Header="Platzhalter">
<Grid ColumnDefinitions="*,10,*" RowDefinitions="Auto,Auto"> <Grid Margin="8" RowDefinitions="Auto,*,Auto,Auto">
<StackPanel><TextBlock Text="Stabile ID" Classes="label"/><TextBox Text="{Binding TemplateId}"/></StackPanel> <Grid ColumnDefinitions="*,Auto"><TextBlock Text="Platzhalter" Classes="section" VerticalAlignment="Center"/>
<StackPanel Grid.Column="2"><TextBlock Text="Name" Classes="label"/><TextBox Text="{Binding TemplateName}"/></StackPanel> <Button Grid.Column="1" Content=" Neu" Click="OnAddPlaceholder"/></Grid>
<StackPanel Grid.Row="1" Grid.ColumnSpan="3" Margin="0,8,0,0"><TextBlock Text="Beschreibung" Classes="label"/><TextBox Text="{Binding Description}"/></StackPanel> <DataGrid Grid.Row="1" Margin="0,10" ItemsSource="{Binding Placeholders}"
</Grid> SelectedItem="{Binding SelectedPlaceholder, Mode=TwoWay}" AutoGenerateColumns="False"
<Grid ColumnDefinitions="*,8,*,8,*"> CanUserResizeColumns="True" IsReadOnly="True">
<StackPanel><TextBlock Text="Breite" Classes="label"/><NumericUpDown Value="{Binding PageWidth}" Minimum="10" Maximum="2000"/></StackPanel> <DataGrid.Columns>
<StackPanel Grid.Column="2"><TextBlock Text="Höhe" Classes="label"/><NumericUpDown Value="{Binding PageHeight}" Minimum="10" Maximum="2000"/></StackPanel> <DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="*"/>
<StackPanel Grid.Column="4"><TextBlock Text="Einheit" Classes="label"/><ComboBox ItemsSource="{Binding Units}" SelectedItem="{Binding Unit}"/></StackPanel> <DataGridTextColumn Header="Typ" Binding="{Binding Type}" Width="105"/>
<DataGridCheckBoxColumn Header="Pflicht" Binding="{Binding Required}" Width="65"/>
</DataGrid.Columns>
</DataGrid>
<TextBlock Grid.Row="2" IsVisible="{Binding HasNoSelectedPlaceholder}"
Text="Wähle einen Platzhalter aus oder lege einen neuen an." Opacity="0.65"
TextWrapping="Wrap" Margin="0,4,0,10"/>
<Border Grid.Row="3" IsVisible="{Binding HasSelectedPlaceholder}" Padding="12"
BorderBrush="#CBD5E1" BorderThickness="1" CornerRadius="4">
<StackPanel Spacing="8">
<TextBlock Text="Auswahl bearbeiten" FontWeight="SemiBold"/>
<Grid ColumnDefinitions="*,10,*">
<StackPanel><TextBlock Text="Name" Classes="label"/>
<TextBox Text="{Binding SelectedPlaceholder.Name, Mode=TwoWay}" PlaceholderText="z. B. Empfaenger"/></StackPanel>
<StackPanel Grid.Column="2"><TextBlock Text="Datentyp" Classes="label"/>
<ComboBox ItemsSource="{Binding PlaceholderTypes}" SelectedItem="{Binding SelectedPlaceholder.Type, Mode=TwoWay}"/></StackPanel>
</Grid>
<CheckBox Content="Pflichtwert" IsChecked="{Binding SelectedPlaceholder.Required, Mode=TwoWay}"/>
<CheckBox IsChecked="{Binding SelectedPlaceholder.IsConstant, Mode=TwoWay}"
IsEnabled="{Binding SelectedPlaceholder.SupportsConstantValue}">
<TextBlock Text="Beispielwert fest im Paket verwenden (konstant, extern nicht überschreibbar)"
TextWrapping="Wrap"/>
</CheckBox>
<StackPanel><TextBlock Text="Beispielwert / konstanter Paketwert" Classes="label"/>
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,3,0,5">
<Button Content="F" FontWeight="Bold" Padding="10,3" Click="OnBoldSelection"
IsEnabled="{Binding SelectedPlaceholder.SupportsRichText}" ToolTip.Tip="Markierten Text fett setzen"/>
<Button Content="K" FontStyle="Italic" Padding="10,3" Click="OnItalicSelection"
IsEnabled="{Binding SelectedPlaceholder.SupportsRichText}" ToolTip.Tip="Markierten Text kursiv setzen"/>
<Button Content="U̲" Padding="10,3" Click="OnUnderlineSelection"
IsEnabled="{Binding SelectedPlaceholder.SupportsRichText}" ToolTip.Tip="Markierten Text unterstreichen"/>
</StackPanel>
<TextBox x:Name="PlaceholderValueTextBox" Text="{Binding SelectedPlaceholder.Sample, Mode=TwoWay}" AcceptsReturn="True"
MinHeight="68" MaxHeight="120" TextWrapping="Wrap" PlaceholderText="Beispieldaten eingeben"/></StackPanel>
<TextBlock IsVisible="{Binding SelectedPlaceholder.SupportsRichText}"
Text="Externe Werte im Text: ${Student.LastName}. Markup: [b]fett[/b], [i]kursiv[/i], [u]unterstrichen[/u]."
FontSize="11" Opacity="0.65" TextWrapping="Wrap"/>
<StackPanel>
<TextBlock Text="Hervorhebung bei direkter Verwendung in TEXT/TEXTBOX" Classes="label"/>
<StackPanel Orientation="Horizontal" Spacing="14">
<CheckBox Content="Fett" IsChecked="{Binding SelectedPlaceholder.Bold, Mode=TwoWay}"/>
<CheckBox Content="Kursiv" IsChecked="{Binding SelectedPlaceholder.Italic, Mode=TwoWay}"/>
<CheckBox Content="Unterstrichen" IsChecked="{Binding SelectedPlaceholder.Underline, Mode=TwoWay}"/>
</StackPanel>
</StackPanel>
<Button Content="Platzhalter entfernen" HorizontalAlignment="Left" Click="OnRemovePlaceholder"/>
</StackPanel>
</Border>
</Grid> </Grid>
</TabItem>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Platzhalter &amp; Beispieldaten" Classes="section"/> <TabItem Header="Bilder">
<Button Grid.Column="1" Content="" Click="OnAddPlaceholder"/></Grid> <Grid Margin="8" RowDefinitions="Auto,*,Auto,Auto">
<DataGrid x:Name="PlaceholderGrid" ItemsSource="{Binding Placeholders}" SelectedItem="{Binding SelectedPlaceholder}" <Grid ColumnDefinitions="*,Auto"><TextBlock Text="Paket-Assets" Classes="section" VerticalAlignment="Center"/>
AutoGenerateColumns="False" Height="210" CanUserResizeColumns="True"> <Button Grid.Column="1" Content=" Importieren …" Click="OnImportAsset"/></Grid>
<DataGrid.Columns> <DataGrid Grid.Row="1" Margin="0,10" ItemsSource="{Binding AssetItems}" SelectedItem="{Binding SelectedAsset}"
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="*"/> AutoGenerateColumns="False" IsReadOnly="True" CanUserResizeColumns="True">
<DataGridTextColumn Header="Typ" Binding="{Binding Type}" Width="105" IsReadOnly="True"/> <DataGrid.Columns>
<DataGridCheckBoxColumn Header="Pflicht" Binding="{Binding Required}" Width="58"/> <DataGridTextColumn Header="Datei" Binding="{Binding Name}" Width="*"/>
<DataGridTextColumn Header="Beispiel" Binding="{Binding Sample}" Width="*"/> <DataGridTextColumn Header="Auflösung" Binding="{Binding Dimensions}" Width="110"/>
</DataGrid.Columns> <DataGridTextColumn Header="Verwendung" Binding="{Binding Usage}" Width="105"/>
</DataGrid> </DataGrid.Columns>
<Grid ColumnDefinitions="Auto,10,*"> </DataGrid>
<Button Content="Markierten Platzhalter entfernen" Click="OnRemovePlaceholder"/> <Grid Grid.Row="2" ColumnDefinitions="*,8,*,8,*">
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center"> <Button Grid.Column="0" Content="Als IMG einfügen" Click="OnInsertSelectedAsset"/>
<TextBlock Text="Typ:" VerticalAlignment="Center"/> <Button Grid.Column="2" Content="Ersetzen …" Click="OnReplaceSelectedAsset"/>
<ComboBox Width="130" ItemsSource="{Binding PlaceholderTypes}" SelectedItem="{Binding SelectedPlaceholder.Type}"/> <Button Grid.Column="4" Content="Entfernen" Click="OnRemoveSelectedAsset"/>
</Grid>
<StackPanel Grid.Row="3" Margin="0,14,0,0" Spacing="8">
<TextBlock Text="Seitenhintergrund" Classes="section"/>
<Button Content="PNG/JPEG als Hintergrund importieren …" Click="OnImportBackground"/>
<Button Content="PDF als Hintergrund (300 DPI) importieren …" Click="OnImportPdfBackground"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
</TabItem>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Paket-Assets" Classes="section"/> <TabItem Header="Seiten &amp; Flows">
<Button Grid.Column="1" Content=" Bild importieren …" Click="OnImportAsset"/></Grid> <ScrollViewer Padding="8">
<DataGrid ItemsSource="{Binding AssetItems}" SelectedItem="{Binding SelectedAsset}" <StackPanel Spacing="14">
AutoGenerateColumns="False" Height="150" IsReadOnly="True" CanUserResizeColumns="True"> <TextBlock Text="Seitentypen" Classes="section"/>
<DataGrid.Columns> <TextBlock Text="Jeder Seitentyp beschreibt nur seine eigenen festen Elemente und Flow-Slots."
<DataGridTextColumn Header="Datei" Binding="{Binding Name}" Width="*"/> TextWrapping="Wrap" Opacity="0.7" FontSize="12"/>
<DataGridTextColumn Header="Auflösung" Binding="{Binding Dimensions}" Width="125"/> <ComboBox ItemsSource="{Binding PageTemplateNames}" SelectedItem="{Binding SelectedPageTemplate, Mode=TwoWay}"/>
<DataGridTextColumn Header="Größe" Binding="{Binding FileSize}" Width="70"/> <Grid ColumnDefinitions="*,8,Auto">
<DataGridTextColumn Header="Verwendung" Binding="{Binding Usage}" Width="105"/> <TextBox Text="{Binding NewPageTemplateName}" PlaceholderText="z. B. continuation"/>
</DataGrid.Columns> <Button Grid.Column="2" Content="Seitentyp anlegen" Click="OnAddPageTemplate"/>
</DataGrid> </Grid>
<Grid ColumnDefinitions="*,8,*,8,*"> <Separator/>
<Button Grid.Column="0" Content="Als IMG einfügen" Click="OnInsertSelectedAsset"/> <TextBlock Text="Content-Flows" Classes="section"/>
<Button Grid.Column="2" Content="Ersetzen …" Click="OnReplaceSelectedAsset"/> <TextBlock Text="Ein Flow läuft automatisch in den gleichnamigen Slot der ersten und anschließend der Folgeseiten."
<Button Grid.Column="4" Content="Entfernen" Click="OnRemoveSelectedAsset"/> TextWrapping="Wrap" Opacity="0.7" FontSize="12"/>
</Grid> <ComboBox ItemsSource="{Binding ContentFlowNames}" SelectedItem="{Binding SelectedContentFlow, Mode=TwoWay}"/>
<Grid ColumnDefinitions="*,8,Auto">
<TextBox Text="{Binding NewFlowName}" PlaceholderText="z. B. body"/>
<Button Grid.Column="2" Content="Flow anlegen" Click="OnAddContentFlow"/>
</Grid>
<Separator/>
<TextBlock Text="Flow-Slot auf ausgewählter Seite" Classes="section"/>
<TextBlock Text="Die Bounding Box wird rechts violett gestrichelt dargestellt und kann dort verschoben und skaliert werden."
TextWrapping="Wrap" Opacity="0.7" FontSize="12"/>
<Grid ColumnDefinitions="*,6,*,6,*,6,*">
<StackPanel><TextBlock Text="X" Classes="label"/><TextBox Text="{Binding NewX}"/></StackPanel>
<StackPanel Grid.Column="2"><TextBlock Text="Y" Classes="label"/><TextBox Text="{Binding NewY}"/></StackPanel>
<StackPanel Grid.Column="4"><TextBlock Text="Breite" Classes="label"/><TextBox Text="{Binding NewWidth}"/></StackPanel>
<StackPanel Grid.Column="6"><TextBlock Text="Höhe" Classes="label"/><TextBox Text="{Binding NewHeight}"/></StackPanel>
</Grid>
<Button Content="Flow-Slot anlegen" HorizontalAlignment="Left" Click="OnAddFlowSlot"/>
</StackPanel>
</ScrollViewer>
</TabItem>
<TextBlock Text="Element hinzufügen" Classes="section"/> <TabItem Header="Elemente">
<ComboBox ItemsSource="{Binding ElementTypes}" SelectedItem="{Binding NewElementType}"/> <ScrollViewer Padding="8">
<Grid ColumnDefinitions="*,6,*,6,*,6,*"> <StackPanel Spacing="12">
<StackPanel><TextBlock Text="X" Classes="label"/><TextBox Text="{Binding NewX}"/></StackPanel> <TextBlock Text="Element hinzufügen" Classes="section"/>
<StackPanel Grid.Column="2"><TextBlock Text="Y" Classes="label"/><TextBox Text="{Binding NewY}"/></StackPanel> <StackPanel><TextBlock Text="Einfügen in" Classes="label"/>
<StackPanel Grid.Column="4"><TextBlock Text="Breite" Classes="label"/><TextBox Text="{Binding NewWidth}"/></StackPanel> <ComboBox ItemsSource="{Binding ElementScopes}" SelectedItem="{Binding NewElementScope}"/></StackPanel>
<StackPanel Grid.Column="6"><TextBlock Text="Höhe" Classes="label"/><TextBox Text="{Binding NewHeight}"/></StackPanel> <Grid ColumnDefinitions="*,8,*">
</Grid> <StackPanel><TextBlock Text="Seitentyp für feste Elemente" Classes="label"/>
<StackPanel> <ComboBox ItemsSource="{Binding PageTemplateNames}" SelectedItem="{Binding SelectedPageTemplate}"/></StackPanel>
<TextBlock Text="IMG-Skalierung in % (nur für IMG)" Classes="label"/> <StackPanel Grid.Column="2"><TextBlock Text="Content-Flow für fließende Elemente" Classes="label"/>
<TextBox Text="{Binding NewImageScale}" PlaceholderText="100"/> <ComboBox ItemsSource="{Binding ContentFlowNames}" SelectedItem="{Binding SelectedContentFlow}"/></StackPanel>
</StackPanel> </Grid>
<StackPanel><TextBlock Text="Inhalt / $Platzhalter / Assetpfad" Classes="label"/><TextBox Text="{Binding NewContent}" PlaceholderText="$Brieftext oder ein Literal"/></StackPanel> <StackPanel><TextBlock Text="Elementtyp" Classes="label"/>
<StackPanel><TextBlock Text="Attribute" Classes="label"/><TextBox Text="{Binding NewAttributes}" PlaceholderText="size=11 bold=true"/></StackPanel> <ComboBox ItemsSource="{Binding ElementTypes}" SelectedItem="{Binding NewElementType}"/></StackPanel>
<Button Content="Element ins Layout übernehmen" Click="OnAddElement"/> <Grid ColumnDefinitions="*,6,*,6,*,6,*">
<Button Content="PNG/JPEG als Hintergrund importieren …" Click="OnImportBackground"/> <StackPanel><TextBlock Text="X" Classes="label"/><TextBox Text="{Binding NewX}"/></StackPanel>
<Button Content="PDF als Hintergrund (300 DPI) importieren …" Click="OnImportPdfBackground"/> <StackPanel Grid.Column="2"><TextBlock Text="Y" Classes="label"/><TextBox Text="{Binding NewY}"/></StackPanel>
<TextBlock Text="Bekannte Einschränkung: TEXTBOX-Überlauf wird in v1 nicht automatisch auf Folgeseiten verteilt." <StackPanel Grid.Column="4"><TextBlock Text="Breite" Classes="label"/><TextBox Text="{Binding NewWidth}"/></StackPanel>
TextWrapping="Wrap" Foreground="#B45309" FontSize="12"/> <StackPanel Grid.Column="6"><TextBlock Text="Höhe" Classes="label"/><TextBox Text="{Binding NewHeight}"/></StackPanel>
</StackPanel> </Grid>
</ScrollViewer> <StackPanel><TextBlock Text="IMG-Skalierung in % (nur für IMG)" Classes="label"/>
<TextBox Text="{Binding NewImageScale}" PlaceholderText="100"/></StackPanel>
<StackPanel><TextBlock Text="Inhalt / $Platzhalter / Assetpfad" Classes="label"/>
<TextBox Text="{Binding NewContent}" PlaceholderText="$Brieftext oder ein Literal"/></StackPanel>
<StackPanel><TextBlock Text="Attribute" Classes="label"/>
<TextBox Text="{Binding NewAttributes}" PlaceholderText="size=11 bold=true"/></StackPanel>
<Button Content="Element ins Layout übernehmen" Click="OnAddElement"/>
<TextBlock Text="Tipp: Koordinaten können rechts im Messmodus direkt aus der Vorschau übernommen werden."
TextWrapping="Wrap" Opacity="0.65" FontSize="11"/>
<TextBlock Text="Elemente im Content-Flow verwenden Reihenfolge, gap und keep-with-next; ihre X/Y-Koordinaten werden nicht benötigt."
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
<TextBlock Text="FLOWBOX verteilt Text automatisch über beliebig viele Seiten. TEXTBOX bleibt ein fester Bereich."
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
<TextBlock Text="Systemvariablen ohne Deklaration: $$today, $$curPage und $$maxPageNum (z. B. &quot;Seite $$curPage von $$maxPageNum&quot;)."
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
<TextBlock Text="DRAWBOX und FLOWDRAWBOX nehmen deklarative Zeichenbefehle (Text, Linien, Rechtecke, Bilder) aus einer externen App entgegen."
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
<Grid Grid.Column="1" RowDefinitions="Auto,*" Margin="0,18"> <Grid Grid.Column="1" RowDefinitions="Auto,*" Margin="0,18">
<Grid ColumnDefinitions="*,Auto" Margin="12,0,12,10"><TextBlock Text="Layout-DSL" Classes="section"/> <Grid ColumnDefinitions="*,Auto,8,Auto" Margin="12,0,12,10"><TextBlock Text="Layout-DSL" Classes="section"/>
<Button Grid.Column="1" Content="Prüfen &amp; Vorschau" Click="OnPreview"/></Grid> <Button Grid.Column="1" Content="Prüfen &amp; Vorschau" Click="OnPreview"/>
<TextBox Grid.Row="1" Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap" <Button Grid.Column="3" Content="Als PDF exportieren …" Click="OnExportCurrentPdf"/></Grid>
FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top" <TabControl Grid.Row="1" Margin="12">
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto" Margin="12"/> <TabItem Header="Seite 1">
<TextBox Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap"
FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top"
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
</TabItem>
<TabItem Header="Folgeseiten">
<Grid RowDefinitions="Auto,*">
<CheckBox Margin="8" Content="Eigenes Layout für Seite 2 und alle weiteren Seiten im Paket speichern"
IsChecked="{Binding UseContinuationLayout}"/>
<TextBox Grid.Row="1" Text="{Binding ContinuationLayoutSource}" IsEnabled="{Binding UseContinuationLayout}"
AcceptsReturn="True" TextWrapping="NoWrap" FontFamily="Menlo,Consolas,monospace" FontSize="13"
VerticalContentAlignment="Top" ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
</Grid>
</TabItem>
</TabControl>
</Grid> </Grid>
<Border Grid.Column="2" Background="#E2E8F0" Padding="18"> <Border Grid.Column="2" Background="#E2E8F0" Padding="18">
<Grid RowDefinitions="Auto,Auto,*,Auto"> <Grid RowDefinitions="Auto,Auto,*,Auto">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<TextBlock Text="Visueller Layout-Editor" Classes="section"/> <StackPanel><TextBlock Text="Visueller Layout-Editor" Classes="section"/>
<ComboBox ItemsSource="{Binding PageTemplateNames}" SelectedItem="{Binding SelectedPageTemplate, Mode=TwoWay}"
MinWidth="150" Margin="0,5,0,0"/>
<Button Content="Seitentyp anzeigen" Margin="0,5,0,0" Click="OnPageTemplateSelectionChanged"/></StackPanel>
<TextBlock Grid.Column="1" Text="{Binding OverlayCoordinates}" FontFamily="Monospace" <TextBlock Grid.Column="1" Text="{Binding OverlayCoordinates}" FontFamily="Monospace"
FontSize="11" VerticalAlignment="Center"/> FontSize="11" VerticalAlignment="Center"/>
</Grid> </Grid>
<StackPanel Grid.Row="1" Spacing="7" Margin="0,10,0,0"> <StackPanel Grid.Row="1" Spacing="7" Margin="0,10,0,0">
<Grid ColumnDefinitions="Auto,8,*">
<TextBlock Text="Vorschau" VerticalAlignment="Center" Classes="label"/>
<ComboBox Grid.Column="2" ItemsSource="{Binding PreviewPages}" SelectedItem="{Binding SelectedPreviewPage}"
DisplayMemberBinding="{Binding Display}" PlaceholderText="Noch nicht gerendert"/>
</Grid>
<Grid ColumnDefinitions="*,8,*"> <Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Content="Koordinaten messen" Click="OnMeasureOverlayMode"/> <Button Grid.Column="0" Content="Koordinaten messen" Click="OnMeasureOverlayMode"/>
<Button Grid.Column="2" Content="Elemente verschieben" Click="OnEditOverlayMode"/> <Button Grid.Column="2" Content="Elemente verschieben" Click="OnEditOverlayMode"/>
@@ -138,6 +307,7 @@
LayoutSource="{Binding LayoutSource, Mode=TwoWay}" LayoutSource="{Binding LayoutSource, Mode=TwoWay}"
PageWidth="{Binding OverlayPageWidth}" PageHeight="{Binding OverlayPageHeight}" PageWidth="{Binding OverlayPageWidth}" PageHeight="{Binding OverlayPageHeight}"
PageUnit="{Binding OverlayPageUnit}" Mode="{Binding OverlayMode}" PageUnit="{Binding OverlayPageUnit}" Mode="{Binding OverlayMode}"
PageTemplateName="{Binding SelectedPageTemplate}"
SnapToGrid="{Binding SnapOverlayToGrid}" GridSize="{Binding OverlayGridSize}" SnapToGrid="{Binding SnapOverlayToGrid}" GridSize="{Binding OverlayGridSize}"
MeasurementCompleted="OnOverlayMeasurementCompleted" MeasurementCompleted="OnOverlayMeasurementCompleted"
ElementGeometryChanged="OnOverlayElementGeometryChanged" ElementGeometryChanged="OnOverlayElementGeometryChanged"
+160 -23
View File
@@ -13,22 +13,63 @@ public partial class MainWindow : Window
{ {
private readonly DesignerViewModel _viewModel = new(); private readonly DesignerViewModel _viewModel = new();
private readonly StarterTemplateLibrary _starterTemplates = new(); private readonly StarterTemplateLibrary _starterTemplates = new();
private string? _currentPackagePath;
public MainWindow() public MainWindow()
{ {
InitializeComponent(); DataContext = _viewModel; InitializeComponent(); DataContext = _viewModel;
RefreshStarterTemplates(); RefreshStarterTemplates();
UpdateDocumentTitle();
} }
private void OnNew(object? sender, RoutedEventArgs e) => _viewModel.Reset(); private void OnNew(object? sender, RoutedEventArgs e)
private void OnAddPlaceholder(object? sender, RoutedEventArgs e) =>
_viewModel.Placeholders.Add(new($"Feld{_viewModel.Placeholders.Count + 1}", PlaceholderType.Text, false, "Beispiel"));
private void OnRemovePlaceholder(object? sender, RoutedEventArgs e)
{ {
if (this.FindControl<DataGrid>("PlaceholderGrid")?.SelectedItem is DesignerPlaceholder selected) _viewModel.Reset(); _currentPackagePath = null; UpdateDocumentTitle();
_viewModel.Placeholders.Remove(selected); }
private void OnExit(object? sender, RoutedEventArgs e) => Close();
private void OnAddPlaceholder(object? sender, RoutedEventArgs e) => _viewModel.AddPlaceholder();
private void OnAddMetadata(object? sender, RoutedEventArgs e)
{
var item = new DesignerMetadata($"custom-{_viewModel.MetadataItems.Count + 1}", "");
_viewModel.MetadataItems.Add(item); _viewModel.SelectedMetadata = item; _viewModel.CanExport = false;
}
private void OnRemoveMetadata(object? sender, RoutedEventArgs e)
{
if (_viewModel.SelectedMetadata is not { } selected) return;
_viewModel.MetadataItems.Remove(selected); _viewModel.SelectedMetadata = null; _viewModel.CanExport = false;
}
private void OnRemovePlaceholder(object? sender, RoutedEventArgs e) => _viewModel.RemoveSelectedPlaceholder();
private void OnBoldSelection(object? sender, RoutedEventArgs e) => WrapPlaceholderSelection("[b]", "[/b]");
private void OnItalicSelection(object? sender, RoutedEventArgs e) => WrapPlaceholderSelection("[i]", "[/i]");
private void OnUnderlineSelection(object? sender, RoutedEventArgs e) => WrapPlaceholderSelection("[u]", "[/u]");
private void WrapPlaceholderSelection(string opening, string closing)
{
if (_viewModel.SelectedPlaceholder is not { SupportsRichText: true } placeholder
|| this.FindControl<TextBox>("PlaceholderValueTextBox") is not { } editor) return;
var source = editor.Text ?? "";
var start = Math.Min(editor.SelectionStart, editor.SelectionEnd);
var end = Math.Max(editor.SelectionStart, editor.SelectionEnd);
var updated = source[..start] + opening + source[start..end] + closing + source[end..];
placeholder.Sample = updated; editor.Text = updated;
editor.SelectionStart = start + opening.Length;
editor.SelectionEnd = end + opening.Length;
editor.Focus(); _viewModel.CanExport = false;
} }
private void OnAddElement(object? sender, RoutedEventArgs e) private void OnAddElement(object? sender, RoutedEventArgs e)
{ try { _viewModel.AddElement(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } } { try { _viewModel.AddElement(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
private void OnAddPageTemplate(object? sender, RoutedEventArgs e)
{ try { _viewModel.AddPageTemplate(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
private void OnAddContentFlow(object? sender, RoutedEventArgs e)
{ try { _viewModel.AddContentFlow(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
private void OnAddFlowSlot(object? sender, RoutedEventArgs e)
{ try { _viewModel.AddFlowSlot(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
private void OnPageTemplateSelectionChanged(object? sender, RoutedEventArgs e)
{
if (!IsLoaded) return;
try { _viewModel.PreviewSelectedPageTemplateCanvas(); }
catch (Exception ex) { _viewModel.SetStatus($"Seitentyp-Vorschau fehlgeschlagen: {ex.Message}", true); }
}
private void OnMeasureOverlayMode(object? sender, RoutedEventArgs e) private void OnMeasureOverlayMode(object? sender, RoutedEventArgs e)
{ _viewModel.OverlayMode = OverlayEditorMode.Measure; _viewModel.SelectedOverlayElement = "Messmodus aktiv"; } { _viewModel.OverlayMode = OverlayEditorMode.Measure; _viewModel.SelectedOverlayElement = "Messmodus aktiv"; }
private void OnEditOverlayMode(object? sender, RoutedEventArgs e) private void OnEditOverlayMode(object? sender, RoutedEventArgs e)
@@ -66,6 +107,22 @@ public partial class MainWindow : Window
catch (Exception ex) { _viewModel.SetStatus($"Bildimport fehlgeschlagen: {ex.Message}", true); } catch (Exception ex) { _viewModel.SetStatus($"Bildimport fehlgeschlagen: {ex.Message}", true); }
} }
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macos")]
private async void OnImportPdfTemplate(object? sender, RoutedEventArgs e)
{
var dialog = new PdfImportDialog();
var accepted = await dialog.ShowDialog<bool>(this);
if (!accepted || dialog.Result is null) return;
try
{
_viewModel.ApplyPdfImport(dialog.Result);
_currentPackagePath = null; UpdateDocumentTitle(); OnPreview(sender, e);
}
catch (Exception ex) { _viewModel.SetStatus($"PDF-Vorschlag konnte nicht übernommen werden: {ex.Message}", true); }
}
private async void OnReplaceSelectedAsset(object? sender, RoutedEventArgs e) private async void OnReplaceSelectedAsset(object? sender, RoutedEventArgs e)
{ {
if (_viewModel.SelectedAsset is null) { _viewModel.SetStatus("Bitte zuerst ein Asset auswählen.", true); return; } if (_viewModel.SelectedAsset is null) { _viewModel.SetStatus("Bitte zuerst ein Asset auswählen.", true); return; }
@@ -80,10 +137,9 @@ public partial class MainWindow : Window
{ {
try try
{ {
var bytes = new QuestTemplateRenderer().RenderFirstPageToPng(_viewModel.BuildLoaded(), _viewModel.BuildDataProvider()); _viewModel.RenderPreviewPages(_viewModel.BuildLoaded(), _viewModel.BuildDataProvider());
using var stream = new MemoryStream(bytes); _viewModel.CanExport = true;
_viewModel.PreviewImage?.Dispose(); _viewModel.PreviewImage = new Bitmap(stream); _viewModel.SetStatus($"Validierung erfolgreich. Vorschau enthält {_viewModel.PreviewPages.Count} Seite(n).", false);
_viewModel.CanExport = true; _viewModel.SetStatus("Validierung erfolgreich. Vorschau ist aktuell.", false);
} }
catch (Exception ex) catch (Exception ex)
{ _viewModel.CanExport = false; _viewModel.SetStatus(ex.Message, true); } { _viewModel.CanExport = false; _viewModel.SetStatus(ex.Message, true); }
@@ -103,8 +159,8 @@ public partial class MainWindow : Window
{ _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; } { _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; }
try try
{ {
var (template, layout) = _starterTemplates.Load(selected); var (template, layout, continuationLayout) = _starterTemplates.LoadWithContinuation(selected);
_viewModel.Load(template, layout); OnPreview(sender, e); _viewModel.Load(template, layout, continuationLayout); _currentPackagePath = null; UpdateDocumentTitle(); OnPreview(sender, e);
} }
catch (Exception ex) { _viewModel.SetStatus($"Öffnen fehlgeschlagen: {ex.Message}", true); } catch (Exception ex) { _viewModel.SetStatus($"Öffnen fehlgeschlagen: {ex.Message}", true); }
} }
@@ -115,9 +171,10 @@ public partial class MainWindow : Window
{ _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; } { _viewModel.SetStatus("Bitte zuerst eine Ausgangsvorlage auswählen.", true); return; }
try try
{ {
var (template, layout) = _starterTemplates.Load(selected); var (template, layout, continuationLayout) = _starterTemplates.LoadWithContinuation(selected);
var projectId = _starterTemplates.CreateUniqueId(template.Manifest.Id + "-neu"); var projectId = _starterTemplates.CreateUniqueId(template.Manifest.Id + "-neu");
_viewModel.LoadAsNewProject(template, layout, projectId); OnPreview(sender, e); _viewModel.LoadAsNewProject(template, layout, continuationLayout, projectId);
_currentPackagePath = null; UpdateDocumentTitle(); OnPreview(sender, e);
} }
catch (Exception ex) { _viewModel.SetStatus($"Klonen fehlgeschlagen: {ex.Message}", true); } catch (Exception ex) { _viewModel.SetStatus($"Klonen fehlgeschlagen: {ex.Message}", true); }
} }
@@ -127,7 +184,8 @@ public partial class MainWindow : Window
try try
{ {
_viewModel.BuildLoaded(); _viewModel.BuildLoaded();
var saved = _starterTemplates.Save(_viewModel.BuildManifest(), _viewModel.LayoutSource, _viewModel.Assets); var saved = _starterTemplates.Save(_viewModel.BuildManifest(), _viewModel.LayoutSource, _viewModel.Assets,
_viewModel.UseContinuationLayout ? _viewModel.ContinuationLayoutSource : null);
RefreshStarterTemplates(saved.Id); RefreshStarterTemplates(saved.Id);
_viewModel.SetStatus($"Ausgangsvorlage „{saved.Name}“ lokal gespeichert/aktualisiert.", false); _viewModel.SetStatus($"Ausgangsvorlage „{saved.Name}“ lokal gespeichert/aktualisiert.", false);
} }
@@ -197,26 +255,91 @@ public partial class MainWindow : Window
try try
{ {
var path = files[0].Path.LocalPath; var loaded = new TemplateLoader().LoadFromPackage(path); var path = files[0].Path.LocalPath; var loaded = new TemplateLoader().LoadFromPackage(path);
using var archive = ZipFile.OpenRead(path); using var reader = new StreamReader(archive.GetEntry(loaded.Manifest.LayoutFile)!.Open()); using var archive = ZipFile.OpenRead(path);
_viewModel.Load(loaded, reader.ReadToEnd()); OnPreview(sender, e); string layoutSource;
using (var reader = new StreamReader(archive.GetEntry(loaded.Manifest.LayoutFile)!.Open())) layoutSource = reader.ReadToEnd();
string? continuationLayoutSource = null;
if (loaded.Manifest.ContinuationLayoutFile is { } continuationPath)
{
using var reader = new StreamReader(archive.GetEntry(continuationPath)!.Open());
continuationLayoutSource = reader.ReadToEnd();
}
_viewModel.Load(loaded, layoutSource, continuationLayoutSource); _currentPackagePath = Path.GetFullPath(path);
UpdateDocumentTitle(); OnPreview(sender, e);
} }
catch (Exception ex) { _viewModel.SetStatus($"Import fehlgeschlagen: {ex.Message}", true); } catch (Exception ex) { _viewModel.SetStatus($"Öffnen fehlgeschlagen: {ex.Message}", true); }
} }
private async void OnExport(object? sender, RoutedEventArgs e) private async void OnSave(object? sender, RoutedEventArgs e)
{
if (_currentPackagePath is null) { await SaveAsAsync(); return; }
SaveTo(_currentPackagePath);
}
private async void OnSaveAs(object? sender, RoutedEventArgs e) => await SaveAsAsync();
private async void OnExportCurrentPdf(object? sender, RoutedEventArgs e)
{
byte[] pdf;
try
{
pdf = _viewModel.RenderCurrentPdf();
}
catch (Exception ex)
{
_viewModel.SetStatus($"PDF-Export fehlgeschlagen: {ex.Message}", true);
return;
}
var file = await StorageProvider.SaveFilePickerAsync(new()
{
Title = "Aktuelle Vorlagenansicht als PDF exportieren",
SuggestedFileName = _viewModel.TemplateId + "-vorschau.pdf",
DefaultExtension = "pdf",
FileTypeChoices = [new("PDF-Dateien") { Patterns = ["*.pdf"] }],
});
if (file is null) return;
try
{
var destination = EnsurePdfExtension(file.Path.LocalPath);
await File.WriteAllBytesAsync(destination, pdf);
_viewModel.SetStatus($"PDF-Vorschau exportiert: {Path.GetFileName(destination)}", false);
}
catch (Exception ex)
{
_viewModel.SetStatus($"PDF-Export fehlgeschlagen: {ex.Message}", true);
}
}
private async Task SaveAsAsync()
{ {
try { _viewModel.BuildLoaded(); } try { _viewModel.BuildLoaded(); }
catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); return; } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); return; }
var file = await StorageProvider.SaveFilePickerAsync(new() var file = await StorageProvider.SaveFilePickerAsync(new()
{ Title = "Vorlagenpaket exportieren", SuggestedFileName = _viewModel.TemplateId + TemplatePackage.Extension, { Title = "Vorlagenpaket speichern unter", SuggestedFileName = _viewModel.TemplateId + TemplatePackage.Extension,
DefaultExtension = TemplatePackage.Extension[1..], FileTypeChoices = [PackageType()] }); DefaultExtension = TemplatePackage.Extension[1..], FileTypeChoices = [PackageType()] });
if (file is null) return; if (file is null) return;
var destination = EnsurePackageExtension(file.Path.LocalPath);
if (SaveTo(destination))
{
_currentPackagePath = Path.GetFullPath(destination);
UpdateDocumentTitle();
}
}
private bool SaveTo(string path)
{
try try
{ {
TemplatePackage.Create(file.Path.LocalPath, _viewModel.BuildManifest(), _viewModel.LayoutSource, _viewModel.Assets); _viewModel.BuildLoaded();
_viewModel.SetStatus($"Paket exportiert: {file.Name}", false); TemplatePackage.Create(path, _viewModel.BuildManifest(), _viewModel.LayoutSource, _viewModel.Assets,
_viewModel.UseContinuationLayout ? _viewModel.ContinuationLayoutSource : null);
_viewModel.CanExport = true;
_viewModel.SetStatus($"Gespeichert: {Path.GetFileName(path)}", false);
return true;
} }
catch (Exception ex) { _viewModel.SetStatus($"Export fehlgeschlagen: {ex.Message}", true); } catch (Exception ex) { _viewModel.SetStatus($"Speichern fehlgeschlagen: {ex.Message}", true); return false; }
} }
private async void OnImportBackground(object? sender, RoutedEventArgs e) private async void OnImportBackground(object? sender, RoutedEventArgs e)
@@ -251,6 +374,20 @@ public partial class MainWindow : Window
private static FilePickerFileType ImageType() => new("PNG/JPEG-Bilder") { Patterns = ["*.png", "*.jpg", "*.jpeg"] }; private static FilePickerFileType ImageType() => new("PNG/JPEG-Bilder") { Patterns = ["*.png", "*.jpg", "*.jpeg"] };
private static FilePickerFileType PackageType() => new("LehrerApp-Vorlagen") { Patterns = ["*.lavorlage"] }; private static FilePickerFileType PackageType() => new("LehrerApp-Vorlagen") { Patterns = ["*.lavorlage"] };
private static string EnsurePackageExtension(string path) =>
string.Equals(Path.GetExtension(path), TemplatePackage.Extension, StringComparison.OrdinalIgnoreCase)
? path : path + TemplatePackage.Extension;
private static string EnsurePdfExtension(string path) =>
string.Equals(Path.GetExtension(path), ".pdf", StringComparison.OrdinalIgnoreCase)
? path : path + ".pdf";
private void RefreshStarterTemplates(string? selectedId = null) => private void RefreshStarterTemplates(string? selectedId = null) =>
_viewModel.SetStarterTemplates(_starterTemplates.GetAll(), selectedId); _viewModel.SetStarterTemplates(_starterTemplates.GetAll(), selectedId);
private void UpdateDocumentTitle()
{
var fileName = _currentPackagePath is null ? "Unbenannt" : Path.GetFileName(_currentPackagePath);
Title = $"{fileName} LehrerApp Vorlagen-Designer";
DocumentNameText.Text = fileName;
DocumentPathText.Text = _currentPackagePath ?? "Noch nicht gespeichert";
}
} }
@@ -0,0 +1,53 @@
<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:LehrerApp.TemplateDesigner"
x:Class="LehrerApp.TemplateDesigner.PdfImportDialog" x:DataType="local:PdfImportDialogViewModel" Title="PDF als Vorlage importieren"
Width="920" Height="720" MinWidth="760" MinHeight="600" WindowStartupLocation="CenterOwner">
<Grid Margin="18" RowDefinitions="Auto,Auto,Auto,Auto,*,Auto" RowSpacing="12">
<TextBlock FontSize="20" FontWeight="Bold" Text="PDF-Import mit geometrischer Analyse"/>
<TextBlock Grid.Row="1" TextWrapping="Wrap" Opacity="0.75"
Text="Koordinaten werden lokal aus dem PDF gelesen. Die optionale KI ordnet nur Namen und Datentypen zu; sie erhält weder PDF noch Bilder."/>
<Grid Grid.Row="2" ColumnDefinitions="Auto,*,Auto" RowDefinitions="Auto,8,Auto">
<TextBlock VerticalAlignment="Center" Text="Ausgefülltes Beispiel:"/>
<TextBox Grid.Column="1" Margin="10,0" Text="{Binding ExamplePath}" IsReadOnly="True"/>
<Button Grid.Column="2" Content="Auswählen …" Click="OnChooseExample"/>
<TextBlock Grid.Row="2" VerticalAlignment="Center" Text="Leeres Template (optional):"/>
<TextBox Grid.Row="2" Grid.Column="1" Margin="10,0" Text="{Binding TemplatePath}" IsReadOnly="True"/>
<Button Grid.Row="2" Grid.Column="2" Content="Auswählen …" Click="OnChooseTemplate"/>
</Grid>
<StackPanel Grid.Row="3" Spacing="8">
<CheckBox Content="KI-Backend für semantische Klassifikation verwenden" IsChecked="{Binding UseAi}"/>
<Grid ColumnDefinitions="*,10,*" IsEnabled="{Binding UseAi}">
<TextBox PlaceholderText="Benutzername" Text="{Binding Username}"/>
<TextBox Grid.Column="2" PlaceholderText="Passwort (wird nicht gespeichert)" PasswordChar="●" Text="{Binding Password}"/>
</Grid>
<Button HorizontalAlignment="Left" Content="Analysieren" Click="OnAnalyze" IsEnabled="{Binding CanAnalyze}"/>
<TextBlock Text="{Binding Status}" TextWrapping="Wrap"/>
</StackPanel>
<DataGrid Grid.Row="4" ItemsSource="{Binding Candidates}" AutoGenerateColumns="False" CanUserResizeColumns="True">
<DataGrid.Columns>
<DataGridCheckBoxColumn Header="Übernehmen" Binding="{Binding Include}" Width="85"/>
<DataGridTextColumn Header="Erkannter Inhalt" Binding="{Binding OriginalText}" IsReadOnly="True" Width="2*"/>
<DataGridTextColumn Header="Platzhalter" Binding="{Binding Name}" Width="*"/>
<DataGridTemplateColumn Header="Typ" Width="130">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="local:PdfImportCandidate">
<ComboBox ItemsSource="{Binding AvailableTypes}" SelectedItem="{Binding Type}"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Konfidenz" Width="90">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="local:PdfImportCandidate">
<TextBlock Text="{Binding ConfidenceLabel}" Foreground="{Binding ConfidenceColor}" FontWeight="SemiBold"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<Grid Grid.Row="5" ColumnDefinitions="*,Auto,10,Auto">
<TextBlock VerticalAlignment="Center" Text="Rot/niedrig und gelb/mittel bitte besonders sorgfältig prüfen." Opacity="0.7"/>
<Button Grid.Column="1" Content="Abbrechen" Click="OnCancel"/>
<Button Grid.Column="3" Content="Geprüften Vorschlag übernehmen" Click="OnAccept" IsEnabled="{Binding HasResult}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,89 @@
using System.Collections.ObjectModel;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Templating;
using System.Runtime.Versioning;
namespace LehrerApp.TemplateDesigner;
public partial class PdfImportDialogViewModel : ObservableObject
{
[ObservableProperty] private string _examplePath = "";
[ObservableProperty] private string _templatePath = "";
[ObservableProperty] private bool _useAi = true;
[ObservableProperty] private string _username = "";
[ObservableProperty] private string _password = "";
[ObservableProperty] private string _status = "Bitte mindestens ein ausgefülltes Beispiel-PDF auswählen.";
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private bool _hasResult;
public ObservableCollection<PdfImportCandidate> Candidates { get; } = [];
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } =
[PlaceholderType.Text, PlaceholderType.Multiline, PlaceholderType.Date, PlaceholderType.Number];
public bool CanAnalyze => !IsBusy && File.Exists(ExamplePath) && (!UseAi
|| (!string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password)));
partial void OnExamplePathChanged(string value) => OnPropertyChanged(nameof(CanAnalyze));
partial void OnUseAiChanged(bool value) => OnPropertyChanged(nameof(CanAnalyze));
partial void OnUsernameChanged(string value) => OnPropertyChanged(nameof(CanAnalyze));
partial void OnPasswordChanged(string value) => OnPropertyChanged(nameof(CanAnalyze));
partial void OnIsBusyChanged(bool value) => OnPropertyChanged(nameof(CanAnalyze));
}
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macos")]
public partial class PdfImportDialog : Window
{
private readonly PdfImportDialogViewModel _viewModel = new();
private PdfImportResult? _result;
public PdfImportResult? Result { get; private set; }
public PdfImportDialog() { InitializeComponent(); DataContext = _viewModel; }
private async void OnChooseExample(object? sender, RoutedEventArgs e)
{
var file = await ChoosePdf("Ausgefülltes Beispiel-PDF auswählen");
if (file is not null) _viewModel.ExamplePath = file;
}
private async void OnChooseTemplate(object? sender, RoutedEventArgs e)
{
var file = await ChoosePdf("Leeres Template-PDF auswählen");
if (file is not null) _viewModel.TemplatePath = file;
}
private async Task<string?> ChoosePdf(string title)
{
var files = await StorageProvider.OpenFilePickerAsync(new()
{ Title = title, AllowMultiple = false, FileTypeFilter = [new("PDF-Dateien") { Patterns = ["*.pdf"] }] });
return files.Count == 0 ? null : files[0].Path.LocalPath;
}
private async void OnAnalyze(object? sender, RoutedEventArgs e)
{
_viewModel.IsBusy = true; _viewModel.HasResult = false; _viewModel.Status = "PDF wird lokal analysiert …";
try
{
_result = await new PdfImportPipeline().BuildAsync(_viewModel.ExamplePath,
string.IsNullOrWhiteSpace(_viewModel.TemplatePath) ? null : _viewModel.TemplatePath,
_viewModel.UseAi ? _viewModel.Username : null, _viewModel.UseAi ? _viewModel.Password : null);
_viewModel.Candidates.Clear();
foreach (var candidate in _result.Candidates) _viewModel.Candidates.Add(candidate);
_viewModel.HasResult = true;
_viewModel.Status = $"{_viewModel.Candidates.Count} variable Textbereiche erkannt. Bitte Zuordnung prüfen und bestätigen.";
}
catch (Exception ex) { _viewModel.Status = $"Import fehlgeschlagen: {ex.Message}"; }
finally { _viewModel.IsBusy = false; }
}
private void OnAccept(object? sender, RoutedEventArgs e)
{
if (_result is null) return;
var document = new PdfImportPipeline().Extract(_viewModel.ExamplePath);
Result = new PdfImportPipeline().BuildResult(_viewModel.ExamplePath, document, _viewModel.Candidates);
Close(true);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -0,0 +1,294 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Runtime.Versioning;
using LehrerApp.Templating;
using PDFtoImage;
using UglyToad.PdfPig;
namespace LehrerApp.TemplateDesigner;
public enum PdfImportConfidence { Low, Medium, High }
public sealed record PdfTextBlock(string Id, int PageNumber, double X, double Y, double Width, double Height,
double FontSize, string FontName, bool Bold, bool Italic, string Text);
public sealed record PdfImportPage(int PageNumber, double Width, double Height, IReadOnlyList<PdfTextBlock> TextBlocks);
public sealed record PdfImportDocument(IReadOnlyList<PdfImportPage> Pages);
public sealed class PdfImportCandidate
{
public required string Id { get; init; }
public required List<string> BlockIds { get; set; }
public required string OriginalText { get; init; }
public required string Name { get; set; }
public PlaceholderType Type { get; set; } = PlaceholderType.Text;
public PdfImportConfidence Confidence { get; set; }
public bool Include { get; set; } = true;
public string ConfidenceLabel => Confidence switch
{ PdfImportConfidence.High => "Hoch", PdfImportConfidence.Medium => "Mittel", _ => "Niedrig" };
public string ConfidenceColor => Confidence switch
{ PdfImportConfidence.High => "#15803D", PdfImportConfidence.Medium => "#A16207", _ => "#B91C1C" };
public IReadOnlyList<PlaceholderType> AvailableTypes { get; } =
[PlaceholderType.Text, PlaceholderType.Multiline, PlaceholderType.Date, PlaceholderType.Number];
}
public sealed record PdfImportResult(string LayoutSource, TemplateManifest Manifest,
IReadOnlyDictionary<string, byte[]> Assets, IReadOnlyList<PdfImportCandidate> Candidates);
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macos")]
public sealed class PdfImportPipeline
{
private const double PositionTolerance = 2;
public PdfImportDocument Extract(string path)
{
using var document = PdfDocument.Open(path);
var pages = new List<PdfImportPage>();
foreach (var page in document.GetPages())
{
var words = page.GetWords().OrderByDescending(x => x.BoundingBox.Top).ThenBy(x => x.BoundingBox.Left).ToList();
var lines = new List<List<UglyToad.PdfPig.Content.Word>>();
foreach (var word in words)
{
var line = lines.FirstOrDefault(x => Math.Abs(x[0].BoundingBox.Bottom - word.BoundingBox.Bottom)
<= Math.Max(1.5, word.BoundingBox.Height * .35));
if (line is null) lines.Add([word]); else line.Add(word);
}
var blocks = lines.Select((line, index) =>
{
var ordered = line.OrderBy(x => x.BoundingBox.Left).ToList();
var left = ordered.Min(x => x.BoundingBox.Left); var right = ordered.Max(x => x.BoundingBox.Right);
var bottom = ordered.Min(x => x.BoundingBox.Bottom); var top = ordered.Max(x => x.BoundingBox.Top);
var letters = ordered.SelectMany(x => x.Letters).ToList();
var font = letters.FirstOrDefault()?.FontName ?? "";
return new PdfTextBlock($"p{page.Number}-t{index + 1}", page.Number,
left, page.Height - top, right - left, top - bottom,
letters.Count == 0 ? top - bottom : letters.Average(x => x.FontSize), font,
font.Contains("Bold", StringComparison.OrdinalIgnoreCase),
font.Contains("Italic", StringComparison.OrdinalIgnoreCase) || font.Contains("Oblique", StringComparison.OrdinalIgnoreCase),
string.Join(' ', ordered.Select(x => x.Text)));
}).Where(x => !string.IsNullOrWhiteSpace(x.Text)).ToList();
pages.Add(new(page.Number, page.Width, page.Height, blocks));
}
return new(pages);
}
public List<PdfImportCandidate> FindCandidates(PdfImportDocument example, PdfImportDocument? blankTemplate)
{
var result = new List<PdfImportCandidate>();
foreach (var page in example.Pages)
{
var templateBlocks = blankTemplate?.Pages.FirstOrDefault(x => x.PageNumber == page.PageNumber)?.TextBlocks ?? [];
foreach (var block in page.TextBlocks)
{
var same = templateBlocks.Any(other => Near(block, other) && other.Text.Equals(block.Text, StringComparison.Ordinal));
if (same) continue;
var confidence = blankTemplate is not null ? PdfImportConfidence.High : HeuristicConfidence(block, page);
result.Add(new PdfImportCandidate
{
Id = block.Id, BlockIds = [block.Id], OriginalText = block.Text,
Name = SuggestedName(block.Text, block, page), Type = SuggestedType(block.Text), Confidence = confidence,
});
}
}
return result;
}
public async Task<PdfImportResult> BuildAsync(string examplePath, string? templatePath,
string? username, string? password, CancellationToken cancellationToken = default)
{
var example = Extract(examplePath);
var blank = templatePath is null ? null : Extract(templatePath);
EnsureCompatible(example, blank);
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.");
var candidates = FindCandidates(example, blank);
if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password))
{
var client = new PdfImportAiClient(new HttpClient { BaseAddress = new Uri("https://backapi.science-teaching.de/") });
var classifications = await client.ClassifyAsync(username, password, example, candidates, cancellationToken);
ApplyClassifications(candidates, classifications);
}
return BuildResult(templatePath ?? examplePath, example, candidates);
}
public PdfImportResult BuildResult(string backgroundPdfPath, PdfImportDocument document,
IReadOnlyList<PdfImportCandidate> candidates)
{
if (document.Pages.Count == 0) throw new InvalidDataException("Das PDF enthält keine Seiten.");
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.");
var first = document.Pages[0];
var assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
{
["pdf-import-background.png"] = RenderPage(backgroundPdfPath),
["pdf-import-mask.png"] = WhitePixelPng,
};
var lines = new List<string>
{
$"PAGE {N(first.Width)} {N(first.Height)} pt",
"BG pdf-import-background.png",
};
var definitions = new List<PlaceholderDefinition>();
var usedNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var candidate in candidates.Where(x => x.Include))
{
var blocks = candidate.BlockIds.Select(id => first.TextBlocks.FirstOrDefault(x => x.Id == id))
.Where(x => x is not null).Cast<PdfTextBlock>().ToList();
if (blocks.Count == 0) continue;
var name = UniqueName(SanitizeName(candidate.Name), usedNames);
var x = blocks.Min(b => b.X); var y = blocks.Min(b => b.Y);
var right = blocks.Max(b => b.X + b.Width); var bottom = blocks.Max(b => b.Y + b.Height);
var padding = Math.Max(1, blocks.Average(b => b.FontSize) * .18);
lines.Add($"IMG pdf-import-mask.png {N(x - padding)} {N(y - padding)} {N(right - x + 2 * padding)} {N(bottom - y + 2 * padding)}");
var attrs = $"size={N(blocks.Average(b => b.FontSize))}"
+ (blocks.Any(b => b.Bold) ? " bold=true" : "") + (blocks.Any(b => b.Italic) ? " italic=true" : "");
var multiline = candidate.Type == PlaceholderType.Multiline || blocks.Count > 1 || candidate.OriginalText.Contains('\n');
lines.Add(multiline
? $"TEXTBOX {N(x)} {N(y)} {N(Math.Max(20, right - x))} {N(Math.Max(bottom - y, blocks.Average(b => b.FontSize) * 2.5))} ${name} {attrs}"
: $"TEXT {N(x)} {N(y)} ${name} {attrs}");
definitions.Add(new(name, multiline ? PlaceholderType.Multiline : candidate.Type, false));
candidate.Name = name;
}
var manifest = new TemplateManifest
{
Id = "pdf-import", Name = "PDF-Import", Description = "Automatisch aus einem PDF rekonstruiert",
PageSize = new((float)first.Width, (float)first.Height, "pt"), Placeholders = definitions,
Metadata = new(StringComparer.OrdinalIgnoreCase) { [TemplateMetadataKeys.Language] = "de-DE" },
};
return new(string.Join(Environment.NewLine, lines) + Environment.NewLine, manifest, assets, candidates);
}
private static void ApplyClassifications(List<PdfImportCandidate> candidates,
IReadOnlyList<PdfAiClassification> classifications)
{
foreach (var classification in classifications)
{
var candidate = candidates.FirstOrDefault(x => x.Id == classification.Id);
if (candidate is null) continue;
candidate.Name = classification.Name;
candidate.Type = classification.Type;
candidate.Confidence = classification.Confidence;
if (classification.BlockIds.Count > 0) candidate.BlockIds = classification.BlockIds;
}
}
private static bool Near(PdfTextBlock a, PdfTextBlock b) => Math.Abs(a.X - b.X) <= PositionTolerance
&& Math.Abs(a.Y - b.Y) <= PositionTolerance && Math.Abs(a.Width - b.Width) <= Math.Max(PositionTolerance, a.Width * .08);
private static PdfImportConfidence HeuristicConfidence(PdfTextBlock block, PdfImportPage page)
{
if (SuggestedType(block.Text) is PlaceholderType.Date or PlaceholderType.Number) return PdfImportConfidence.High;
if (block.Y < page.Height * .42 && block.X < page.Width * .6) return PdfImportConfidence.Medium;
return PdfImportConfidence.Low;
}
private static PlaceholderType SuggestedType(string text)
{
if (System.Text.RegularExpressions.Regex.IsMatch(text, @"\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b")) return PlaceholderType.Date;
if (decimal.TryParse(text, NumberStyles.Number, CultureInfo.GetCultureInfo("de-DE"), out _)) return PlaceholderType.Number;
return text.Length > 100 ? PlaceholderType.Multiline : PlaceholderType.Text;
}
private static string SuggestedName(string text, PdfTextBlock block, PdfImportPage page)
{
if (SuggestedType(text) == PlaceholderType.Date) return "Datum";
if (System.Text.RegularExpressions.Regex.IsMatch(text, @"^\d{5}\s+")) return "PlzOrt";
if (text.StartsWith("Betreff", StringComparison.OrdinalIgnoreCase)) return "Betreff";
if (block.Y < page.Height * .42 && block.X < page.Width * .6) return "Adresse";
return "Feld";
}
private static string SanitizeName(string name)
{
var safe = string.Concat(name.Trim().Where(char.IsLetterOrDigit));
return string.IsNullOrEmpty(safe) ? "Feld" : safe;
}
private static string UniqueName(string name, HashSet<string> used)
{
if (used.Add(name)) return name;
for (var i = 2; ; i++) if (used.Add(name + i)) return name + i;
}
private static string N(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
private static void EnsureCompatible(PdfImportDocument example, PdfImportDocument? blank)
{
if (blank is null) return;
if (example.Pages.Count != blank.Pages.Count) throw new InvalidDataException("Template und Beispiel haben unterschiedlich viele Seiten.");
for (var i = 0; i < example.Pages.Count; i++)
if (Math.Abs(example.Pages[i].Width - blank.Pages[i].Width) > PositionTolerance
|| Math.Abs(example.Pages[i].Height - blank.Pages[i].Height) > PositionTolerance)
throw new InvalidDataException("Template und Beispiel haben unterschiedliche Seitengrößen.");
}
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macos")]
private static byte[] RenderPage(string path)
{
var temporary = Path.Combine(Path.GetTempPath(), $"lehrerapp-pdf-import-{Guid.NewGuid():N}.png");
try
{
using var pdf = File.OpenRead(path);
Conversion.SavePng(temporary, pdf, page: 0, options: new RenderOptions(Dpi: 300));
return File.ReadAllBytes(temporary);
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
private static readonly byte[] WhitePixelPng = Convert.FromBase64String(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nJkAAAAASUVORK5CYII=");
}
public sealed record PdfAiClassification(string Id, List<string> BlockIds, string Name,
PlaceholderType Type, PdfImportConfidence Confidence);
internal sealed class PdfImportAiClient(HttpClient http)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
public async Task<IReadOnlyList<PdfAiClassification>> ClassifyAsync(string username, string password,
PdfImportDocument document, IReadOnlyList<PdfImportCandidate> candidates, CancellationToken cancellationToken)
{
HttpResponseMessage login;
try { login = await http.PostAsJsonAsync("login.php", new { username, password }, JsonOptions, cancellationToken); }
catch (HttpRequestException ex) { throw new InvalidOperationException("Der KI-Dienst ist nicht erreichbar.", ex); }
if (login.StatusCode == HttpStatusCode.Unauthorized) throw new InvalidOperationException("Benutzername oder Passwort ist falsch.");
login.EnsureSuccessStatusCode();
var token = (await login.Content.ReadFromJsonAsync<LoginResult>(JsonOptions, cancellationToken))?.Token
?? throw new InvalidOperationException("Das KI-Backend lieferte kein Token.");
using var request = new HttpRequestMessage(HttpMethod.Post, "pdf-template.php")
{
Content = JsonContent.Create(new { document, candidates = candidates.Select(x => new
{ x.Id, x.BlockIds, x.OriginalText, suggestedName = x.Name, x.Type, x.Confidence }) }, options: JsonOptions),
};
request.Headers.Authorization = new("Bearer", token);
using var response = await http.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.PaymentRequired) throw new InvalidOperationException("Nicht genügend KI-Guthaben.");
if (response.StatusCode == HttpStatusCode.Unauthorized) throw new InvalidOperationException("Die KI-Anmeldung ist abgelaufen.");
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadFromJsonAsync<ErrorResult>(JsonOptions, cancellationToken);
throw new InvalidOperationException(error?.Error ?? "Die KI-Klassifikation ist fehlgeschlagen.");
}
var result = await response.Content.ReadFromJsonAsync<ClassificationResult>(JsonOptions, cancellationToken);
return result?.Classifications ?? throw new InvalidOperationException("Die KI-Antwort ist unvollständig.");
}
private sealed record LoginResult(string Token);
private sealed record ErrorResult(string Error);
private sealed record ClassificationResult(List<PdfAiClassification> Classifications);
}
@@ -39,17 +39,17 @@ public sealed class StarterTemplateLibrary
} }
public StarterTemplateItem Save(TemplateManifest manifest, string layoutSource, public StarterTemplateItem Save(TemplateManifest manifest, string layoutSource,
IReadOnlyDictionary<string, byte[]> assets) IReadOnlyDictionary<string, byte[]> assets, string? continuationLayoutSource = null)
{ {
var path = Path.Combine(_directory, SafeId(manifest.Id) + TemplatePackage.Extension); var path = Path.Combine(_directory, SafeId(manifest.Id) + TemplatePackage.Extension);
TemplatePackage.Create(path, manifest, layoutSource, assets); TemplatePackage.Create(path, manifest, layoutSource, assets, continuationLayoutSource);
return ToItem(manifest, path); return ToItem(manifest, path);
} }
public StarterTemplateItem Import(string sourcePath) public StarterTemplateItem Import(string sourcePath)
{ {
var (template, layout) = LoadPackage(sourcePath); var (template, layout, continuationLayout) = LoadPackage(sourcePath);
return Save(template.Manifest, layout, template.Assets); return Save(template.Manifest, layout, template.Assets, continuationLayout);
} }
public void Export(StarterTemplateItem item, string destinationPath) public void Export(StarterTemplateItem item, string destinationPath)
@@ -64,13 +64,19 @@ public sealed class StarterTemplateLibrary
public StarterTemplateItem Duplicate(StarterTemplateItem item) public StarterTemplateItem Duplicate(StarterTemplateItem item)
{ {
var (template, layout) = Load(item); var (template, layout, continuationLayout) = LoadWithContinuation(item);
var id = CreateUniqueId(template.Manifest.Id + "-kopie"); var id = CreateUniqueId(template.Manifest.Id + "-kopie");
var manifest = CopyManifest(template.Manifest, id, template.Manifest.Name + " - Kopie"); var manifest = CopyManifest(template.Manifest, id, template.Manifest.Name + " - Kopie");
return Save(manifest, layout, template.Assets); return Save(manifest, layout, template.Assets, continuationLayout);
} }
public (LoadedTemplate Template, string LayoutSource) Load(StarterTemplateItem item) => public (LoadedTemplate Template, string LayoutSource) Load(StarterTemplateItem item)
{
var (template, layout, _) = LoadPackage(item.PackagePath);
return (template, layout);
}
public (LoadedTemplate Template, string LayoutSource, string? ContinuationLayoutSource) LoadWithContinuation(StarterTemplateItem item) =>
LoadPackage(item.PackagePath); LoadPackage(item.PackagePath);
public void Delete(StarterTemplateItem item) public void Delete(StarterTemplateItem item)
@@ -78,15 +84,24 @@ public sealed class StarterTemplateLibrary
if (File.Exists(item.PackagePath)) File.Delete(item.PackagePath); if (File.Exists(item.PackagePath)) File.Delete(item.PackagePath);
} }
private (LoadedTemplate Template, string LayoutSource) LoadPackage(string path) private (LoadedTemplate Template, string LayoutSource, string? ContinuationLayoutSource) LoadPackage(string path)
{ {
var template = _loader.LoadFromPackage(path); var template = _loader.LoadFromPackage(path);
using var archive = ZipFile.OpenRead(path); using var archive = ZipFile.OpenRead(path);
var layoutEntry = archive.Entries.FirstOrDefault(x => x.FullName.Equals( var layoutEntry = archive.Entries.FirstOrDefault(x => x.FullName.Equals(
template.Manifest.LayoutFile.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase)) template.Manifest.LayoutFile.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase))
?? throw new InvalidDataException($"Layoutdatei „{template.Manifest.LayoutFile}“ fehlt."); ?? throw new InvalidDataException($"Layoutdatei „{template.Manifest.LayoutFile}“ fehlt.");
using var reader = new StreamReader(layoutEntry.Open()); string layoutSource;
return (template, reader.ReadToEnd()); using (var reader = new StreamReader(layoutEntry.Open())) layoutSource = reader.ReadToEnd();
string? continuationLayoutSource = null;
if (template.Manifest.ContinuationLayoutFile is { } continuationPath)
{
var continuationEntry = archive.Entries.First(x => x.FullName.Equals(
continuationPath.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase));
using var reader = new StreamReader(continuationEntry.Open());
continuationLayoutSource = reader.ReadToEnd();
}
return (template, layoutSource, continuationLayoutSource);
} }
public string CreateUniqueId(string baseId) public string CreateUniqueId(string baseId)
@@ -108,7 +123,11 @@ public sealed class StarterTemplateLibrary
Description = source.Description, Description = source.Description,
PageSize = new(source.PageSize.Width, source.PageSize.Height, source.PageSize.Unit), PageSize = new(source.PageSize.Width, source.PageSize.Height, source.PageSize.Unit),
LayoutFile = source.LayoutFile, LayoutFile = source.LayoutFile,
Placeholders = source.Placeholders.Select(x => new PlaceholderDefinition(x.Name, x.Type, x.Required)).ToList(), ContinuationLayoutFile = source.ContinuationLayoutFile,
MetadataFile = source.MetadataFile,
Metadata = new(source.Metadata, StringComparer.OrdinalIgnoreCase),
Placeholders = source.Placeholders.Select(x => new PlaceholderDefinition(x.Name, x.Type, x.Required,
x.IsConstant, x.ConstantValue, x.Bold, x.Italic, x.Underline)).ToList(),
}; };
private static StarterTemplateItem ToItem(TemplateManifest manifest, string path) => private static StarterTemplateItem ToItem(TemplateManifest manifest, string path) =>
@@ -0,0 +1,144 @@
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Templating.Tests;
public sealed class ConstantPlaceholderTests
{
[Fact]
public void Resolver_KonstanterWertUeberschreibtExternenWert()
{
var manifest = new TemplateManifest
{
Placeholders = [new("Hinweis", PlaceholderType.Multiline, true, true, "Interner Langtext")],
};
var external = new Dictionary<string, PlaceholderValue>
{ ["Hinweis"] = new MultilineValue("Extern manipuliert") };
var resolved = TemplateDataResolver.Resolve(manifest, external);
Assert.Equal("Interner Langtext", Assert.IsType<MultilineValue>(resolved["Hinweis"]).Value);
}
[Fact]
public void Renderer_BenoetigtFuerKonstantenPflichtwertKeineExternenDatenUndUnterstuetztHervorhebung()
{
var manifest = new TemplateManifest
{
Id = "konstant", Name = "Konstant",
Placeholders = [new("Hinweis", PlaceholderType.Multiline, true, true,
"Dieser Text lebt im Paket.", Bold: true, Italic: true, Underline: true)],
};
var layout = new LayoutParser().Parse("PAGE 210 297 mm\nTEXTBOX 20 20 170 50 $Hinweis size=11");
var template = new LoadedTemplate(manifest, layout, new Dictionary<string, byte[]>());
var pdf = new QuestTemplateRenderer().RenderToPdf(template, new EmptyProvider());
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
}
[Fact]
public void Validate_BehandeltKonstantenPflichtwertAlsInternErfuellt()
{
var manifest = new TemplateManifest
{
Placeholders =
[
new("Intern", PlaceholderType.Text, true, true, "Fest"),
new("Extern", PlaceholderType.Text, true),
],
};
var template = new LoadedTemplate(manifest, new(210, 297, "mm", []), new Dictionary<string, byte[]>());
var result = new TemplateLoader().Validate(template, new Dictionary<string, PlaceholderType>());
Assert.Single(result.Issues);
Assert.Contains("Extern", result.Issues[0].Message);
}
[Fact]
public void PaketRoundtrip_BehaeltKonstantenWertUndTextformatierung()
{
var path = Path.Combine(Path.GetTempPath(), $"constant-{Guid.NewGuid():N}.lavorlage");
try
{
var manifest = new TemplateManifest
{
Id = "konstant", Name = "Konstant",
Placeholders = [new("Baustein", PlaceholderType.Text, false, true, "Fest im Paket",
Bold: true, Italic: false, Underline: true)],
};
TemplatePackage.Create(path, manifest, "PAGE 210 297 mm\nTEXT 20 20 $Baustein",
new Dictionary<string, byte[]>());
var definition = Assert.Single(new TemplateLoader().LoadFromPackage(path).Manifest.Placeholders);
Assert.True(definition.IsConstant);
Assert.Equal("Fest im Paket", definition.ConstantValue);
Assert.True(definition.Bold);
Assert.True(definition.Underline);
}
finally { if (File.Exists(path)) File.Delete(path); }
}
[Fact]
public void RichTextParser_UnterstuetztVerschachtelteHervorhebungUndExternePlatzhalter()
{
var runs = TemplateRichText.Parse("Hallo [b]Familie [i]${Student.LastName}[/i][/b]!");
var placeholder = Assert.Single(runs, x => x.IsPlaceholder);
Assert.Equal("Student.LastName", placeholder.Placeholder);
Assert.True(placeholder.Bold);
Assert.True(placeholder.Italic);
Assert.False(placeholder.Underline);
Assert.Throws<InvalidDataException>(() => TemplateRichText.Parse("[b]nicht geschlossen"));
}
[Fact]
public void ExternerWertWirdImKonstantenRichTextNichtAlsMarkupInterpretiert()
{
var manifest = new TemplateManifest
{
Id = "sicher", Name = "Sicher",
Placeholders =
[
new("Baustein", PlaceholderType.Multiline, true, true,
"Sehr geehrte Familie [b]${Student.LastName}[/b],"),
new("Student.LastName", PlaceholderType.Text, true),
],
};
var template = new LoadedTemplate(manifest,
new LayoutParser().Parse("PAGE 210 297 mm\nTEXTBOX 20 20 170 50 $Baustein"),
new Dictionary<string, byte[]>());
var provider = new ValuesProvider(new Dictionary<string, PlaceholderValue>
{ ["Student.LastName"] = new TextValue("[u]nicht als Markup geöffnet") });
var pdf = new QuestTemplateRenderer().RenderToPdf(template, provider);
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
}
[Fact]
public void KonstantenValidierung_MeldetNichtDeklarierteEingebettetePlatzhalter()
{
var manifest = new TemplateManifest
{
Placeholders = [new("Baustein", PlaceholderType.Text, false, true, "Hallo ${Unbekannt}")],
};
var issue = Assert.Single(TemplateDataResolver.ValidateConstants(manifest));
Assert.Contains("Unbekannt", issue);
}
private sealed class EmptyProvider : ITemplateDataProvider
{
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() =>
new Dictionary<string, PlaceholderValue>();
}
private sealed class ValuesProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
{
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values;
}
}
@@ -0,0 +1,59 @@
using System.IO.Compression;
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Templating.Tests;
public sealed class TemplateMetadataTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"template-metadata-{Guid.NewGuid():N}");
public TemplateMetadataTests() => Directory.CreateDirectory(_directory);
[Fact]
public void Paket_SchreibtLesbareMetadatendateiUndLaedtWerteInsManifest()
{
var path = Path.Combine(_directory, "metadata.lavorlage");
var manifest = new TemplateManifest
{
Id = "metadata", Name = "Metadaten",
Metadata = new(StringComparer.OrdinalIgnoreCase)
{
["language"] = "de-DE",
["report-type"] = "parent-letter",
["filter"] = "class=7a",
},
};
TemplatePackage.Create(path, manifest, "PAGE 210 297 mm", new Dictionary<string, byte[]>());
using (var archive = ZipFile.OpenRead(path))
using (var reader = new StreamReader(archive.GetEntry("metadata.txt")!.Open()))
{
var text = reader.ReadToEnd();
Assert.Contains("language=de-DE", text);
Assert.Contains("report-type=parent-letter", text);
}
var loaded = new TemplateLoader().LoadFromPackage(path);
Assert.Equal("de-DE", loaded.Manifest.Metadata["language"]);
Assert.Equal("parent-letter", loaded.Manifest.Metadata["REPORT-TYPE"]);
Assert.Equal("class=7a", loaded.Manifest.Metadata["filter"]);
Assert.DoesNotContain("metadata.txt", loaded.Assets.Keys);
}
[Fact]
public void Textformat_IgnoriertKommentareUndLehntDoppelteSchluesselAb()
{
var parsed = TemplateMetadataText.Parse("# Kommentar\nlanguage = de-DE\nreport-type=letter\n");
Assert.Equal("de-DE", parsed["language"]);
var exception = Assert.Throws<InvalidDataException>(() =>
TemplateMetadataText.Parse("language=de-DE\nLANGUAGE=en-US"));
Assert.Contains("mehrfach", exception.Message);
}
public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, true);
}
}
+182 -2
View File
@@ -19,14 +19,129 @@ public sealed class TemplatingTests : IDisposable
IMG logo.png 15 15 30 12 scale=50% IMG logo.png 15 15 30 12 scale=50%
TEXT 20 45 $Datum|dd.MM.yyyy size=10 TEXT 20 45 $Datum|dd.MM.yyyy size=10
TEXTBOX 20 90 170 120 $Brieftext wrap=true TEXTBOX 20 90 170 120 $Brieftext wrap=true
FLOWBOX 20 20 170 250 $Brieftext size=11
TABLE 20 215 170 40 $Zeilen size=9 TABLE 20 215 170 40 $Zeilen size=9
CHART 20 260 170 25 $Werte type=line CHART 20 260 170 25 $Werte type=line
"""); """);
Assert.Equal(6, layout.Elements.Count); Assert.Equal(7, layout.Elements.Count);
Assert.Equal("50%", Assert.IsType<ImageElement>(layout.Elements[1]).Attributes["scale"]); Assert.Equal("50%", Assert.IsType<ImageElement>(layout.Elements[1]).Attributes["scale"]);
Assert.Equal("dd.MM.yyyy", Assert.IsType<TextElement>(layout.Elements[2]).Format); Assert.Equal("dd.MM.yyyy", Assert.IsType<TextElement>(layout.Elements[2]).Format);
Assert.Equal("line", Assert.IsType<ChartElement>(layout.Elements[5]).ChartType); Assert.IsType<FlowBoxElement>(layout.Elements[4]);
Assert.Equal("line", Assert.IsType<ChartElement>(layout.Elements[6]).ChartType);
}
[Fact]
public void Systemvariablen_BrauchenKeineManifestDeklarationUndRendernMitSeitenzahlen()
{
var manifest = new TemplateManifest { Id = "system", Name = "Systemvariablen" };
var layout = new LayoutParser().Parse("""
PAGE 210 297 mm
TEXT 20 10 "Stand: $$today" size=9
TEXT 140 285 "Seite $$curPage von $$maxPageNum" size=9
FLOWBOX 20 20 170 250 "Langer Inhalt für zwei Seiten.\nLanger Inhalt für zwei Seiten."
""");
var template = new LoadedTemplate(manifest, layout, new Dictionary<string, byte[]>());
var validation = new TemplateLoader().Validate(template, new Dictionary<string, PlaceholderType>());
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
new DictionaryProvider(new Dictionary<string, PlaceholderValue>()));
Assert.True(validation.IsValid);
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
}
[Fact]
public void FlowBox_FliesstAufFolgeseitenUndPaketEnthaeltFolgeseitenLayout()
{
var path = Path.Combine(_directory, "flow.lavorlage");
var manifest = new TemplateManifest
{
Id = "flow", Name = "Fließtext", ContinuationLayoutFile = "continuation.tpl",
Placeholders = [new("Text", PlaceholderType.Multiline, true)],
};
var first = "PAGE 210 297 mm\nTEXT 20 10 \"Erste Seite\" size=14\nFLOWBOX 20 25 170 252 $Text size=11";
var continuation = "PAGE 210 297 mm\nTEXT 20 10 \"Folgeseite\" size=10\nFLOWBOX 20 25 170 252 $Text size=11";
TemplatePackage.Create(path, manifest, first, new Dictionary<string, byte[]>(), continuation);
var loaded = new TemplateLoader().LoadFromPackage(path);
var longText = string.Join('\n', Enumerable.Repeat(
"Ein langer Klassenbucheintrag mit ausreichend Inhalt für den automatischen Seitenumbruch.", 250));
var pdf = new QuestTemplateRenderer().RenderToPdf(loaded,
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Text"] = new MultilineValue(longText) }));
var source = System.Text.Encoding.ASCII.GetString(pdf);
Assert.NotNull(loaded.ContinuationLayout);
Assert.True(System.Text.RegularExpressions.Regex.Matches(source, @"/Type\s*/Page\b").Count > 1);
}
[Fact]
public void DrawBox_RendertDeklarativeExterneZeichenbefehleInFesterBox()
{
var template = new LoadedTemplate(new TemplateManifest
{
Placeholders = [new("Grafik", PlaceholderType.Drawing, true)],
}, new LayoutParser().Parse("PAGE 210 297 mm\nDRAWBOX 20 20 100 60 $Grafik"),
new Dictionary<string, byte[]>());
var drawing = new DrawingValue(
[new DrawRectangle(0, 0, 100, 60, "#1D4ED8", 1, "#EFF6FF"),
new DrawString(5, 5, "Externer Inhalt", 11, Bold: true),
new MoveTo(5, 25), new LineTo(95, 25, "#DC2626", 1.5f),
new DrawLine(5, 35, 95, 50, "#059669", 1)], 60);
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Grafik"] = drawing }));
Assert.Single(System.Text.RegularExpressions.Regex.Matches(
System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b"));
}
[Fact]
public void FlowDrawBox_PaginertHohenDeklarativenZeichenraum()
{
var template = new LoadedTemplate(new TemplateManifest
{
Placeholders = [new("Protokoll", PlaceholderType.Drawing, true)],
}, new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 257 $Protokoll"),
new Dictionary<string, byte[]>());
var drawing = new DrawingValue(
Enumerable.Range(0, 80).SelectMany(i => (DrawingCommand[])
[new DrawString(0, i * 10, $"Zeile {i + 1}", 8), new DrawLine(0, i * 10 + 9, 160, i * 10 + 9, "#CBD5E1", .3f)])
.ToList(), 800);
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Protokoll"] = drawing }));
Assert.True(System.Text.RegularExpressions.Regex.Matches(
System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b").Count >= 4);
}
[Fact]
public void FlowDrawBox_CallbackErhaeltSeitenflaecheStateUndSteuertSeitenwechsel()
{
var template = new LoadedTemplate(new TemplateManifest
{
Placeholders = [new("Bericht", PlaceholderType.Drawing, true)],
}, new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 257 $Bericht"),
new Dictionary<string, byte[]>());
var invocations = 0;
var drawing = new PagedDrawingValue(context =>
{
invocations++;
var item = context.State is int value ? value : 0;
context.Canvas.DrawRectangle(0, 0, context.Width, 20, "#1D4ED8", .5f, "#EFF6FF");
context.Canvas.DrawStringEx(0, 2, 14, context.Width, $"Untrennbarer Block {item + 1}",
DrawingTextAlignment.AlignCenter, 11, "Arial", "#1E3A8A", bold: true);
context.State = item + 1;
return context.PageNumber == 3;
}, InitialState: 0);
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Bericht"] = drawing }));
Assert.Equal(3, invocations);
Assert.Equal(3, System.Text.RegularExpressions.Regex.Matches(
System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b").Count);
} }
[Theory] [Theory]
@@ -56,6 +171,71 @@ public sealed class TemplatingTests : IDisposable
Assert.Equal(3, exception.Result.Issues.Count(issue => issue.Line is not null)); Assert.Equal(3, exception.Result.Issues.Count(issue => issue.Line is not null));
} }
[Fact]
public void LayoutParser_LiestSeitentypenSlotsUndContentFlows()
{
var layout = new LayoutParser().Parse("""
PAGE 210 297 mm
#pragma format-version 3
#pragma page-template first
TEXT 20 20 "Briefkopf"
#pragma flow-slot body x=20 y=80 w=170 h=190
#pragma end-page-template
#pragma page-template continuation
#pragma flow-slot body x=20 y=25 w=170 h=245
#pragma end-page-template
#pragma content-flow body
TEXTBOX $Text size=11 overflow=continue
TEXT "Gruß" gap=8 keep-with-next=true
TEXT $Name
#pragma end-content-flow
""");
Assert.Equal(3, layout.FormatVersion);
Assert.Equal(2, layout.PageTemplates.Count);
Assert.Equal(80, layout.PageTemplates[0].FlowSlots.Single().Y);
Assert.Equal(3, layout.ContentFlows.Single().Elements.Count);
Assert.IsType<TextBoxElement>(layout.ContentFlows.Single().Elements[0]);
}
[Fact]
public void Renderer_FliesstLangenTextAufFolgeseitenWeiter()
{
var manifest = new TemplateManifest
{
Id = "flow", Name = "Flow",
Placeholders = [new("Text", PlaceholderType.Multiline, true), new("Name", PlaceholderType.Text, true)],
};
var layout = new LayoutParser().Parse("""
PAGE 210 297 mm
#pragma format-version 3
#pragma page-template first
TEXT 20 15 "Erste Seite" size=16
#pragma flow-slot body x=20 y=55 w=170 h=215
#pragma end-page-template
#pragma page-template continuation
TEXT 20 12 "Folgeseite" size=9
#pragma flow-slot body x=20 y=25 w=170 h=245
#pragma end-page-template
#pragma content-flow body
TEXTBOX $Text size=11 overflow=continue
TEXT "Mit freundlichen Grüßen" gap=8 keep-with-next=true
TEXT $Name gap=3
#pragma end-content-flow
""");
var loaded = new LoadedTemplate(manifest, layout, new Dictionary<string, byte[]>());
var longText = string.Join('\n', Enumerable.Repeat("Dies ist eine ausreichend lange Textzeile für den Seitenumbruch.", 180));
var pages = new QuestTemplateRenderer().RenderPagesToPng(loaded,
new DictionaryProvider(new Dictionary<string, PlaceholderValue>
{
["Text"] = new MultilineValue(longText), ["Name"] = new TextValue("M. Mustermann"),
}), 40);
Assert.True(pages.Count >= 3);
Assert.All(pages, page => Assert.True(page.Length > 500));
}
[Fact] [Fact]
public void Loader_BlockiertPathTraversal() public void Loader_BlockiertPathTraversal()
{ {
@@ -0,0 +1,29 @@
using System.Security.Cryptography;
using QuestPDF.Drawing;
namespace LehrerApp.Templating;
/// <summary>
/// Registers application-supplied TTF/OTF font bytes for TEXT and drawing commands without
/// exposing QuestPDF types to the calling application.
/// </summary>
public static class DrawingFontRegistry
{
private static readonly object Gate = new();
private static readonly HashSet<string> Registered = new(StringComparer.Ordinal);
public static void RegisterFont(string familyName, byte[] fontData)
{
if (string.IsNullOrWhiteSpace(familyName) || familyName.Length > 80)
throw new ArgumentException("Der Schriftname fehlt oder ist zu lang.", nameof(familyName));
if (fontData.Length is 0 or > 20 * 1024 * 1024)
throw new ArgumentException("Eine Schriftdatei muss zwischen 1 Byte und 20 MB groß sein.", nameof(fontData));
var key = familyName + ":" + Convert.ToHexString(SHA256.HashData(fontData));
lock (Gate)
{
if (!Registered.Add(key)) return;
using var stream = new MemoryStream(fontData, writable: false);
FontManager.RegisterFontWithCustomName(familyName, stream);
}
}
}
+179 -23
View File
@@ -9,6 +9,12 @@ public sealed class LayoutParser
{ {
var issues = new List<ValidationIssue>(); var issues = new List<ValidationIssue>();
var elements = new List<TemplateElement>(); var elements = new List<TemplateElement>();
var pageTemplates = new List<PageTemplateDefinition>();
var contentFlows = new List<ContentFlowDefinition>();
string? currentPageName = null, currentFlowName = null;
List<TemplateElement>? currentPageElements = null, currentFlowElements = null;
List<FlowSlotDefinition>? currentSlots = null;
var formatVersion = 1;
float width = 0, height = 0; float width = 0, height = 0;
var unit = "mm"; var unit = "mm";
var pageSeen = false; var pageSeen = false;
@@ -18,15 +24,75 @@ public sealed class LayoutParser
{ {
var lineNumber = index + 1; var lineNumber = index + 1;
var raw = lines[index].Trim(); var raw = lines[index].Trim();
if (raw.Length == 0 || raw.StartsWith('#')) continue; if (raw.Length == 0) continue;
try try
{ {
if (raw.StartsWith("#pragma", StringComparison.OrdinalIgnoreCase))
{
var pragma = Tokenize(raw);
Require(pragma, 2);
switch (pragma[1].ToLowerInvariant())
{
case "format-version":
Require(pragma, 3);
if (!int.TryParse(pragma[2], out formatVersion) || formatVersion is < 1 or > 3)
throw new FormatException("format-version muss zwischen 1 und 3 liegen.");
break;
case "page-template":
Require(pragma, 3);
if (currentPageName is not null || currentFlowName is not null)
throw new FormatException("Verschachtelte Bereiche sind nicht erlaubt.");
currentPageName = pragma[2]; currentPageElements = []; currentSlots = [];
break;
case "end-page-template":
if (currentPageName is null || currentPageElements is null || currentSlots is null)
throw new FormatException("Kein page-template ist geöffnet.");
if (pageTemplates.Any(x => x.Name.Equals(currentPageName, StringComparison.OrdinalIgnoreCase)))
throw new FormatException($"page-template „{currentPageName}“ ist mehrfach definiert.");
pageTemplates.Add(new(currentPageName, currentPageElements, currentSlots));
currentPageName = null; currentPageElements = null; currentSlots = null;
break;
case "flow-slot":
Require(pragma, 3);
if (currentPageName is null || currentSlots is null)
throw new FormatException("flow-slot muss innerhalb eines page-template stehen.");
var slotAttributes = Attributes(pragma, 3);
foreach (var required in new[] { "x", "y", "w", "h" })
if (!slotAttributes.ContainsKey(required))
throw new FormatException($"flow-slot benötigt {required}=…");
var slot = new FlowSlotDefinition(lineNumber, pragma[2], Number(slotAttributes["x"]),
Number(slotAttributes["y"]), Number(slotAttributes["w"]), Number(slotAttributes["h"]));
if (slot.Width <= 0 || slot.Height <= 0) throw new FormatException("Flow-Slot muss eine positive Größe haben.");
if (currentSlots.Any(x => x.Name.Equals(slot.Name, StringComparison.OrdinalIgnoreCase)))
throw new FormatException($"flow-slot „{slot.Name}“ ist in dieser Seitenvorlage mehrfach definiert.");
currentSlots.Add(slot);
break;
case "content-flow":
Require(pragma, 3);
if (currentPageName is not null || currentFlowName is not null)
throw new FormatException("Verschachtelte Bereiche sind nicht erlaubt.");
currentFlowName = pragma[2]; currentFlowElements = [];
break;
case "end-content-flow":
if (currentFlowName is null || currentFlowElements is null)
throw new FormatException("Kein content-flow ist geöffnet.");
if (contentFlows.Any(x => x.Name.Equals(currentFlowName, StringComparison.OrdinalIgnoreCase)))
throw new FormatException($"content-flow „{currentFlowName}“ ist mehrfach definiert.");
contentFlows.Add(new(currentFlowName, currentFlowElements));
currentFlowName = null; currentFlowElements = null;
break;
default: throw new FormatException($"Unbekanntes Pragma „{pragma[1]}“.");
}
continue;
}
if (raw.StartsWith('#')) continue;
var tokens = Tokenize(raw); var tokens = Tokenize(raw);
if (tokens.Count == 0) continue; if (tokens.Count == 0) continue;
var keyword = tokens[0].ToUpperInvariant(); var keyword = tokens[0].ToUpperInvariant();
if (!pageSeen && keyword != "PAGE") if (!pageSeen && keyword != "PAGE")
throw new FormatException("PAGE muss das erste Statement sein."); throw new FormatException("PAGE muss das erste Statement sein.");
TemplateElement? parsedElement = null;
switch (keyword) switch (keyword)
{ {
case "PAGE": case "PAGE":
@@ -39,40 +105,94 @@ public sealed class LayoutParser
pageSeen = true; pageSeen = true;
break; break;
case "BG": case "BG":
Require(tokens, 2); elements.Add(new BackgroundElement(lineNumber, tokens[1])); break; if (currentFlowName is not null) throw new FormatException("BG ist in einem content-flow nicht erlaubt.");
Require(tokens, 2); parsedElement = new BackgroundElement(lineNumber, tokens[1]); break;
case "IMG": case "IMG":
Require(tokens, 6); var flowImage = currentFlowName is not null;
var imageAttributes = Attributes(tokens, 6); Require(tokens, flowImage ? 2 : 6);
var imageAttributes = Attributes(tokens, flowImage ? 2 : 6);
if (imageAttributes.TryGetValue("scale", out var scale)) Percentage(scale); if (imageAttributes.TryGetValue("scale", out var scale)) Percentage(scale);
elements.Add(new ImageElement(lineNumber, tokens[1], parsedElement = flowImage
Number(tokens[2]), Number(tokens[3]), Number(tokens[4]), Number(tokens[5]), imageAttributes)); ? new ImageElement(lineNumber, tokens[1], 0, 0,
OptionalNumber(imageAttributes, "w"), OptionalNumber(imageAttributes, "h"), imageAttributes)
: new ImageElement(lineNumber, tokens[1], Number(tokens[2]), Number(tokens[3]),
Number(tokens[4]), Number(tokens[5]), imageAttributes);
break; break;
case "TEXT": case "TEXT":
Require(tokens, 4); var flowText = currentFlowName is not null;
var textRef = Reference(tokens[3]); Require(tokens, flowText ? 2 : 4);
elements.Add(new TextElement(lineNumber, Number(tokens[1]), Number(tokens[2]), var textContent = tokens[flowText ? 1 : 3];
tokens[3], textRef.Name, textRef.Format, Attributes(tokens, 4))); break; var textRef = Reference(textContent);
parsedElement = new TextElement(lineNumber, flowText ? 0 : Number(tokens[1]),
flowText ? 0 : Number(tokens[2]), textContent, textRef.Name, textRef.Format,
Attributes(tokens, flowText ? 2 : 4)); break;
case "TEXTBOX": case "TEXTBOX":
Require(tokens, 6); var flowBox = currentFlowName is not null;
var boxRef = Reference(tokens[5]); Require(tokens, flowBox ? 2 : 6);
elements.Add(new TextBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]), var boxContent = tokens[flowBox ? 1 : 5];
Number(tokens[3]), Number(tokens[4]), tokens[5], boxRef.Name, boxRef.Format, var boxRef = Reference(boxContent);
Attributes(tokens, 6))); break; parsedElement = new TextBoxElement(lineNumber, flowBox ? 0 : Number(tokens[1]),
flowBox ? 0 : Number(tokens[2]), flowBox ? 0 : Number(tokens[3]),
flowBox ? 0 : Number(tokens[4]), boxContent, boxRef.Name, boxRef.Format,
Attributes(tokens, flowBox ? 2 : 6)); break;
case "FLOWBOX":
var nestedFlowBox = currentFlowName is not null;
Require(tokens, nestedFlowBox ? 2 : 6);
var flowContent = tokens[nestedFlowBox ? 1 : 5];
var flowRef = Reference(flowContent);
parsedElement = new FlowBoxElement(lineNumber,
nestedFlowBox ? 0 : Number(tokens[1]), nestedFlowBox ? 0 : Number(tokens[2]),
nestedFlowBox ? 0 : Number(tokens[3]), nestedFlowBox ? 0 : Number(tokens[4]),
flowContent, flowRef.Name, flowRef.Format, Attributes(tokens, nestedFlowBox ? 2 : 6));
break;
case "DRAWBOX":
var flowDrawing = currentFlowName is not null;
Require(tokens, flowDrawing ? 2 : 6);
var drawingAttributes = Attributes(tokens, flowDrawing ? 2 : 6);
parsedElement = new DrawBoxElement(lineNumber,
flowDrawing ? 0 : Number(tokens[1]), flowDrawing ? 0 : Number(tokens[2]),
flowDrawing ? RequiredPositiveNumber(drawingAttributes, "w") : Number(tokens[3]),
flowDrawing ? RequiredPositiveNumber(drawingAttributes, "h") : Number(tokens[4]),
RequiredReference(tokens[flowDrawing ? 1 : 5]), drawingAttributes);
break;
case "FLOWDRAWBOX":
var nestedFlowDrawing = currentFlowName is not null;
Require(tokens, nestedFlowDrawing ? 2 : 6);
var flowDrawingAttributes = Attributes(tokens, nestedFlowDrawing ? 2 : 6);
parsedElement = new FlowDrawBoxElement(lineNumber,
nestedFlowDrawing ? 0 : Number(tokens[1]), nestedFlowDrawing ? 0 : Number(tokens[2]),
nestedFlowDrawing ? RequiredPositiveNumber(flowDrawingAttributes, "w") : Number(tokens[3]),
nestedFlowDrawing ? RequiredPositiveNumber(flowDrawingAttributes, "h") : Number(tokens[4]),
RequiredReference(tokens[nestedFlowDrawing ? 1 : 5]), flowDrawingAttributes);
break;
case "TABLE": case "TABLE":
Require(tokens, 6); var flowTable = currentFlowName is not null;
elements.Add(new TableElement(lineNumber, Number(tokens[1]), Number(tokens[2]), Require(tokens, flowTable ? 2 : 6);
Number(tokens[3]), Number(tokens[4]), RequiredReference(tokens[5]), Attributes(tokens, 6))); parsedElement = new TableElement(lineNumber, flowTable ? 0 : Number(tokens[1]),
flowTable ? 0 : Number(tokens[2]), flowTable ? 0 : Number(tokens[3]),
flowTable ? 0 : Number(tokens[4]), RequiredReference(tokens[flowTable ? 1 : 5]),
Attributes(tokens, flowTable ? 2 : 6));
break; break;
case "CHART": case "CHART":
Require(tokens, 6); var flowChart = currentFlowName is not null;
var attributes = Attributes(tokens, 6); Require(tokens, flowChart ? 2 : 6);
var attributes = Attributes(tokens, flowChart ? 2 : 6);
var chartType = attributes.GetValueOrDefault("type", "bar").ToLowerInvariant(); var chartType = attributes.GetValueOrDefault("type", "bar").ToLowerInvariant();
if (chartType is not ("line" or "bar")) throw new FormatException("CHART type muss line oder bar sein."); if (chartType is not ("line" or "bar")) throw new FormatException("CHART type muss line oder bar sein.");
elements.Add(new ChartElement(lineNumber, Number(tokens[1]), Number(tokens[2]), parsedElement = new ChartElement(lineNumber, flowChart ? 0 : Number(tokens[1]),
Number(tokens[3]), Number(tokens[4]), RequiredReference(tokens[5]), chartType, attributes)); flowChart ? 0 : Number(tokens[2]), flowChart ? 0 : Number(tokens[3]),
flowChart ? 0 : Number(tokens[4]), RequiredReference(tokens[flowChart ? 1 : 5]), chartType, attributes);
break; break;
default: throw new FormatException($"Unbekanntes Element „{tokens[0]}“."); default: throw new FormatException($"Unbekanntes Element „{tokens[0]}“.");
} }
if (parsedElement is not null)
{
elements.Add(parsedElement);
if (currentPageElements is not null) currentPageElements.Add(parsedElement);
else if (currentFlowElements is not null) currentFlowElements.Add(parsedElement);
else if (pageTemplates.Count > 0 || contentFlows.Count > 0 || formatVersion >= 3)
throw new FormatException("Elemente müssen in page-template oder content-flow stehen.");
}
} }
catch (FormatException ex) catch (FormatException ex)
{ {
@@ -81,12 +201,37 @@ public sealed class LayoutParser
} }
if (!pageSeen) issues.Add(new(ValidationSeverity.Error, "PAGE fehlt.")); if (!pageSeen) issues.Add(new(ValidationSeverity.Error, "PAGE fehlt."));
if (currentPageName is not null) issues.Add(new(ValidationSeverity.Error, $"page-template „{currentPageName}“ wurde nicht geschlossen."));
if (currentFlowName is not null) issues.Add(new(ValidationSeverity.Error, $"content-flow „{currentFlowName}“ wurde nicht geschlossen."));
if (pageTemplates.Count > 0 || contentFlows.Count > 0)
{
var firstTemplate = pageTemplates.FirstOrDefault(x => x.Name.Equals("first", StringComparison.OrdinalIgnoreCase));
var continuationTemplate = pageTemplates.FirstOrDefault(x => x.Name.Equals("continuation", StringComparison.OrdinalIgnoreCase));
if (firstTemplate is null)
issues.Add(new(ValidationSeverity.Error, "Die Seitenvorlage „first“ fehlt."));
foreach (var flow in contentFlows)
{
if (firstTemplate is not null && !firstTemplate.FlowSlots.Any(slot => slot.Name.Equals(flow.Name, StringComparison.OrdinalIgnoreCase)))
issues.Add(new(ValidationSeverity.Error, $"Für content-flow „{flow.Name}“ fehlt der gleichnamige flow-slot im Seitentyp „first“."));
if (continuationTemplate is not null && !continuationTemplate.FlowSlots.Any(slot => slot.Name.Equals(flow.Name, StringComparison.OrdinalIgnoreCase)))
issues.Add(new(ValidationSeverity.Error, $"Für content-flow „{flow.Name}“ fehlt der gleichnamige flow-slot im Seitentyp „continuation“."));
}
foreach (var slot in pageTemplates.SelectMany(page => page.FlowSlots))
if (slot.X < 0 || slot.Y < 0 || slot.X + slot.Width > width || slot.Y + slot.Height > height)
issues.Add(new(ValidationSeverity.Error, $"Flow-Slot „{slot.Name}“ in Zeile {slot.Line} liegt außerhalb der Seite.", slot.Line));
}
if (issues.Count > 0) throw new TemplateValidationException(new(issues)); if (issues.Count > 0) throw new TemplateValidationException(new(issues));
return new(width, height, unit, elements); return new(width, height, unit, elements)
{
FormatVersion = formatVersion,
PageTemplates = pageTemplates,
ContentFlows = contentFlows,
};
} }
private static (string? Name, string? Format) Reference(string value) private static (string? Name, string? Format) Reference(string value)
{ {
if (SystemVariables.IsStandalone(value)) return (null, null);
if (!value.StartsWith('$')) return (null, null); if (!value.StartsWith('$')) return (null, null);
var parts = value[1..].Split('|', 2); var parts = value[1..].Split('|', 2);
if (string.IsNullOrWhiteSpace(parts[0])) throw new FormatException("Platzhaltername fehlt."); if (string.IsNullOrWhiteSpace(parts[0])) throw new FormatException("Platzhaltername fehlt.");
@@ -99,6 +244,17 @@ public sealed class LayoutParser
private static float Number(string value) => float.TryParse(value, NumberStyles.Float, private static float Number(string value) => float.TryParse(value, NumberStyles.Float,
CultureInfo.InvariantCulture, out var result) ? result : throw new FormatException($"„{value}“ ist keine Zahl."); CultureInfo.InvariantCulture, out var result) ? result : throw new FormatException($"„{value}“ ist keine Zahl.");
private static float OptionalNumber(IReadOnlyDictionary<string, string> attributes, string name) =>
attributes.TryGetValue(name, out var value) ? Number(value) : 0;
private static float RequiredPositiveNumber(IReadOnlyDictionary<string, string> attributes, string name)
{
if (!attributes.TryGetValue(name, out var raw))
throw new FormatException($"Das Element benötigt {name}=…");
var value = Number(raw);
return value > 0 ? value : throw new FormatException($"{name} muss positiv sein.");
}
public static float Percentage(string value) public static float Percentage(string value)
{ {
var normalized = value.EndsWith('%') ? value[..^1] : value; var normalized = value.EndsWith('%') ? value[..^1] : value;
+94 -4
View File
@@ -1,6 +1,8 @@
using System.Text.Json.Serialization;
namespace LehrerApp.Templating; namespace LehrerApp.Templating;
public enum PlaceholderType { Text, Multiline, Date, Number, Image, Table, Chart } public enum PlaceholderType { Text, Multiline, Date, Number, Image, Table, Chart, Drawing }
public abstract record PlaceholderValue(PlaceholderType Type); public abstract record PlaceholderValue(PlaceholderType Type);
public sealed record TextValue(string Value) : PlaceholderValue(PlaceholderType.Text); public sealed record TextValue(string Value) : PlaceholderValue(PlaceholderType.Text);
@@ -14,6 +16,67 @@ public sealed record TableValue(IReadOnlyList<string> Columns, IReadOnlyList<IRe
public sealed record ChartPoint(string X, decimal Y); public sealed record ChartPoint(string X, decimal Y);
public sealed record ChartSeries(string Label, IReadOnlyList<ChartPoint> Points); public sealed record ChartSeries(string Label, IReadOnlyList<ChartPoint> Points);
public sealed record ChartValue(IReadOnlyList<ChartSeries> Series) : PlaceholderValue(PlaceholderType.Chart); public sealed record ChartValue(IReadOnlyList<ChartSeries> Series) : PlaceholderValue(PlaceholderType.Chart);
public abstract record DrawingCommand;
public sealed record DrawString(float X, float Y, string Text, float FontSize = 11,
string Color = "#000000", bool Bold = false, bool Italic = false, string FontFamily = "") : DrawingCommand;
public enum DrawingTextAlignment { AlignLeft, AlignCenter, AlignRight }
public sealed record DrawStringEx(float X, float Y, float Height, float Width, string Text,
DrawingTextAlignment Alignment = DrawingTextAlignment.AlignLeft, float FontSize = 11,
string FontFamily = "", string Color = "#000000", bool Bold = false, bool Italic = false)
: DrawingCommand;
public sealed record MoveTo(float X, float Y) : DrawingCommand;
public sealed record LineTo(float X, float Y, string Color = "#000000", float StrokeWidth = 1) : DrawingCommand;
public sealed record DrawLine(float X1, float Y1, float X2, float Y2,
string Color = "#000000", float StrokeWidth = 1) : DrawingCommand;
public sealed record DrawRectangle(float X, float Y, float Width, float Height,
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;
/// <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)
: PlaceholderValue(PlaceholderType.Drawing);
/// <summary>A recorded drawing surface handed to an in-process external renderer.</summary>
public sealed class DrawingCanvas
{
private readonly List<DrawingCommand> _commands = [];
public IReadOnlyList<DrawingCommand> Commands => _commands;
public void DrawString(float x, float y, string text, float fontSize = 11, string color = "#000000",
bool bold = false, bool italic = false, string fontFamily = "") =>
_commands.Add(new LehrerApp.Templating.DrawString(x, y, text, fontSize, color, bold, italic, fontFamily));
public void DrawStringEx(float x, float y, float height, float width, string text,
DrawingTextAlignment alignment = DrawingTextAlignment.AlignLeft, float fontSize = 11,
string fontFamily = "", string color = "#000000", bool bold = false, bool italic = false) =>
_commands.Add(new LehrerApp.Templating.DrawStringEx(x, y, height, width, text, alignment,
fontSize, fontFamily, color, bold, italic));
public void MoveTo(float x, float y) => _commands.Add(new LehrerApp.Templating.MoveTo(x, y));
public void LineTo(float x, float y, string color = "#000000", float strokeWidth = 1) =>
_commands.Add(new LehrerApp.Templating.LineTo(x, y, color, strokeWidth));
public void DrawLine(float x1, float y1, float x2, float y2, string color = "#000000", float strokeWidth = 1) =>
_commands.Add(new LehrerApp.Templating.DrawLine(x1, y1, x2, y2, color, strokeWidth));
public void DrawRectangle(float x, float y, float width, float height, string strokeColor = "#000000",
float strokeWidth = 1, string fillColor = "none") =>
_commands.Add(new LehrerApp.Templating.DrawRectangle(x, y, width, height, strokeColor, strokeWidth, fillColor));
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));
}
public sealed class DrawingPageContext(float width, float height, int pageNumber, DrawingCanvas canvas, object? state)
{
public float Width { get; } = width;
public float Height { get; } = height;
public int PageNumber { get; } = pageNumber;
public DrawingCanvas Canvas { get; } = canvas;
public object? State { get; set; } = state;
}
public delegate bool DrawingPageCallback(DrawingPageContext context);
/// <summary>
/// In-process callback drawing. The callback returns true when finished or false to request another box.
/// State is carried across callbacks. It is recorded before QuestPDF performs layout and is never executed by a package.
/// </summary>
public sealed record PagedDrawingValue(DrawingPageCallback DrawPage, object? InitialState = null,
int MaxPages = 1_000) : PlaceholderValue(PlaceholderType.Drawing);
public interface ITemplateDataProvider public interface ITemplateDataProvider
{ {
@@ -21,7 +84,9 @@ public interface ITemplateDataProvider
} }
public sealed record PageSizeDefinition(float Width, float Height, string Unit = "mm"); public sealed record PageSizeDefinition(float Width, float Height, string Unit = "mm");
public sealed record PlaceholderDefinition(string Name, PlaceholderType Type, bool Required = false); public sealed record PlaceholderDefinition(string Name, PlaceholderType Type, bool Required = false,
bool IsConstant = false, string? ConstantValue = null,
bool Bold = false, bool Italic = false, bool Underline = false);
public sealed class TemplateManifest public sealed class TemplateManifest
{ {
@@ -31,6 +96,10 @@ public sealed class TemplateManifest
public string Description { get; set; } = ""; public string Description { get; set; } = "";
public PageSizeDefinition PageSize { get; set; } = new(210, 297); public PageSizeDefinition PageSize { get; set; } = new(210, 297);
public string LayoutFile { get; set; } = "layout.tpl"; public string LayoutFile { get; set; } = "layout.tpl";
public string? ContinuationLayoutFile { get; set; }
public string MetadataFile { get; set; } = TemplateMetadataText.FileName;
[JsonIgnore]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public List<PlaceholderDefinition> Placeholders { get; set; } = []; public List<PlaceholderDefinition> Placeholders { get; set; } = [];
} }
@@ -47,6 +116,15 @@ public sealed record TextElement(int Line, float X, float Y, string Content, str
public sealed record TextBoxElement(int Line, float X, float Y, float Width, float Height, public sealed record TextBoxElement(int Line, float X, float Y, float Width, float Height,
string Content, string? Placeholder, string? Format, IReadOnlyDictionary<string, string> Attributes) string Content, string? Placeholder, string? Format, IReadOnlyDictionary<string, string> Attributes)
: TemplateElement(Line, X, Y, Width, Height, Attributes); : TemplateElement(Line, X, Y, Width, Height, Attributes);
public sealed record FlowBoxElement(int Line, float X, float Y, float Width, float Height,
string Content, string? Placeholder, string? Format, IReadOnlyDictionary<string, string> Attributes)
: TemplateElement(Line, X, Y, Width, Height, Attributes);
public sealed record DrawBoxElement(int Line, float X, float Y, float Width, float Height,
string Placeholder, IReadOnlyDictionary<string, string> Attributes)
: TemplateElement(Line, X, Y, Width, Height, Attributes);
public sealed record FlowDrawBoxElement(int Line, float X, float Y, float Width, float Height,
string Placeholder, IReadOnlyDictionary<string, string> Attributes)
: TemplateElement(Line, X, Y, Width, Height, Attributes);
public sealed record TableElement(int Line, float X, float Y, float Width, float Height, public sealed record TableElement(int Line, float X, float Y, float Width, float Height,
string Placeholder, IReadOnlyDictionary<string, string> Attributes) string Placeholder, IReadOnlyDictionary<string, string> Attributes)
: TemplateElement(Line, X, Y, Width, Height, Attributes); : TemplateElement(Line, X, Y, Width, Height, Attributes);
@@ -54,6 +132,11 @@ public sealed record ChartElement(int Line, float X, float Y, float Width, float
string Placeholder, string ChartType, IReadOnlyDictionary<string, string> Attributes) string Placeholder, string ChartType, IReadOnlyDictionary<string, string> Attributes)
: TemplateElement(Line, X, Y, Width, Height, Attributes); : TemplateElement(Line, X, Y, Width, Height, Attributes);
public sealed record FlowSlotDefinition(int Line, string Name, float X, float Y, float Width, float Height);
public sealed record PageTemplateDefinition(string Name, IReadOnlyList<TemplateElement> Elements,
IReadOnlyList<FlowSlotDefinition> FlowSlots);
public sealed record ContentFlowDefinition(string Name, IReadOnlyList<TemplateElement> Elements);
internal static class EmptyAttributes internal static class EmptyAttributes
{ {
public static readonly IReadOnlyDictionary<string, string> Value = public static readonly IReadOnlyDictionary<string, string> Value =
@@ -61,10 +144,17 @@ internal static class EmptyAttributes
} }
public sealed record TemplateLayout(float Width, float Height, string Unit, public sealed record TemplateLayout(float Width, float Height, string Unit,
IReadOnlyList<TemplateElement> Elements); IReadOnlyList<TemplateElement> Elements)
{
public int FormatVersion { get; init; } = 1;
public IReadOnlyList<PageTemplateDefinition> PageTemplates { get; init; } = [];
public IReadOnlyList<ContentFlowDefinition> ContentFlows { get; init; } = [];
public bool UsesPageTemplates => PageTemplates.Count > 0 || ContentFlows.Count > 0;
}
public sealed record LoadedTemplate(TemplateManifest Manifest, TemplateLayout Layout, public sealed record LoadedTemplate(TemplateManifest Manifest, TemplateLayout Layout,
IReadOnlyDictionary<string, byte[]> Assets, string SourceName = ""); IReadOnlyDictionary<string, byte[]> Assets, string SourceName = "",
TemplateLayout? ContinuationLayout = null);
public enum ValidationSeverity { Warning, Error } public enum ValidationSeverity { Warning, Error }
public sealed record ValidationIssue(ValidationSeverity Severity, string Message, int? Line = null); public sealed record ValidationIssue(ValidationSeverity Severity, string Message, int? Line = null);
+536 -13
View File
@@ -15,40 +15,275 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
BuildDocument(template, ValidateData(template, data)).GeneratePdf(); BuildDocument(template, ValidateData(template, data)).GeneratePdf();
public byte[] RenderFirstPageToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120) public byte[] RenderFirstPageToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
{
return RenderPagesToPng(template, data, dpi).First();
}
public IReadOnlyList<byte[]> RenderPagesToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
{ {
var settings = new ImageGenerationSettings { ImageFormat = ImageFormat.Png, RasterDpi = dpi }; var settings = new ImageGenerationSettings { ImageFormat = ImageFormat.Png, RasterDpi = dpi };
return BuildDocument(template, ValidateData(template, data)).GenerateImages(settings).First(); return BuildDocument(template, ValidateData(template, data)).GenerateImages(settings).ToList();
} }
private static IReadOnlyDictionary<string, PlaceholderValue> ValidateData(LoadedTemplate template, private static IReadOnlyDictionary<string, PlaceholderValue> ValidateData(LoadedTemplate template,
ITemplateDataProvider provider) ITemplateDataProvider provider)
{ {
var values = provider.GetValues(); var values = TemplateDataResolver.Resolve(template.Manifest, provider.GetValues());
var validation = new TemplateLoader().Validate(template, values.ToDictionary(x => x.Key, x => x.Value.Type)); var validation = new TemplateLoader().Validate(template, values.ToDictionary(x => x.Key, x => x.Value.Type));
if (!validation.IsValid) throw new TemplateValidationException(validation); if (!validation.IsValid) throw new TemplateValidationException(validation);
return values; return values;
} }
private static IDocument BuildDocument(LoadedTemplate template, private static IDocument BuildDocument(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values) => Document.Create(document => IReadOnlyDictionary<string, PlaceholderValue> values)
{ {
document.Page(page => values = PreparePagedDrawings(template, values);
return template.Layout.UsesPageTemplates
? BuildFlowDocument(template, values)
: BuildLegacyDocument(template, values);
}
private static IDocument BuildLegacyDocument(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
var flowBox = template.Layout.Elements.SingleOrDefault(x => x is FlowBoxElement or FlowDrawBoxElement);
return Document.Create(document => document.Page(page =>
{ {
page.Size(UnitConverter.Points(template.Layout.Width, template.Layout.Unit), page.Size(UnitConverter.Points(template.Layout.Width, template.Layout.Unit),
UnitConverter.Points(template.Layout.Height, template.Layout.Unit)); UnitConverter.Points(template.Layout.Height, template.Layout.Unit));
page.Margin(0); page.Margin(0);
page.Content().Layers(layers => page.Content().Layers(layers =>
{ {
layers.PrimaryLayer().Width(UnitConverter.Points(template.Layout.Width, template.Layout.Unit)) if (flowBox is null)
.Height(UnitConverter.Points(template.Layout.Height, template.Layout.Unit)).Background(Colors.White); layers.PrimaryLayer().Width(UnitConverter.Points(template.Layout.Width, template.Layout.Unit))
foreach (var element in template.Layout.Elements) .Height(UnitConverter.Points(template.Layout.Height, template.Layout.Unit)).Background(Colors.White);
else
RenderLegacyFlowElement(layers.PrimaryLayer(), flowBox, template, values);
RenderStaticLayers(layers, template.Layout, template, values,
template.ContinuationLayout is null ? null : static container => container.ShowOnce());
if (template.ContinuationLayout is { } continuation)
RenderStaticLayers(layers, continuation, template, values, static container => container.SkipOnce());
});
}));
}
private static IReadOnlyDictionary<string, PlaceholderValue> PreparePagedDrawings(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
var result = new Dictionary<string, PlaceholderValue>(values, StringComparer.Ordinal);
var layouts = template.ContinuationLayout is null
? new[] { template.Layout }
: new[] { template.Layout, template.ContinuationLayout };
var drawingElements = layouts.SelectMany(x => x.Elements)
.Where(x => x is DrawBoxElement or FlowDrawBoxElement).ToList();
foreach (var group in drawingElements.GroupBy(x => x switch
{ DrawBoxElement box => box.Placeholder, FlowDrawBoxElement box => box.Placeholder, _ => "" }))
{
if (values.GetValueOrDefault(group.Key) is not PagedDrawingValue) continue;
var signatures = group.Select(x => (x.GetType(), x.Width, x.Height)).Distinct().Count();
if (signatures > 1)
throw new InvalidDataException($"Der Callback-Platzhalter „{group.Key}“ wird in unterschiedlich großen oder unterschiedlichen Zeichenboxen verwendet.");
}
foreach (var element in drawingElements)
{
var placeholder = element switch
{ DrawBoxElement box => box.Placeholder, FlowDrawBoxElement box => box.Placeholder, _ => null };
if (placeholder is null || values.GetValueOrDefault(placeholder) is not PagedDrawingValue callback) continue;
if (result.GetValueOrDefault(placeholder) is RecordedDrawingValue) continue;
var pages = element is FlowDrawBoxElement
? DrawingElementRenderer.RecordPages(callback, element.Width, element.Height, callback.MaxPages)
: [DrawingElementRenderer.RecordSingle(callback, element.Width, element.Height)];
result[placeholder] = new RecordedDrawingValue(pages);
}
return result;
}
private static void RenderStaticLayers(LayersDescriptor layers, TemplateLayout layout, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values, Func<IContainer, IContainer>? visibility)
{
foreach (var element in layout.Elements.Where(x => x is not (FlowBoxElement or FlowDrawBoxElement)))
{
var current = element;
layers.Layer().Element(container => RenderElement(
visibility is null ? container : visibility(container), current, template, values));
}
}
private static void RenderLegacyFlowElement(IContainer container, TemplateElement element, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
var unit = template.Layout.Unit;
container
.PaddingLeft(UnitConverter.Points(element.X, unit))
.PaddingTop(UnitConverter.Points(element.Y, unit))
.PaddingRight(UnitConverter.Points(Math.Max(0, template.Layout.Width - element.X - element.Width), unit))
.PaddingBottom(UnitConverter.Points(Math.Max(0, template.Layout.Height - element.Y - element.Height), unit))
.Element(content =>
{
if (element is FlowBoxElement box)
RenderResolvedText(content, box.Content, box.Placeholder, box.Format,
values, template.Manifest, box.Attributes);
else if (element is FlowDrawBoxElement drawing
&& values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue)
DrawingElementRenderer.RenderFlow(content, drawingValue, drawing.Width, drawing.Height, unit);
});
}
private static IDocument BuildFlowDocument(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
var firstPage = template.Layout.PageTemplates.First(x =>
x.Name.Equals("first", StringComparison.OrdinalIgnoreCase));
var continuationPage = template.Layout.PageTemplates.FirstOrDefault(x =>
x.Name.Equals("continuation", StringComparison.OrdinalIgnoreCase)) ?? firstPage;
var populatedFlows = template.Layout.ContentFlows.Where(x => x.Elements.Count > 0).ToList();
if (populatedFlows.Count > 1)
throw new InvalidDataException("Aktuell darf genau ein Content-Flow Inhalt enthalten. Weitere Flow-Slots können bereits gestaltet werden, parallele paginierende Flows folgen in einer späteren Formatstufe.");
var primaryFlow = populatedFlows.FirstOrDefault() ?? template.Layout.ContentFlows.FirstOrDefault();
return Document.Create(document =>
{
document.Page(page =>
{
var pageWidth = UnitConverter.Points(template.Layout.Width, template.Layout.Unit);
var pageHeight = UnitConverter.Points(template.Layout.Height, template.Layout.Unit);
page.Size(pageWidth, pageHeight);
page.Margin(0);
ConfigurePageCanvas(page.Background(), firstPage, continuationPage, template, values);
if (primaryFlow is null)
{ {
var current = element; page.Content().Height(1);
layers.Layer().Element(container => RenderElement(container, current, template, values)); return;
} }
var firstSlot = FindSlot(firstPage, primaryFlow.Name);
var continuationSlot = FindSlot(continuationPage, primaryFlow.Name) ?? firstSlot;
if (firstSlot is null)
throw new InvalidDataException($"Für content-flow „{primaryFlow.Name}“ fehlt ein flow-slot auf der ersten Seite.");
if (Math.Abs(firstSlot.X - continuationSlot!.X) > 0.01f ||
Math.Abs(firstSlot.Width - continuationSlot.Width) > 0.01f)
throw new InvalidDataException($"Der primäre Flow „{primaryFlow.Name}“ muss vorerst auf erster und Folgeseite dieselbe X-Position und Breite besitzen.");
var left = UnitConverter.Points(firstSlot.X, template.Layout.Unit);
var right = UnitConverter.Points(template.Layout.Width - firstSlot.X - firstSlot.Width, template.Layout.Unit);
page.MarginLeft(left);
page.MarginRight(right);
ConfigureVariableVerticalSlot(page, firstSlot, continuationSlot, template.Layout);
page.Content().Column(column => RenderFlowColumn(column, primaryFlow.Elements, template, values));
}); });
}); });
}); }
private static void ConfigureVariableVerticalSlot(PageDescriptor page, FlowSlotDefinition first,
FlowSlotDefinition continuation, TemplateLayout layout)
{
var firstTop = UnitConverter.Points(first.Y, layout.Unit);
var continuationTop = UnitConverter.Points(continuation.Y, layout.Unit);
var firstBottom = UnitConverter.Points(layout.Height - first.Y - first.Height, layout.Unit);
var continuationBottom = UnitConverter.Points(layout.Height - continuation.Y - continuation.Height, layout.Unit);
page.Header().Column(column =>
{
column.Item().ShowOnce().Height(firstTop);
column.Item().SkipOnce().Height(continuationTop);
});
page.Footer().Column(column =>
{
column.Item().ShowOnce().Height(firstBottom);
column.Item().SkipOnce().Height(continuationBottom);
});
}
private static FlowSlotDefinition? FindSlot(PageTemplateDefinition page, string name) =>
page.FlowSlots.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
private static void ConfigurePageCanvas(IContainer canvas, PageTemplateDefinition first,
PageTemplateDefinition continuation, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
canvas.Layers(layers =>
{
layers.PrimaryLayer().Background(Colors.White);
foreach (var element in first.Elements)
{
var current = element;
layers.Layer().ShowOnce().Element(root => RenderElement(root, current, template, values));
}
foreach (var element in continuation.Elements)
{
var current = element;
layers.Layer().SkipOnce().Element(root => RenderElement(root, current, template, values));
}
});
}
private static void RenderFlowColumn(ColumnDescriptor column, IReadOnlyList<TemplateElement> elements,
LoadedTemplate template, IReadOnlyDictionary<string, PlaceholderValue> values)
{
for (var index = 0; index < elements.Count; index++)
{
var element = elements[index];
var gap = UnitConverter.Points(ParseFloat(element.Attributes, "gap", 0), template.Layout.Unit);
var keepWithNext = ParseBool(element.Attributes, "keep-with-next") && index + 1 < elements.Count;
if (keepWithNext)
{
var next = elements[++index];
column.Item().PaddingTop(gap).PreventPageBreak().Column(group =>
{
group.Item().Element(container => RenderFlowElement(container, element, template, values));
var nextGap = UnitConverter.Points(ParseFloat(next.Attributes, "gap", 0), template.Layout.Unit);
group.Item().PaddingTop(nextGap).Element(container => RenderFlowElement(container, next, template, values));
});
}
else
column.Item().PaddingTop(gap).Element(container => RenderFlowElement(container, element, template, values));
}
}
private static void RenderFlowElement(IContainer container, TemplateElement element, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
switch (element)
{
case TextElement text:
RenderResolvedText(container, text.Content, text.Placeholder, text.Format,
values, template.Manifest, text.Attributes);
break;
case TextBoxElement box:
RenderResolvedText(container, box.Content, box.Placeholder, box.Format,
values, template.Manifest, box.Attributes);
break;
case FlowBoxElement box:
RenderResolvedText(container, box.Content, box.Placeholder, box.Format,
values, template.Manifest, box.Attributes);
break;
case DrawBoxElement drawing when values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue:
DrawingElementRenderer.RenderFixed(
container.Width(UnitConverter.Points(drawing.Width, template.Layout.Unit))
.Height(UnitConverter.Points(drawing.Height, template.Layout.Unit)),
drawingValue, drawing.Width, drawing.Height, template.Layout.Unit);
break;
case FlowDrawBoxElement drawing when values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue:
DrawingElementRenderer.RenderFlow(container, drawingValue,
drawing.Width, drawing.Height, template.Layout.Unit);
break;
case ImageElement image:
var imageContainer = container;
if (image.Width > 0) imageContainer = imageContainer.Width(UnitConverter.Points(image.Width, template.Layout.Unit));
if (image.Height > 0) imageContainer = imageContainer.Height(UnitConverter.Points(image.Height, template.Layout.Unit));
imageContainer.Image(GetAsset(template, image.Path)).FitArea();
break;
case TableElement table when values.GetValueOrDefault(table.Placeholder) is TableValue tableValue:
TableElementRenderer.Render(container, tableValue, table.Attributes);
break;
case ChartElement chart when values.GetValueOrDefault(chart.Placeholder) is ChartValue chartValue:
var chartHeight = UnitConverter.Points(ParseFloat(chart.Attributes, "h", 50), template.Layout.Unit);
ChartElementRenderer.Render(container.Height(chartHeight), chartValue, chart.ChartType, chart.Attributes);
break;
}
}
private static void RenderElement(IContainer root, TemplateElement element, LoadedTemplate template, private static void RenderElement(IContainer root, TemplateElement element, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values) IReadOnlyDictionary<string, PlaceholderValue> values)
@@ -71,11 +306,26 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
.TranslateY(UnitConverter.Points(text.Y, unit)) .TranslateY(UnitConverter.Points(text.Y, unit))
.Width(UnitConverter.Points(Math.Max(0, template.Layout.Width - text.X), unit)) .Width(UnitConverter.Points(Math.Max(0, template.Layout.Width - text.X), unit))
.Height(QuestTemplateRenderer.ParseFloat(text.Attributes, "size", 11) * 1.8f); .Height(QuestTemplateRenderer.ParseFloat(text.Attributes, "size", 11) * 1.8f);
RenderText(textContainer, ResolveContent(text.Content, text.Placeholder, text.Format, values), text.Attributes); RenderResolvedText(textContainer, text.Content, text.Placeholder, text.Format,
values, template.Manifest, text.Attributes);
break; break;
case TextBoxElement box: case TextBoxElement box:
RenderText(Position(root, box, unit).Shrink(), RenderResolvedText(Position(root, box, unit).Shrink(), box.Content, box.Placeholder, box.Format,
ResolveContent(box.Content, box.Placeholder, box.Format, values), box.Attributes); values, template.Manifest, box.Attributes);
break;
case FlowBoxElement box:
RenderResolvedText(Position(root, box, unit).Shrink(), box.Content, box.Placeholder, box.Format,
values, template.Manifest, box.Attributes);
break;
case DrawBoxElement drawing:
if (values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue)
DrawingElementRenderer.RenderFixed(Position(root, drawing, unit), drawingValue,
drawing.Width, drawing.Height, unit);
break;
case FlowDrawBoxElement drawing:
if (values.GetValueOrDefault(drawing.Placeholder) is { } flowDrawingValue)
DrawingElementRenderer.RenderFixed(Position(root, drawing, unit), flowDrawingValue,
drawing.Width, drawing.Height, unit);
break; break;
case TableElement table: case TableElement table:
if (values.GetValueOrDefault(table.Placeholder) is TableValue tableValue) if (values.GetValueOrDefault(table.Placeholder) is TableValue tableValue)
@@ -100,13 +350,103 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
{ {
var aligned = attributes.GetValueOrDefault("align", "left").ToLowerInvariant() switch var aligned = attributes.GetValueOrDefault("align", "left").ToLowerInvariant() switch
{ "center" => container.AlignCenter(), "right" => container.AlignRight(), _ => container.AlignLeft() }; { "center" => container.AlignCenter(), "right" => container.AlignRight(), _ => container.AlignLeft() };
if (SystemVariables.Contains(content))
{
aligned.Text(text =>
{
text.DefaultTextStyle(style => ApplyTextStyle(style, attributes));
AppendSystemVariables(text, content);
});
return;
}
var descriptor = aligned.Text(content); var descriptor = aligned.Text(content);
descriptor.FontSize(ParseFloat(attributes, "size", 11)); descriptor.FontSize(ParseFloat(attributes, "size", 11));
if (ParseBool(attributes, "bold")) descriptor.SemiBold(); if (ParseBool(attributes, "bold")) descriptor.SemiBold();
if (ParseBool(attributes, "italic")) descriptor.Italic(); if (ParseBool(attributes, "italic")) descriptor.Italic();
if (ParseBool(attributes, "underline")) descriptor.Underline();
if (attributes.TryGetValue("color", out var color)) descriptor.FontColor(color); if (attributes.TryGetValue("color", out var color)) descriptor.FontColor(color);
} }
private static void RenderResolvedText(IContainer container, string content, string? placeholder, string? format,
IReadOnlyDictionary<string, PlaceholderValue> values, TemplateManifest manifest,
IReadOnlyDictionary<string, string> elementAttributes)
{
var attributes = TextAttributes(manifest, placeholder, elementAttributes);
var definition = placeholder is null ? null
: manifest.Placeholders.FirstOrDefault(x => x.Name.Equals(placeholder, StringComparison.Ordinal));
if (definition is { IsConstant: true, Type: PlaceholderType.Text or PlaceholderType.Multiline })
{
RenderRichText(container, TemplateRichText.Parse(definition.ConstantValue ?? ""), values, attributes);
return;
}
RenderText(container, ResolveContent(content, placeholder, format, values), attributes);
}
private static void RenderRichText(IContainer container, IReadOnlyList<TemplateRichTextRun> runs,
IReadOnlyDictionary<string, PlaceholderValue> values, IReadOnlyDictionary<string, string> attributes)
{
var aligned = attributes.GetValueOrDefault("align", "left").ToLowerInvariant() switch
{ "center" => container.AlignCenter(), "right" => container.AlignRight(), _ => container.AlignLeft() };
var fontSize = ParseFloat(attributes, "size", 11);
var globalBold = ParseBool(attributes, "bold");
var globalItalic = ParseBool(attributes, "italic");
var globalUnderline = ParseBool(attributes, "underline");
attributes.TryGetValue("color", out var color);
aligned.Text(text =>
{
foreach (var run in runs)
{
var content = run.Placeholder is null ? run.Text
: values.TryGetValue(run.Placeholder, out var value) ? Format(value, run.Format) : "";
foreach (var span in AppendSystemVariables(text, content))
{
span.FontSize(fontSize);
if (globalBold || run.Bold) span.SemiBold();
if (globalItalic || run.Italic) span.Italic();
if (globalUnderline || run.Underline) span.Underline();
if (color is not null) span.FontColor(color);
}
}
});
}
private static TextStyle ApplyTextStyle(TextStyle style, IReadOnlyDictionary<string, string> attributes)
{
style = style.FontSize(ParseFloat(attributes, "size", 11));
if (ParseBool(attributes, "bold")) style = style.SemiBold();
if (ParseBool(attributes, "italic")) style = style.Italic();
if (ParseBool(attributes, "underline")) style = style.Underline();
if (attributes.TryGetValue("color", out var color)) style = style.FontColor(color);
return style;
}
private static IEnumerable<TextSpanDescriptor> AppendSystemVariables(TextDescriptor text, string content)
{
foreach (var part in SystemVariables.Split(content))
{
yield return part.Name switch
{
SystemVariables.CurrentPage => text.CurrentPageNumber(),
SystemVariables.MaximumPageNumber => text.TotalPages(),
SystemVariables.Today => text.Span(DateTime.Today.ToString("d", CultureInfo.GetCultureInfo("de-DE"))),
_ => text.Span(part.Text),
};
}
}
private static IReadOnlyDictionary<string, string> TextAttributes(TemplateManifest manifest, string? placeholder,
IReadOnlyDictionary<string, string> elementAttributes)
{
if (placeholder is null) return elementAttributes;
var definition = manifest.Placeholders.FirstOrDefault(x => x.Name.Equals(placeholder, StringComparison.Ordinal));
if (definition is null || (!definition.Bold && !definition.Italic && !definition.Underline)) return elementAttributes;
var result = new Dictionary<string, string>(elementAttributes, StringComparer.OrdinalIgnoreCase);
if (definition.Bold) result["bold"] = "true";
if (definition.Italic) result["italic"] = "true";
if (definition.Underline) result["underline"] = "true";
return result;
}
private static string ResolveContent(string content, string? placeholder, string? format, private static string ResolveContent(string content, string? placeholder, string? format,
IReadOnlyDictionary<string, PlaceholderValue> values) IReadOnlyDictionary<string, PlaceholderValue> values)
{ {
@@ -134,6 +474,9 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
attributes.TryGetValue(key, out var raw) && bool.TryParse(raw, out var value) && value; attributes.TryGetValue(key, out var raw) && bool.TryParse(raw, out var value) && value;
} }
internal sealed record RecordedDrawingValue(IReadOnlyList<IReadOnlyList<DrawingCommand>> Pages)
: PlaceholderValue(PlaceholderType.Drawing);
public static class UnitConverter public static class UnitConverter
{ {
public static float Points(float value, string unit) => unit.ToLowerInvariant() switch public static float Points(float value, string unit) => unit.ToLowerInvariant() switch
@@ -164,6 +507,186 @@ internal static class TableElementRenderer
} }
} }
internal static class DrawingElementRenderer
{
public static void RenderFixed(IContainer container, PlaceholderValue value, float width, float height, string unit)
{
var drawing = value switch
{
DrawingValue direct => direct,
RecordedDrawingValue recorded => new DrawingValue(recorded.Pages.FirstOrDefault() ?? [], height),
_ => throw new InvalidDataException("DRAWBOX erwartet einen Drawing-Platzhalter."),
};
container.Svg(BuildSvg(drawing, width, height, 0, unit));
}
public static void RenderFlow(IContainer container, PlaceholderValue value, float width, float pageHeight, string unit)
{
var pages = value switch
{
DrawingValue direct => Slice(direct, pageHeight),
RecordedDrawingValue recorded => recorded.Pages
.Select(commands => new DrawingValue(commands, pageHeight)).ToList(),
_ => throw new InvalidDataException("FLOWDRAWBOX erwartet einen Drawing-Platzhalter."),
};
container.Column(column =>
{
for (var page = 0; page < pages.Count; page++)
{
if (page > 0) column.Item().PageBreak();
var offset = value is DrawingValue ? page * pageHeight : 0;
column.Item().Height(UnitConverter.Points(pageHeight, unit)).Svg(
BuildSvg(pages[page], width, pageHeight, offset, unit));
}
});
}
private static List<DrawingValue> Slice(DrawingValue value, float pageHeight)
{
var pageCount = Math.Max(1, (int)Math.Ceiling(Math.Max(0, value.ContentHeight) / pageHeight));
return Enumerable.Repeat(value, pageCount).ToList();
}
internal static List<IReadOnlyList<DrawingCommand>> RecordPages(PagedDrawingValue value,
float width, float height, int maxPages)
{
if (maxPages is < 1 or > 10_000)
throw new InvalidDataException("PagedDrawingValue.MaxPages muss zwischen 1 und 10000 liegen.");
var pages = new List<IReadOnlyList<DrawingCommand>>();
var state = value.InitialState;
for (var pageNumber = 1; pageNumber <= maxPages; pageNumber++)
{
var canvas = new DrawingCanvas();
var context = new DrawingPageContext(width, height, pageNumber, canvas, state);
var finished = value.DrawPage(context);
pages.Add(canvas.Commands.ToList());
state = context.State;
if (finished) return pages;
}
throw new InvalidDataException($"Der Zeichen-Callback war nach {maxPages} Seiten noch nicht beendet.");
}
internal static IReadOnlyList<DrawingCommand> RecordSingle(PagedDrawingValue value, float width, float height)
{
var canvas = new DrawingCanvas();
value.DrawPage(new DrawingPageContext(width, height, 1, canvas, value.InitialState));
return canvas.Commands.ToList();
}
private static string BuildSvg(DrawingValue value, float width, float height, float verticalOffset, string unit)
{
if (!float.IsFinite(value.ContentHeight) || value.ContentHeight < 0)
throw new InvalidDataException("DrawingValue.ContentHeight muss eine endliche, nichtnegative Zahl sein.");
if (value.Commands.Count > 100_000)
throw new InvalidDataException("DrawingValue enthält zu viele Zeichenbefehle.");
var svg = new StringBuilder();
svg.Append(CultureInfo.InvariantCulture,
$"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 {verticalOffset} {width} {height}\" overflow=\"hidden\">");
float? currentX = null, currentY = null;
foreach (var command in value.Commands)
{
switch (command)
{
case MoveTo move:
Validate(move.X, move.Y); currentX = move.X; currentY = move.Y;
break;
case LineTo line when currentX is not null && currentY is not null:
Validate(line.X, line.Y, line.StrokeWidth); Positive(line.StrokeWidth);
AppendLine(svg, currentX.Value, currentY.Value, line.X, line.Y, line.Color, line.StrokeWidth);
currentX = line.X; currentY = line.Y;
break;
case LineTo:
throw new InvalidDataException("LineTo benötigt ein vorheriges MoveTo.");
case DrawLine line:
Validate(line.X1, line.Y1, line.X2, line.Y2, line.StrokeWidth); Positive(line.StrokeWidth);
AppendLine(svg, line.X1, line.Y1, line.X2, line.Y2, line.Color, line.StrokeWidth);
break;
case DrawRectangle rectangle:
Validate(rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height, rectangle.StrokeWidth);
Positive(rectangle.Width, rectangle.Height, rectangle.StrokeWidth);
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)}\"/>");
break;
case DrawString text:
Validate(text.X, text.Y, text.FontSize);
Positive(text.FontSize);
var localFontSize = text.FontSize / UnitConverter.Points(1, unit);
svg.Append(CultureInfo.InvariantCulture,
$"<text x=\"{text.X}\" y=\"{text.Y + localFontSize}\" font-size=\"{localFontSize}\" fill=\"{Attribute(text.Color)}\" font-family=\"{FontFamily(text.FontFamily)}\" font-weight=\"{(text.Bold ? "600" : "400")}\" font-style=\"{(text.Italic ? "italic" : "normal")}\">{SecurityElement.Escape(text.Text)}</text>");
break;
case DrawStringEx text:
Validate(text.X, text.Y, text.Height, text.Width, text.FontSize);
Positive(text.Height, text.Width, text.FontSize);
AppendTextBox(svg, text, unit);
break;
case DrawImage image:
Validate(image.X, image.Y, image.Width, image.Height);
Positive(image.Width, image.Height);
if (image.MimeType is not ("image/png" or "image/jpeg"))
throw new InvalidDataException("DrawImage unterstützt nur PNG und JPEG.");
if (image.Data.Length > 20 * 1024 * 1024)
throw new InvalidDataException("Ein DrawImage-Bild darf höchstens 20 MB groß sein.");
svg.Append(CultureInfo.InvariantCulture,
$"<image x=\"{image.X}\" y=\"{image.Y}\" width=\"{image.Width}\" height=\"{image.Height}\" href=\"data:{image.MimeType};base64,{Convert.ToBase64String(image.Data)}\" preserveAspectRatio=\"xMidYMid meet\"/>");
break;
default: throw new InvalidDataException($"Unbekannter Zeichenbefehl {command.GetType().Name}.");
}
}
return svg.Append("</svg>").ToString();
}
private static void AppendLine(StringBuilder svg, float x1, float y1, float x2, float y2,
string color, float strokeWidth) => svg.Append(CultureInfo.InvariantCulture,
$"<line x1=\"{x1}\" y1=\"{y1}\" x2=\"{x2}\" y2=\"{y2}\" stroke=\"{Attribute(color)}\" stroke-width=\"{strokeWidth}\"/>");
private static string Attribute(string value)
{
if (value != "none" && !System.Text.RegularExpressions.Regex.IsMatch(value,
@"^(#[0-9a-fA-F]{3,8}|[a-zA-Z]{1,24})$"))
throw new InvalidDataException($"Ungültiger Zeichenfarbwert „{value}“.");
return value;
}
private static string FontFamily(string value)
{
if (string.IsNullOrWhiteSpace(value)) return "sans-serif";
if (!System.Text.RegularExpressions.Regex.IsMatch(value, @"^[\p{L}\p{N} _.,-]{1,80}$"))
throw new InvalidDataException($"Ungültige Schriftfamilie „{value}“.");
return SecurityElement.Escape(value) ?? "sans-serif";
}
private static void AppendTextBox(StringBuilder svg, DrawStringEx text, string unit)
{
var fontSize = text.FontSize / UnitConverter.Points(1, unit);
var (x, anchor) = text.Alignment switch
{
DrawingTextAlignment.AlignCenter => (text.X + text.Width / 2, "middle"),
DrawingTextAlignment.AlignRight => (text.X + text.Width, "end"),
_ => (text.X, "start"),
};
var clipId = "clip" + Guid.NewGuid().ToString("N");
svg.Append(CultureInfo.InvariantCulture,
$"<defs><clipPath id=\"{clipId}\"><rect x=\"{text.X}\" y=\"{text.Y}\" width=\"{text.Width}\" height=\"{text.Height}\"/></clipPath></defs>");
svg.Append(CultureInfo.InvariantCulture,
$"<text x=\"{x}\" y=\"{text.Y + fontSize}\" text-anchor=\"{anchor}\" clip-path=\"url(#{clipId})\" font-size=\"{fontSize}\" fill=\"{Attribute(text.Color)}\" font-family=\"{FontFamily(text.FontFamily)}\" font-weight=\"{(text.Bold ? "600" : "400")}\" font-style=\"{(text.Italic ? "italic" : "normal")}\">");
var lines = text.Text.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
for (var index = 0; index < lines.Length; index++)
svg.Append(CultureInfo.InvariantCulture,
$"<tspan x=\"{x}\" dy=\"{(index == 0 ? 0 : fontSize * 1.2f)}\">{SecurityElement.Escape(lines[index])}</tspan>");
svg.Append("</text>");
}
private static void Validate(params float[] values)
{
if (values.Any(x => !float.IsFinite(x))) throw new InvalidDataException("Zeichenkoordinaten müssen endlich sein.");
}
private static void Positive(params float[] values)
{
if (values.Any(x => x < 0)) throw new InvalidDataException("Zeichengrößen dürfen nicht negativ sein.");
}
}
internal static class ChartElementRenderer internal static class ChartElementRenderer
{ {
public static void Render(IContainer container, ChartValue value, string chartType, public static void Render(IContainer container, ChartValue value, string chartType,
+195
View File
@@ -1,5 +1,200 @@
# LehrerApp Templating # LehrerApp Templating
## Dynamische Zeichenflächen für externe Apps
Externe `ITemplateDataProvider` können einen Platzhalter vom Typ `Drawing` mit einem
`DrawingValue` befüllen. Dabei wird kein QuestPDF-/Skia-Canvas nach außen gegeben. Die portable
Form besteht stattdessen aus einer geprüften, serialisierbaren Befehlsliste:
```csharp
var drawing = new DrawingValue(
[
new DrawRectangle(0, 0, 160, 24, "#2563EB", 0.8f, "#EFF6FF"),
new DrawString(4, 4, "Dynamischer Bericht", 11, "#1E3A8A", Bold: true),
new MoveTo(4, 19),
new LineTo(156, 19, "#93C5FD", 0.6f),
new DrawImage(120, 2, 30, 18, pngBytes, "image/png")
],
ContentHeight: 24);
```
Koordinaten und Längen verwenden die Einheit des Layouts; `DrawString.FontSize` wird wie bei
`TEXT` in Punkt angegeben. Unterstützt werden `DrawString`, `MoveTo`, `LineTo`, `DrawLine`,
`DrawRectangle` und `DrawImage` (PNG/JPEG). Die Befehle gelangen über den normalen
`ITemplateDataProvider`, beispielsweise als `values["ExternerBericht"] = drawing`.
`DrawStringEx(x, y, height, width, ...)` ergänzt eine geclippte Textbox mit `AlignLeft`,
`AlignCenter` oder `AlignRight`. Farbe und Schriftfamilie können bei beiden Textbefehlen gesetzt
werden. Ohne Schriftangabe wird `sans-serif` verwendet. Für portable Schriften registriert die
integrierende App TTF-/OTF-Daten einmal vor dem Rendern:
```csharp
DrawingFontRegistry.RegisterFont("MeineSchulschrift", fontBytes);
canvas.DrawStringEx(0, 0, 12, context.Width, "Zentrierte Überschrift",
DrawingTextAlignment.AlignCenter, 11, "MeineSchulschrift", "#1E3A8A", bold: true);
```
Explizite Zeilenumbrüche werden berücksichtigt; Text außerhalb von `height`/`width` wird
abgeschnitten. Eine automatische Worttrennung findet in dieser elementaren Zeichenfunktion nicht
statt.
Im Layout stehen zwei Varianten zur Verfügung:
```text
DRAWBOX 20 40 170 80 $ExternerBericht
FLOWDRAWBOX 20 40 170 237 $LangesProtokoll
```
`DRAWBOX` ist ein fester, geclippter Viewport. Inhalte außerhalb seiner Breite oder Höhe werden
nicht angezeigt. `FLOWDRAWBOX` zerlegt den vertikalen Zeichenraum anhand von `ContentHeight` in
gleich hohe Seitenfenster und setzt ihn auf Folgeseiten fort. Wie bei `FLOWBOX` darf ein Layout
höchstens ein fließendes Element enthalten; bei einem eigenen Folgeseitenlayout müssen Typ,
Position und Größe übereinstimmen.
Ein Vorlagenpaket kann keinen Callback und keinen Typ aus einer fremden Assembly einschleusen.
Farben, Zahlen, Bildformate, Bildgröße und Gesamtzahl der portablen Befehle werden validiert.
Damit bleibt die paketfähige Schnittstelle deterministisch. Ein `PagedDrawingValue` ist dagegen
ein ausdrücklich vom vertrauenswürdigen In-Process-`ITemplateDataProvider` übergebener Delegate.
Für umfangreiche In-Process-Integrationen gibt es zusätzlich den klassischen seitenweisen
Callback `PagedDrawingValue`. Er wird vor QuestPDFs Layout vollständig in deklarative Seitenlisten
aufgezeichnet und daher nicht durch interne Layoutdurchläufe mehrfach ausgeführt:
```csharp
var value = new PagedDrawingValue(context =>
{
var nextRow = context.State is int row ? row : 0;
// Nur vollständige Strukturen zeichnen, die noch in die zugewiesene Box passen.
while (nextRow < rows.Count && PasstNochVollstaendig(rows[nextRow], context))
ZeichneZeile(context.Canvas, rows[nextRow++]);
context.State = nextRow;
return nextRow == rows.Count; // true = fertig, false = weitere FLOWDRAWBOX
}, InitialState: 0);
```
`DrawingPageContext` stellt `Width`, `Height`, `PageNumber`, `Canvas` und ein über alle Aufrufe
weitergereichtes `State`-Objekt bereit. So kann der Zeichner Tabellenzeilen, Diagrammgruppen oder
andere unteilbare Strukturen bewusst auf die nächste Seite verschieben. Ein `DRAWBOX`-Callback
wird genau einmal aufgerufen; bei `FLOWDRAWBOX` fordert `false` eine weitere Seite an. `MaxPages`
verhindert Endlosschleifen. Delegates funktionieren nur innerhalb desselben .NET-Prozesses;
prozessübergreifend bleibt `DrawingValue` die Übergabeform.
## PDF-Import im TemplateDesigner
Der Menüpunkt **Einfügen → PDF als Vorlage importieren** rekonstruiert einseitige PDF-Vorlagen.
Der präzisere Modus verwendet ein leeres Template zusammen mit einem ausgefüllten Beispiel und
ermittelt variable Textbereiche über einen toleranten Geometrie-Diff. Mit nur einem PDF werden
Datum, Zahlen und typische Adressbereiche lokal heuristisch vorerkannt.
PdfPig extrahiert Text, Bounding-Box, Schriftgröße und verfügbare Schriftmerkmale. Die optionale
KI-Klassifikation erhält ausschließlich diese strukturierte Zwischenrepräsentation und darf nur
Placeholder-Namen, Typ, Gruppierung und Konfidenz liefern. Koordinaten werden nicht an die KI
delegiert. Das gerasterte PDF bleibt als Hintergrund erhalten; erkannte variable Bereiche werden
deterministisch mit einem weißen Asset maskiert und anschließend als `TEXT` oder `TEXTBOX`
eingefügt. Der Nutzer prüft alle Vorschläge im Importdialog und muss die Übernahme ausdrücklich
bestätigen. Das Paket wird dabei noch nicht gespeichert.
Die serverseitige Klassifikation liegt in `ai-backend/pdf-template.php` und verwendet denselben
Login-, Bearer-Token-, Guthaben- und Abrechnungsmechanismus wie die übrigen KI-Funktionen. Das
Passwort wird vom eigenständigen Designer nicht gespeichert. Tabellen-/Chart-Erkennung und die
automatische Rekonstruktion mehrseitiger Vorlagen sind bewusst nicht Teil von v1.
## Mehrseitiger Fließtext
`TEXTBOX` bleibt ein absolut positionierter Bereich mit fester Höhe. Für Texte unbekannter Länge
steht `FLOWBOX` mit derselben Syntax zur Verfügung:
```text
PAGE 210 297 mm
FLOWBOX 20 45 170 232 $Klassenbucheintraege size=11
```
Der Inhalt wird innerhalb dieses Bereichs umbrochen und bei Bedarf auf beliebig vielen Seiten
fortgesetzt. Pro Layout ist höchstens eine `FLOWBOX` zulässig. Das optionale Manifestfeld
`continuationLayoutFile` verweist auf ein zweites Layout im Paket, das ab Seite 2 verwendet wird.
Damit können Folgeseiten beispielsweise einen kleineren Briefkopf oder einen eigenen Hintergrund
haben. Haupt- und Folgeseitenlayout müssen dieselbe Seitengröße besitzen; ihre `FLOWBOX` muss aus
technischen Gründen dieselbe Position und Größe haben. Ohne Folgeseitenlayout werden die statischen
Elemente der ersten Seite auf jeder erzeugten Seite wiederholt.
## Systemvariablen
Textinhalte können drei vom Renderer bereitgestellte Variablen verwenden. Sie werden nicht im
Manifest deklariert und nicht vom `ITemplateDataProvider` geliefert:
- `$$today` aktuelles lokales Datum im deutschen Kurzformat
- `$$curPage` aktuelle Seitenzahl
- `$$maxPageNum` Gesamtzahl der Seiten
Sie können allein oder innerhalb eines Literals stehen, beispielsweise:
```text
TEXT 20 10 "Stand: $$today" size=9
TEXT 145 285 "Seite $$curPage von $$maxPageNum" size=9 align=right
```
QuestPDF löst aktuelle und gesamte Seitenzahl während der Dokumenterzeugung auf. Dafür ist kein
zusätzlicher Renderdurchlauf durch die Anwendung erforderlich. Die Variablen funktionieren auch in
konstanten Rich-Text-Platzhaltern.
## Konstante Platzhalter und Hervorhebung
Ein Platzhalter kann seinen Wert vollständig im Vorlagenpaket tragen. `IsConstant=true` bewirkt,
dass `ConstantValue` beim Rendern immer verwendet wird; ein gleichnamiger Wert aus dem externen
`ITemplateDataProvider` wird bewusst ignoriert. Damit eignen sich Konstanten besonders für lange
Textbausteine in `TEXTBOX`, rechtliche Hinweise oder wiederkehrende Fußtexte.
```csharp
new PlaceholderDefinition(
"Datenschutzhinweis",
PlaceholderType.Multiline,
IsConstant: true,
ConstantValue: "Dieser längere Text wird im Paket gespeichert.",
Bold: true,
Italic: false,
Underline: false);
```
Konstante Werte werden für `Text`, `Multiline`, `Date` und `Number` unterstützt. Die Eigenschaften
`Bold`, `Italic` und `Underline` wirken, wenn der Platzhalter direkt von einem `TEXT`- oder
`TEXTBOX`-Element referenziert wird. In der Layout-DSL kann Unterstreichung außerdem direkt mit
`underline=true` gesetzt werden.
Konstante Text- und Multiline-Werte unterstützen zusätzlich abschnittsweise Hervorhebung und
eingebettete externe Platzhalter:
```text
Sehr geehrte Familie [b]${Student.LastName}[/b],
bitte geben Sie die [u]unterschriebene Erklärung[/u] bis [i]Freitag[/i] zurück.
```
Unterstützt werden `[b]…[/b]`, `[i]…[/i]` und `[u]…[/u]`, auch verschachtelt. Die Klammerform
`${Name}` ist in Fließtext vorzuziehen; `${Datum|dd.MM.yyyy}` erlaubt zusätzlich ein Format.
Eingebettete Platzhalter müssen im Manifest als externe Platzhalter deklariert sein. Ihre gelieferten
Werte werden immer als reiner Text behandelt und können deshalb kein Markup einschleusen. Mit
`\[`, `\$` und `\\` lassen sich die Steuerzeichen wörtlich ausgeben.
## Freie Paketmetadaten
Jedes neu gespeicherte `.lavorlage`-Paket enthält eine lesbare `metadata.txt`. Pro Zeile steht ein
frei wählbares Schlüssel-Wert-Paar; leere Zeilen und mit `#` beginnende Kommentare werden beim
Einlesen ignoriert:
```text
language=de-DE
report-type=parent-letter
school-year=2026/27
```
Werte dürfen ein weiteres `=` enthalten. Schlüssel sind ohne Beachtung der Groß-/Kleinschreibung
eindeutig und Werte bleiben einzeilig. Der Loader stellt sie der Anwendung direkt über
`loadedTemplate.Manifest.Metadata` zur Verfügung. Bestehende Pakete ohne `metadata.txt` werden
weiterhin mit einer leeren Metadatensammlung geladen. Die empfohlenen Standardschlüssel stehen
zusätzlich als `TemplateMetadataKeys.Language` und `TemplateMetadataKeys.ReportType` bereit.
## Bildskalierung ## Bildskalierung
`IMG` unterstützt neben dem festen Begrenzungsrahmen eine optionale prozentuale Skalierung: `IMG` unterstützt neben dem festen Begrenzungsrahmen eine optionale prozentuale Skalierung:
+48
View File
@@ -0,0 +1,48 @@
namespace LehrerApp.Templating;
internal static class SystemVariables
{
internal const string CurrentPage = "curPage";
internal const string MaximumPageNumber = "maxPageNum";
internal const string Today = "today";
private static readonly string[] Names = [CurrentPage, MaximumPageNumber, Today];
internal static bool IsStandalone(string value) =>
TryRead(value, 0, out var length) && length == value.Length;
internal static bool Contains(string value) => Names.Any(name =>
value.Contains("$$" + name, StringComparison.Ordinal));
internal static bool TryRead(string source, int index, out int length)
{
length = 0;
if (index < 0 || index + 2 > source.Length || source[index] != '$' || source[index + 1] != '$')
return false;
foreach (var name in Names)
{
var token = "$$" + name;
if (!source.AsSpan(index).StartsWith(token, StringComparison.Ordinal)) continue;
var end = index + token.Length;
if (end < source.Length && (char.IsLetterOrDigit(source[end]) || source[end] is '_' or '-' or '.'))
continue;
length = token.Length;
return true;
}
return false;
}
internal static IEnumerable<(string Text, string? Name)> Split(string source)
{
var literalStart = 0;
for (var index = 0; index < source.Length;)
{
if (!TryRead(source, index, out var length)) { index++; continue; }
if (index > literalStart) yield return (source[literalStart..index], null);
yield return (source.Substring(index, length), source.Substring(index + 2, length - 2));
index += length;
literalStart = index;
}
if (literalStart < source.Length) yield return (source[literalStart..], null);
}
}
@@ -0,0 +1,73 @@
using System.Globalization;
namespace LehrerApp.Templating;
public static class TemplateDataResolver
{
public static IReadOnlyList<string> ValidateConstants(TemplateManifest manifest)
{
var issues = new List<string>();
var definitions = manifest.Placeholders.GroupBy(x => x.Name, StringComparer.Ordinal)
.ToDictionary(x => x.Key, x => x.First(), StringComparer.Ordinal);
foreach (var constant in manifest.Placeholders.Where(x => x.IsConstant))
{
try
{
ConstantValue(constant);
if (constant.Type is not (PlaceholderType.Text or PlaceholderType.Multiline)) continue;
foreach (var reference in TemplateRichText.UsedPlaceholders(constant.ConstantValue ?? ""))
{
if (!definitions.TryGetValue(reference, out var target))
issues.Add($"Konstanter Text „{constant.Name}“ verwendet den nicht deklarierten Platzhalter „{reference}“.");
else if (target.IsConstant)
issues.Add($"Konstanter Text „{constant.Name}“ darf nur externe Platzhalter verwenden; „{reference}“ ist ebenfalls konstant.");
else if (target.Type is not (PlaceholderType.Text or PlaceholderType.Multiline
or PlaceholderType.Date or PlaceholderType.Number))
issues.Add($"Eingebetteter Platzhalter „{reference}“ hat keinen textuell darstellbaren Typ.");
}
}
catch (InvalidDataException ex) { issues.Add(ex.Message); }
}
return issues;
}
public static IReadOnlyDictionary<string, PlaceholderValue> Resolve(TemplateManifest manifest,
IReadOnlyDictionary<string, PlaceholderValue> externalValues)
{
var result = new Dictionary<string, PlaceholderValue>(externalValues, StringComparer.Ordinal);
foreach (var definition in manifest.Placeholders.Where(x => x.IsConstant))
result[definition.Name] = ConstantValue(definition);
return result;
}
public static PlaceholderValue ConstantValue(PlaceholderDefinition definition)
{
var raw = definition.ConstantValue ?? "";
return definition.Type switch
{
PlaceholderType.Text => new TextValue(raw),
PlaceholderType.Multiline => new MultilineValue(raw),
PlaceholderType.Date => new DateValue(ParseDate(definition, raw)),
PlaceholderType.Number => new NumberValue(ParseNumber(definition, raw)),
_ => throw new InvalidDataException(
$"Platzhalter „{definition.Name}“ vom Typ {definition.Type} kann keinen konstanten Textwert verwenden."),
};
}
private static DateOnly ParseDate(PlaceholderDefinition definition, string raw)
{
if (DateOnly.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
|| DateOnly.TryParse(raw, CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out date)) return date;
throw Error(definition, "Datum, z. B. 2026-08-30");
}
private static decimal ParseNumber(PlaceholderDefinition definition, string raw)
{
if (decimal.TryParse(raw, NumberStyles.Number, CultureInfo.InvariantCulture, out var number)
|| decimal.TryParse(raw, NumberStyles.Number, CultureInfo.GetCultureInfo("de-DE"), out number)) return number;
throw Error(definition, "Zahl");
}
private static InvalidDataException Error(PlaceholderDefinition definition, string expected) =>
new($"Konstanter Wert für „{definition.Name}“ ist ungültig; erwartet wird: {expected}.");
}
+58 -4
View File
@@ -57,13 +57,56 @@ public sealed class TemplateLoader(TemplateLimits? limits = null,
if (manifest.SchemaVersion != CurrentSchemaVersion) if (manifest.SchemaVersion != CurrentSchemaVersion)
throw Error($"schemaVersion {manifest.SchemaVersion} wird nicht unterstützt."); throw Error($"schemaVersion {manifest.SchemaVersion} wird nicht unterstützt.");
if (!IsSafeRelativePath(manifest.LayoutFile)) throw Error("layoutFile enthält einen unsicheren Pfad."); if (!IsSafeRelativePath(manifest.LayoutFile)) throw Error("layoutFile enthält einen unsicheren Pfad.");
if (!IsSafeRelativePath(manifest.MetadataFile)) throw Error("metadataFile enthält einen unsicheren Pfad.");
if (manifest.ContinuationLayoutFile is not null && !IsSafeRelativePath(manifest.ContinuationLayoutFile))
throw Error("continuationLayoutFile enthält einen unsicheren Pfad.");
if (Normalize(manifest.MetadataFile).Equals(Normalize(manifest.LayoutFile), StringComparison.OrdinalIgnoreCase))
throw Error("metadataFile und layoutFile dürfen nicht identisch sein.");
if (!entries.TryGetValue(Normalize(manifest.LayoutFile), out var layoutEntry)) if (!entries.TryGetValue(Normalize(manifest.LayoutFile), out var layoutEntry))
throw Error($"Layoutdatei „{manifest.LayoutFile}“ fehlt."); throw Error($"Layoutdatei „{manifest.LayoutFile}“ fehlt.");
string layoutSource; string layoutSource;
using (var reader = new StreamReader(layoutEntry.Open())) layoutSource = reader.ReadToEnd(); using (var reader = new StreamReader(layoutEntry.Open())) layoutSource = reader.ReadToEnd();
var metadataPath = Normalize(manifest.MetadataFile);
manifest.Metadata = new(StringComparer.OrdinalIgnoreCase);
if (entries.TryGetValue(metadataPath, out var metadataEntry))
{
if (metadataEntry.Length > 1024 * 1024) throw Error("metadata.txt überschreitet das Größenlimit.");
try
{
using var reader = new StreamReader(metadataEntry.Open());
manifest.Metadata = TemplateMetadataText.Parse(reader.ReadToEnd());
}
catch (InvalidDataException ex) { throw Error($"{manifest.MetadataFile} ist ungültig: {ex.Message}"); }
}
var layout = new LayoutParser().Parse(layoutSource); var layout = new LayoutParser().Parse(layoutSource);
var referencedAssets = layout.Elements.Select(AssetPath).Where(x => x is not null).Cast<string>() TemplateLayout? continuationLayout = null;
if (manifest.ContinuationLayoutFile is { } continuationPath)
{
if (!entries.TryGetValue(Normalize(continuationPath), out var continuationEntry))
throw Error($"Folgeseiten-Layoutdatei „{continuationPath}“ fehlt.");
using var reader = new StreamReader(continuationEntry.Open());
continuationLayout = new LayoutParser().Parse(reader.ReadToEnd());
if (continuationLayout.Width != layout.Width || continuationLayout.Height != layout.Height
|| !continuationLayout.Unit.Equals(layout.Unit, StringComparison.OrdinalIgnoreCase))
issues.Add(new(ValidationSeverity.Error, "Folgeseiten-Layout und Hauptlayout müssen dieselbe Seitengröße und Einheit verwenden."));
}
var allLayouts = continuationLayout is null ? new[] { layout } : new[] { layout, continuationLayout };
var flowBoxes = FlowElements(layout).ToList();
if (flowBoxes.Count > 1)
issues.Add(new(ValidationSeverity.Error, "Ein Layout darf höchstens ein fließendes Element (FLOWBOX/FLOWDRAWBOX) enthalten."));
if (continuationLayout is not null)
{
var continuationFlows = FlowElements(continuationLayout).ToList();
if (flowBoxes.Count != 1 || continuationFlows.Count != 1)
issues.Add(new(ValidationSeverity.Error, "Bei einem Folgeseiten-Layout müssen Haupt- und Folgeseite jeweils genau ein gleichartiges fließendes Element enthalten."));
else if (flowBoxes[0].GetType() != continuationFlows[0].GetType())
issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen denselben fließenden Elementtyp verwenden."));
else if (flowBoxes[0].X != continuationFlows[0].X || flowBoxes[0].Y != continuationFlows[0].Y
|| flowBoxes[0].Width != continuationFlows[0].Width || flowBoxes[0].Height != continuationFlows[0].Height)
issues.Add(new(ValidationSeverity.Error, "Die FLOWBOX muss auf Haupt- und Folgeseiten dieselbe Position und Größe haben."));
}
var referencedAssets = allLayouts.SelectMany(x => x.Elements).Select(AssetPath).Where(x => x is not null).Cast<string>()
.Select(Normalize).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); .Select(Normalize).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
foreach (var path in referencedAssets) foreach (var path in referencedAssets)
{ {
@@ -74,9 +117,12 @@ public sealed class TemplateLoader(TemplateLimits? limits = null,
var assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase); var assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
var layoutPath = Normalize(manifest.LayoutFile); var layoutPath = Normalize(manifest.LayoutFile);
var continuationLayoutPath = manifest.ContinuationLayoutFile is null ? null : Normalize(manifest.ContinuationLayoutFile);
foreach (var (path, asset) in entries.Where(x => foreach (var (path, asset) in entries.Where(x =>
!x.Key.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) !x.Key.Equals("manifest.json", StringComparison.OrdinalIgnoreCase)
&& !x.Key.Equals(layoutPath, StringComparison.OrdinalIgnoreCase))) && !x.Key.Equals(layoutPath, StringComparison.OrdinalIgnoreCase)
&& !x.Key.Equals(continuationLayoutPath, StringComparison.OrdinalIgnoreCase)
&& !x.Key.Equals(metadataPath, StringComparison.OrdinalIgnoreCase)))
{ {
if (asset.Length > _limits.MaxAssetBytes) if (asset.Length > _limits.MaxAssetBytes)
{ issues.Add(new(ValidationSeverity.Error, $"Bild „{path}“ überschreitet das Größenlimit.")); continue; } { issues.Add(new(ValidationSeverity.Error, $"Bild „{path}“ überschreitet das Größenlimit.")); continue; }
@@ -91,12 +137,14 @@ public sealed class TemplateLoader(TemplateLimits? limits = null,
} }
var declared = manifest.Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal); var declared = manifest.Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal);
foreach (var used in UsedPlaceholders(layout).Where(x => !declared.Contains(x))) foreach (var used in allLayouts.SelectMany(UsedPlaceholders).Distinct(StringComparer.Ordinal).Where(x => !declared.Contains(x)))
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{used}“ wird im Layout verwendet, aber nicht im Manifest deklariert.")); issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{used}“ wird im Layout verwendet, aber nicht im Manifest deklariert."));
foreach (var duplicate in manifest.Placeholders.GroupBy(x => x.Name, StringComparer.Ordinal).Where(x => x.Count() > 1)) foreach (var duplicate in manifest.Placeholders.GroupBy(x => x.Name, StringComparer.Ordinal).Where(x => x.Count() > 1))
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{duplicate.Key}“ ist mehrfach deklariert.")); issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{duplicate.Key}“ ist mehrfach deklariert."));
foreach (var issue in TemplateDataResolver.ValidateConstants(manifest))
issues.Add(new(ValidationSeverity.Error, issue));
if (issues.Count > 0) throw new TemplateValidationException(new(issues)); if (issues.Count > 0) throw new TemplateValidationException(new(issues));
return new(manifest, layout, assets, sourceName); return new(manifest, layout, assets, sourceName, continuationLayout);
} }
catch (InvalidDataException ex) catch (InvalidDataException ex)
{ throw Error($"Paket ist kein lesbares ZIP-Archiv: {ex.Message}"); } { throw Error($"Paket ist kein lesbares ZIP-Archiv: {ex.Message}"); }
@@ -107,6 +155,7 @@ public sealed class TemplateLoader(TemplateLimits? limits = null,
var issues = new List<ValidationIssue>(); var issues = new List<ValidationIssue>();
foreach (var placeholder in template.Manifest.Placeholders) foreach (var placeholder in template.Manifest.Placeholders)
{ {
if (placeholder.IsConstant) continue;
if (!providedTypes.TryGetValue(placeholder.Name, out var actual)) if (!providedTypes.TryGetValue(placeholder.Name, out var actual))
{ {
if (placeholder.Required) issues.Add(new(ValidationSeverity.Error, $"Pflichtwert „{placeholder.Name}“ fehlt.")); if (placeholder.Required) issues.Add(new(ValidationSeverity.Error, $"Pflichtwert „{placeholder.Name}“ fehlt."));
@@ -121,10 +170,15 @@ public sealed class TemplateLoader(TemplateLimits? limits = null,
{ {
TextElement { Placeholder: { } p } => p, TextElement { Placeholder: { } p } => p,
TextBoxElement { Placeholder: { } p } => p, TextBoxElement { Placeholder: { } p } => p,
FlowBoxElement { Placeholder: { } p } => p,
DrawBoxElement d => d.Placeholder,
FlowDrawBoxElement d => d.Placeholder,
TableElement t => t.Placeholder, TableElement t => t.Placeholder,
ChartElement c => c.Placeholder, ChartElement c => c.Placeholder,
_ => null, _ => null,
}).Where(x => x is not null).Cast<string>().Distinct(StringComparer.Ordinal); }).Where(x => x is not null).Cast<string>().Distinct(StringComparer.Ordinal);
private static IEnumerable<TemplateElement> FlowElements(TemplateLayout layout) =>
layout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement);
internal static bool IsSafeRelativePath(string path) => !string.IsNullOrWhiteSpace(path) internal static bool IsSafeRelativePath(string path) => !string.IsNullOrWhiteSpace(path)
&& !Path.IsPathRooted(path) && !path.Split('/', '\\').Any(part => part == ".." || part.Length == 0); && !Path.IsPathRooted(path) && !path.Split('/', '\\').Any(part => part == ".." || part.Length == 0);
@@ -0,0 +1,53 @@
namespace LehrerApp.Templating;
public static class TemplateMetadataKeys
{
public const string Language = "language";
public const string ReportType = "report-type";
}
public static class TemplateMetadataText
{
public const string FileName = "metadata.txt";
public static Dictionary<string, string> Parse(string source)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var lines = source.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
for (var index = 0; index < lines.Length; index++)
{
var line = lines[index].Trim();
if (line.Length == 0 || line.StartsWith('#')) continue;
var separator = line.IndexOf('=');
if (separator < 1)
throw new InvalidDataException($"Metadatenzeile {index + 1} muss Schlüssel=Wert enthalten.");
var key = line[..separator].Trim();
var value = line[(separator + 1)..].Trim();
Validate(key, value);
if (!result.TryAdd(key, value))
throw new InvalidDataException($"Metadatenschlüssel „{key}“ ist mehrfach vorhanden.");
}
return result;
}
public static string Serialize(IReadOnlyDictionary<string, string> metadata)
{
if (metadata.Count == 0) return "";
var lines = new List<string>(metadata.Count);
foreach (var (key, value) in metadata.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase))
{
Validate(key, value);
lines.Add($"{key.Trim()}={value.Trim()}");
}
return string.Join('\n', lines) + "\n";
}
private static void Validate(string key, string value)
{
if (string.IsNullOrWhiteSpace(key)) throw new InvalidDataException("Ein Metadatenschlüssel darf nicht leer sein.");
if (key.Contains('=') || key.Contains('\n') || key.Contains('\r'))
throw new InvalidDataException($"Metadatenschlüssel „{key}“ enthält ein unzulässiges Zeichen.");
if (value.Contains('\n') || value.Contains('\r'))
throw new InvalidDataException($"Metadatenwert für „{key}“ darf nur eine Zeile umfassen.");
}
}
+21 -1
View File
@@ -8,8 +8,23 @@ public static class TemplatePackage
public const string Extension = ".lavorlage"; public const string Extension = ".lavorlage";
public static void Create(string outputPath, TemplateManifest manifest, string layoutSource, public static void Create(string outputPath, TemplateManifest manifest, string layoutSource,
IReadOnlyDictionary<string, byte[]> assets) IReadOnlyDictionary<string, byte[]> assets, string? continuationLayoutSource = null)
{ {
if (!TemplateLoader.IsSafeRelativePath(manifest.MetadataFile))
throw new InvalidDataException("metadataFile enthält einen unsicheren Pfad.");
if (TemplateLoader.Normalize(manifest.MetadataFile).Equals(
TemplateLoader.Normalize(manifest.LayoutFile), StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("metadataFile und layoutFile dürfen nicht identisch sein.");
var reservedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "manifest.json", TemplateLoader.Normalize(manifest.LayoutFile), TemplateLoader.Normalize(manifest.MetadataFile) };
if (manifest.ContinuationLayoutFile is { } continuationPath)
{
if (!TemplateLoader.IsSafeRelativePath(continuationPath))
throw new InvalidDataException("continuationLayoutFile enthält einen unsicheren Pfad.");
reservedPaths.Add(TemplateLoader.Normalize(continuationPath));
if (continuationLayoutSource is null)
throw new InvalidDataException("Das Folgeseiten-Layout fehlt.");
}
if (!string.Equals(Path.GetExtension(outputPath), Extension, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(Path.GetExtension(outputPath), Extension, StringComparison.OrdinalIgnoreCase))
outputPath += Extension; outputPath += Extension;
var directory = Path.GetDirectoryName(outputPath); var directory = Path.GetDirectoryName(outputPath);
@@ -21,10 +36,15 @@ public static class TemplatePackage
{ {
WriteText(archive, "manifest.json", JsonSerializer.Serialize(manifest, TemplateLoader.JsonOptions)); WriteText(archive, "manifest.json", JsonSerializer.Serialize(manifest, TemplateLoader.JsonOptions));
WriteText(archive, manifest.LayoutFile, layoutSource); WriteText(archive, manifest.LayoutFile, layoutSource);
if (manifest.ContinuationLayoutFile is { } continuationFile)
WriteText(archive, continuationFile, continuationLayoutSource!);
WriteText(archive, manifest.MetadataFile, TemplateMetadataText.Serialize(manifest.Metadata));
foreach (var asset in assets) foreach (var asset in assets)
{ {
if (!TemplateLoader.IsSafeRelativePath(asset.Key)) if (!TemplateLoader.IsSafeRelativePath(asset.Key))
throw new InvalidDataException($"Unsicherer Assetpfad „{asset.Key}“."); throw new InvalidDataException($"Unsicherer Assetpfad „{asset.Key}“.");
if (reservedPaths.Contains(TemplateLoader.Normalize(asset.Key)))
throw new InvalidDataException($"Assetpfad „{asset.Key}“ ist für Paketdaten reserviert.");
var entry = archive.CreateEntry(TemplateLoader.Normalize(asset.Key), CompressionLevel.Optimal); var entry = archive.CreateEntry(TemplateLoader.Normalize(asset.Key), CompressionLevel.Optimal);
using var stream = entry.Open(); stream.Write(asset.Value); using var stream = entry.Open(); stream.Write(asset.Value);
} }
+114
View File
@@ -0,0 +1,114 @@
using System.Text;
namespace LehrerApp.Templating;
public sealed record TemplateRichTextRun(string Text, string? Placeholder, string? Format,
bool Bold, bool Italic, bool Underline)
{
public bool IsPlaceholder => Placeholder is not null;
}
public static class TemplateRichText
{
public static IReadOnlyList<TemplateRichTextRun> Parse(string source)
{
var runs = new List<TemplateRichTextRun>();
var literal = new StringBuilder();
var styles = new Stack<char>();
void Flush()
{
if (literal.Length == 0) return;
AddRun(runs, new(literal.ToString(), null, null,
styles.Contains('b'), styles.Contains('i'), styles.Contains('u')));
literal.Clear();
}
for (var index = 0; index < source.Length;)
{
if (source[index] == '\\' && index + 1 < source.Length
&& source[index + 1] is '\\' or '[' or '$')
{ literal.Append(source[index + 1]); index += 2; continue; }
if (source[index] == '$' && SystemVariables.TryRead(source, index, out var systemLength))
{
literal.Append(source, index, systemLength);
index += systemLength;
continue;
}
if (TryTag(source, index, out var tag, out var closing, out var tagLength))
{
Flush();
if (closing)
{
if (styles.Count == 0 || styles.Peek() != tag)
throw new InvalidDataException($"Rich-Text-Tag [/{tag}] ist nicht passend geöffnet.");
styles.Pop();
}
else styles.Push(tag);
index += tagLength; continue;
}
if (source[index] == '$' && TryPlaceholder(source, index, out var name, out var format, out var length))
{
Flush();
AddRun(runs, new("", name, format,
styles.Contains('b'), styles.Contains('i'), styles.Contains('u')));
index += length; continue;
}
literal.Append(source[index++]);
}
Flush();
if (styles.Count > 0) throw new InvalidDataException($"Rich-Text-Tag [{styles.Peek()}] wurde nicht geschlossen.");
return runs;
}
public static IReadOnlyList<string> UsedPlaceholders(string source) => Parse(source)
.Where(x => x.Placeholder is not null).Select(x => x.Placeholder!).Distinct(StringComparer.Ordinal).ToList();
private static bool TryTag(string source, int index, out char tag, out bool closing, out int length)
{
tag = default; closing = false; length = 0;
if (index + 2 < source.Length && source[index] == '[' && source[index + 2] == ']'
&& source[index + 1] is 'b' or 'i' or 'u')
{ tag = source[index + 1]; length = 3; return true; }
if (index + 3 < source.Length && source[index] == '[' && source[index + 1] == '/'
&& source[index + 3] == ']' && source[index + 2] is 'b' or 'i' or 'u')
{ tag = source[index + 2]; closing = true; length = 4; return true; }
return false;
}
private static bool TryPlaceholder(string source, int index, out string name, out string? format, out int length)
{
name = ""; format = null; length = 0;
if (index + 1 >= source.Length) return false;
if (source[index + 1] == '{')
{
var end = source.IndexOf('}', index + 2);
if (end < 0) throw new InvalidDataException("Platzhalter mit '${' wurde nicht mit '}' geschlossen.");
var content = source[(index + 2)..end];
var parts = content.Split('|', 2);
name = parts[0].Trim(); format = parts.Length == 2 ? parts[1] : null;
if (name.Length == 0) throw new InvalidDataException("Ein eingebetteter Platzhaltername darf nicht leer sein.");
length = end - index + 1; return true;
}
if (!IsNameStart(source[index + 1])) return false;
var cursor = index + 2;
while (cursor < source.Length && IsNamePart(source[cursor])) cursor++;
name = source[(index + 1)..cursor]; length = cursor - index; return true;
}
private static bool IsNameStart(char value) => char.IsLetter(value) || value == '_';
private static bool IsNamePart(char value) => char.IsLetterOrDigit(value) || value is '_' or '.' or '-';
private static void AddRun(List<TemplateRichTextRun> runs, TemplateRichTextRun run)
{
if (!run.IsPlaceholder && runs.LastOrDefault() is { IsPlaceholder: false } previous
&& previous.Bold == run.Bold && previous.Italic == run.Italic && previous.Underline == run.Underline)
runs[^1] = previous with { Text = previous.Text + run.Text };
else runs.Add(run);
}
}
+131
View File
@@ -1392,6 +1392,18 @@ Zugangsdaten; Schülerdaten, Fehlzeiten und der unverschlüsselte CSV-Report pas
eine lokale `ParticipationSession` dieses Kurses existiert, können als offen, entschuldigt oder eine lokale `ParticipationSession` dieses Kurses existiert, können als offen, entschuldigt oder
unentschuldigt übernommen werden; fremde/ganz­tägige Abwesenheiten erzeugen keine lokale Stunde. unentschuldigt übernommen werden; fremde/ganz­tägige Abwesenheiten erzeugen keine lokale Stunde.
**Nachtrag (September 2026, Nutzer-Feedback) — Zeitraum-Datumsfelder ließen sich nicht ändern:**
Avalonias WinUI-artiger Spinner-`DatePicker` übernimmt Änderungen im Tag/Monat/Jahr-Flyout nur über
einen separaten Häkchen-Button; ein Klick daneben verwirft sie stillschweigend. Alle verbliebenen
Zeitraum-Datumsfelder mit diesem `DatePicker` auf `CalendarDatePicker` umgestellt (gleiche
`DateTimeOffset?`-Bindung, kein Typwechsel nötig) — bereits das etablierte Muster für frei wählbare
Einzeldatumsfelder in dieser Codebasis (`WithdrawStudentDialog`, `CreateLetterDialog`, 7.2.3), dort
ohne dieses Problem, da ein Klick auf einen Kalendertag sofort übernimmt statt einen
Bestätigungsschritt zu verlangen. Betroffen: [ClassTeacherRegisterView.axaml](LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherRegisterView.axaml),
[ClassTeacherAbsencesView.axaml](LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherAbsencesView.axaml),
[WebUntisTimetableImportDialog.axaml](LehrerApp.Desktop/Views/Planning/WebUntisTimetableImportDialog.axaml)
und [WebUntisDocumentationComparisonDialog.axaml](LehrerApp.Desktop/Views/Students/WebUntisDocumentationComparisonDialog.axaml).
**Nachtrag zu 4.3, Fehlzeiten je Unterricht (August 2026):** Der ursprüngliche Fehlzeitenabgleich **Nachtrag zu 4.3, Fehlzeiten je Unterricht (August 2026):** Der ursprüngliche Fehlzeitenabgleich
rief `getTimetableWithAbsences` ohne Element auf und bekam damit den kompletten Lehrer-Stundenplan rief `getTimetableWithAbsences` ohne Element auf und bekam damit den kompletten Lehrer-Stundenplan
zurück (einmal pro Kursmitglied, siehe damalige Ineffizienz-Korrektur) — das erfordert mehr zurück (einmal pro Kursmitglied, siehe damalige Ineffizienz-Korrektur) — das erfordert mehr
@@ -1679,6 +1691,75 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
`IStudentRepository`, `IParticipationRepository`, `IParticipationSessionRepository` (alle schon `IStudentRepository`, `IParticipationRepository`, `IParticipationSessionRepository` (alle schon
als Singleton registriert, nur Konstruktor-Injection ergänzt). als Singleton registriert, nur Konstruktor-Injection ergänzt).
**Bewusst zurückgestellt:** Elterngesprächs-Blatt als PDF — Konzept noch nicht geschärft genug. **Bewusst zurückgestellt:** Elterngesprächs-Blatt als PDF — Konzept noch nicht geschärft genug.
- [x] **"Klassenlehrer"-Feature — Verspätungen nicht mehr als fehlende Entschuldigung, sondern als
eigenes Muster (August 2026):** Nutzer-Feedback: "Offene Entschuldigungen" erinnerte auch bei
Verspätungen, die aber i.d.R. gar nicht entschuldigungsfähig sind — die Erinnerung "Entschuldigung
fehlt" war hier gegenstandslos.
- `ClassTeacherOpenExcuseRow.Build` filtert jetzt zusätzlich `!a.IsLate` heraus — Verspätungen
tauchen nicht mehr in der Karte "Offene Entschuldigungen" auf und lösen darüber auch keine
Wiedervorlage mehr aus.
- Stattdessen zweite Eskalationsstufe in der Mustererkennung: die bestehende Kurzfrist-Regel
(≥2 Verspätungen in 7 Tagen → Warning-Hinweis ohne Aktion) bleibt, kommt aber erst zum Zug,
wenn eine neue Jahresschwelle (`LateYearEscalationThreshold = 5`, Verspätungen seit
Schuljahresbeginn) nicht bereits gerissen ist — dann Danger-Hinweis "Elterngespräch oder Brief
erwägen" mit eigenem "+"-Button (`CreateReminderForPatternNoticeCommand`, legt wie bei "Offene
Entschuldigungen" eine `WorkTask` an). Kernlogik aus dem bisher privaten `BuildPatternNotices`
in ein neues öffentliches, statisches `DetectLatePatterns` gezogen (gleiches Muster wie
`DetectWeekdayPatterns`), damit die Schwellenwerte ohne ViewModel-Instanz testbar sind.
`ClassTeacherPatternNotice` hat dafür ein neues `CanCreateReminder`-Flag (Default `false`,
steuert Sichtbarkeit des "+"-Buttons in der XAML).
- [x] **"Klassenlehrer"-Feature — Fehlquote seit Schuljahresbeginn zu niedrig kurz nach Schuljahres-
start (August 2026):** Nutzer-Feedback: Schüler*innen, die seit Unterrichtsbeginn nachweislich an
jedem Tag fehlten, zeigten trotzdem nur ~57 % statt der erwarteten ~100 % in
`ClassTeacherRosterRow.YearSummaryLabel`. Ursache: der Nenner (`SchoolDaysElapsed`) zählte
Werktage ab dem fest verdrahteten 1. August (`SchoolYearService.SchoolYearStart`) — die
tatsächlichen Sommerferien enden je nach Bundesland/Jahr aber erst Wochen später, und kurz nach
Schuljahresbeginn macht diese Restferienzeit einen großen Teil des bis dahin "verstrichenen"
Zeitraums aus (rechnerisch: absolut korrekte Fehlzeiten-Zähler, aber ein um mehrere Wochen zu
großer Nenner). Mehrstufig gelöst, jede Stufe durch Nutzer-Feedback ausgelöst:
1. Erster Versuch: `EstimateTermStart` (frühester Fehlzeiten-Eintrag der ganzen Klasse als
Näherung für den tatsächlichen ersten Unterrichtstag) — ohne zusätzlichen WebUntis-Abruf, aber
nur eine Näherung.
2. Nutzer-Hinweis: WebUntis kennt den echten Ferienkalender bereits (`getHolidays`-Bericht, in
`LehrerApp.WebUntis/WebUntisClient.GetHolidaysAsync` schon implementiert, aber bis dahin
nirgends im Desktop verdrahtet). Verdrahtet über `WebUntisIntegrationService.GetHolidaysAsync`
(neuer `UntisHolidayDto`, gleiches Muster wie `GetSchoolYearsAsync`), gecacht (nicht über die
LiteDB-Tabellen von `UntisReportCacheService` — deren heißes/kaltes Fenster ist auf sich
laufend ändernde Fehlzeiten zugeschnitten, Ferien ändern sich dagegen innerhalb eines
Schuljahrs praktisch nie — sondern einfacher tagesgenauer Cache direkt in
`WebUntisSettingsService`: `CachedUntisHoliday`-Liste + `HolidaysFetchedAt`, unverschlüsselt,
kein Geheimnis anders als iCal-URL/API-Zugangsdaten in derselben Datei). Neue
`ClassTeacherOverviewViewModel.CountSchoolWeekdays` zieht Ferienzeiträume von der
Werktagszählung ab.
3. **Nutzer-Verifikation deckte auf: `getHolidays` liefert für dieses Konto nie einen
Sommerferien-Eintrag** — geprüft anhand der tatsächlich gecachten Antwort (179 Einträge,
11 Jahre Historie ab 2015): Herbst-/Weihnachts-/Osterferien und einzelne bewegliche
Ferientage sind lückenlos dabei, aber kein einziger Juli-/August-Zeitraum, in keinem der
11 Jahre. Vermutlich weil die Sommerferien WebUntis-intern zwischen zwei
Schuljahres-Datensätzen liegen (die bei 1.8./31.7. enden) statt "in" einem davon — WebUntis
scheint sie deshalb keinem Schuljahr zuzuordnen. `CountSchoolWeekdays` (Ferien innerhalb eines
Zeitraums abziehen) kann diese Lücke also grundsätzlich nicht schließen, unabhängig von
Caching oder Implementierung. `EstimateTermStart` bleibt deshalb zusätzlich bestehen (behebt
die Sommerferien-Lücke am Startpunkt), `CountSchoolWeekdays` läuft ab diesem geschätzten
Starttag (behebt Herbst-/Weihnachts-/Osterferien & bewegliche Ferientage innerhalb des
restlichen Jahres — oben unter "Trend & Fehlquote" noch als bewusste Vereinfachung
dokumentiert, für den Teil jetzt erledigt). Beide Kombinationen als Regressionstest
festgehalten (`CountSchoolWeekdays_AbEchtemTerminstartOhneSommerferienEintragStimmtMitBeobachtungUeberein`)
mit den vom Nutzer nachgezählten echten Werten (13 Schultage seit 13.08.2026).
- Herkunft des Nenners jetzt direkt im Tooltip nachvollziehbar statt nur intern verrechnet:
`ClassTeacherRosterRow.TermStart` ("seit dd.MM.") und `HolidayWeekdaysExcluded`
("X Ferientage abgezogen") — Lehre aus diesem Vorfall, bei dem der falsche Wert sonst erneut
unbemerkt geblieben wäre.
- `AppLogger` (optional, DI) protokolliert jeden `GetHolidaysAsync`-Aufruf: bei Erfolg Name +
Zeitraum jedes geladenen Ferieneintrags, bei Fehlschlag die Exception — nächster Diagnoseschritt
wäre sonst wieder nur stilles Rätselraten gewesen.
Neue Abhängigkeiten `WebUntisIntegrationService`, `AppLogger?` in `ClassTeacherOverviewViewModel`
(beide bereits als Singleton registriert, nur Konstruktor-Injection ergänzt).
**Nicht behoben, weil mit den vorhandenen Daten nicht erkennbar:** Kolleg*innen, die die
Anwesenheitsliste nur sporadisch führen, drücken die Quote auf dieselbe Weise (fehlende
Fehlzeiten-Einträge an Tagen mit tatsächlichem Unterricht) — von echter Anwesenheit ist das aus
dem WebUntis-Fehlzeitenbericht allein nicht unterscheidbar, dafür bräuchte es Daten darüber, ob
für eine Stunde überhaupt eine Anwesenheitsprüfung stattfand.
### 4.4 Wochen-/Tagesansicht ### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3 - [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
@@ -2329,6 +2410,56 @@ CSV-Exports der Auswertung (6.3.3) über die gemeinsame Export-Infrastruktur aus
welche TickTick-Liste ist die Zielliste (fest in den Einstellungen hinterlegt, analog zur welche TickTick-Liste ist die Zielliste (fest in den Einstellungen hinterlegt, analog zur
WebUntis-URL)? WebUntis-URL)?
- [ ] **6.1.8** E-Mail-Ordner-Triage fürs dienstliche Postfach (Nutzerwunsch, 2026-08-30): Der
Nutzer verwaltet Termine/Bitten/Rundschreiben/Messevorbereitungen aktuell ausschließlich per
Mail (eM Client gegen ein schlecht konfiguriertes Open-Xchange-"Fake-Exchange", echtes
Outlook/EWS funktioniert dort nicht zuverlässig — Open-Xchange spricht aber Standard-IMAP).
Ziel ist **keine** vollwertige Mail-Client-Funktion in LehrerApp (kein Compose/Reply, keine
allgemeine Ordnerverwaltung, kein Postfach-weites Lesen), sondern eine schmale Brücke: Der
Nutzer legt in eM Client einen Ordner an (z.B. "→LehrerApp") und verschiebt dorthin manuell,
was eine Aktion in der Schule braucht. LehrerApp verbindet sich per IMAP **ausschließlich
mit diesem einen Ordner plus einem festgelegten Archiv-Zielordner** — keine anderen Ordner
werden je adressiert, insbesondere nicht der Posteingang. Zeigt die Mails im Triage-Ordner
an (Betreff/Absender/Datum/Textvorschau) und bietet pro Mail "→ Termin anlegen" /
"→ Aufgabe anlegen" (öffnet die bestehenden Dialoge aus 6.1.2 bzw. Kapitel 4, vorbefüllt mit
Betreff als Titel). Nach dem Anlegen verschiebt LehrerApp die Mail automatisch in den
Archiv-Ordner (`UID MOVE`, RFC 6851; falls vom Open-Xchange-Server nicht unterstützt:
Fallback `COPY` + `\Deleted`-Flag + `EXPUNGE`, jeweils nur innerhalb des Triage-Ordners) —
genau der manuelle "Mail raussuchen und archivieren"-Schritt entfällt damit. Echtes Löschen
bleibt eine separate, bewusst zusätzliche Aktion (eigener Button, nicht automatisch beim
Archivieren mitgemacht).
- **Architektur-Vorbild:** wie `LehrerApp.WebUntis` ein eigenständiger, serverunabhängiger
Client — neues `LehrerApp.Mail`-Projekt (IMAP-Verbindung + Nachrichtenliste + Move/Delete,
via `MailKit` — .NET hat kein natives IMAP in der BCL, `System.Net.Mail` deckt nur SMTP ab;
MailKit ist der De-facto-Standard, MIT-lizenziert, Eintrag in `Directory.Packages.props`),
Desktop bindet es analog zu `LehrerApp.WebUntis` direkt ein. Zugangsdaten
(IMAP-Host/Port/Nutzer/Passwort, Triage- und Archiv-Ordnername) dürfen wie
WebUntis-Zugangsdaten **nicht** über `LehrerApp.Api` laufen — rein lokal, gleiches
Einstellungsfeld-Muster wie die WebUntis-URL (Kapitel 12). Passwort wird lokal verschlüsselt
abgelegt (Nutzerentscheid: gleiches Bedrohungsmodell wie eM Client selbst — wer lokal an die
Zugangsdaten kommt, hätte auch direkten Zugriff auf das Mailprogramm), kein separater
OS-Credential-Store nötig. Der Postfach-Bereich (Navigationspunkt, Triage-Ansicht) bleibt in
der UI komplett verborgen, bis IMAP-Zugangsdaten hinterlegt sind — gleiches
Sichtbarkeits-/DI-Registrierungsmuster wie `SyncEngine`/`SnapshotService`, die laut
`AppBootstrapper.LoadServerUrl` nur bei konfigurierter Server-URL überhaupt registriert
werden.
- **Mehrgeräte-Fall:** Ein aus einer Mail erzeugter `WorkTask`/Termin synct wie jedes andere
Objekt ganz normal über den bestehenden Sync-Layer zu allen Geräten (kein Zusatzaufwand,
siehe die `OnChange`-Begründung im Nachtrag oben). Es fehlt nur der Rückkanal: das
tatsächliche Verschieben/Löschen der Mail kann nur das Gerät ausführen, das selbst IMAP-
Zugangsdaten zum *gleichen* Postfach hinterlegt hat. Dafür trägt der `WorkTask` ein neues,
nicht-geheimes Herkunftsfeld (z.B. `SourceMailAccount` = IMAP-Nutzername/Adresse,
`SourceMailFolder`, `SourceMailUid`) — synct automatisch mit. Jeder Client vergleicht dieses
Feld beim Anzeigen mit seiner eigenen lokalen IMAP-Konfiguration: nur bei Übereinstimmung
wird der "Postfach archivieren"-Button überhaupt angezeigt; ohne passende (oder ganz ohne)
lokale Zugangsdaten sieht der Task nur einen Hinweis "stammt aus Mail auf …", aber keine
ausführbare Aktion. Verhindert, dass ein Gerät ohne die richtigen Zugangsdaten versucht, auf
ein Postfach zuzugreifen, das es gar nicht kennt.
- **Offen:** Poll-Intervall; ob `UID MOVE` vom Open-Xchange-Server tatsächlich unterstützt wird
(vorher gegen den echten Server prüfen, sonst greift der Fallback); ob eine Mail-Vorschau nur
Text oder auch einfaches HTML rendern soll; Verhalten bei Anhängen (zunächst vermutlich
ignorieren, nur Text/Metadaten).
**Nachtrag — Pädagogische Klassen-Aufgaben (Nutzer-Feedback):** Wunsch nach einer zweiten, **Nachtrag — Pädagogische Klassen-Aufgaben (Nutzer-Feedback):** Wunsch nach einer zweiten,
"weniger arbeitszeitrelevant als pädagogisch" gedachten Art von Todo-Item (Beispiele: "weniger arbeitszeitrelevant als pädagogisch" gedachten Art von Todo-Item (Beispiele:
"Ansage an die Klasse", "Etwas zum Stichtag einsammeln/austeilen"), gleichberechtigt im "Ansage an die Klasse", "Etwas zum Stichtag einsammeln/austeilen"), gleichberechtigt im
+36
View File
@@ -77,6 +77,42 @@ function ai_backend_fail(int $httpStatus, string $message): never
exit; exit;
} }
/**
* Dekodiert eine JSON-Antwort des LLM. Modelle setzen trotz entsprechender Anweisung gelegentlich
* Markdown-Codezäune oder einen kurzen Begleittext um das eigentliche JSON. Diese rein
* syntaktischen Zusätze sollen eine ansonsten gültige Antwort nicht unbrauchbar machen.
*/
function ai_backend_decode_json_response(string $content): ?array
{
$content = trim($content, "\xEF\xBB\xBF \t\n\r\0\x0B");
$candidates = [$content];
if (preg_match('/```(?:json)?\s*([\s\S]*?)\s*```/i', $content, $match) === 1) {
$candidates[] = trim($match[1]);
}
$firstBrace = strpos($content, '{');
$lastBrace = strrpos($content, '}');
if ($firstBrace !== false && $lastBrace !== false && $lastBrace >= $firstBrace) {
$candidates[] = substr($content, $firstBrace, $lastBrace - $firstBrace + 1);
}
foreach (array_unique($candidates) as $candidate) {
$decoded = json_decode($candidate, true);
if (is_array($decoded) && json_last_error() === JSON_ERROR_NONE) {
return $decoded;
}
}
error_log(sprintf(
'LLM JSON parse failed: %s; length=%d; sha256=%s',
json_last_error_msg(),
strlen($content),
hash('sha256', $content)
));
return null;
}
/** /**
* Ruft das konfigurierte LLM (oder den FakeProvider für lokale Tests, siehe README.md) auf und * Ruft das konfigurierte LLM (oder den FakeProvider für lokale Tests, siehe README.md) auf und
* verrechnet die echten Token-Kosten gegen das Guthaben des Nutzers. Gemeinsame Logik für jeden * verrechnet die echten Token-Kosten gegen das Guthaben des Nutzers. Gemeinsame Logik für jeden
+1 -1
View File
@@ -60,7 +60,7 @@ PROMPT;
$userContent = json_encode($body); $userContent = json_encode($body);
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent); $result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent);
$parsed = json_decode($result['content'], true); $parsed = ai_backend_decode_json_response($result['content']);
if (!is_array($parsed) || !isset($parsed['explanation'])) { if (!is_array($parsed) || !isset($parsed['explanation'])) {
ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.'); ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.');
} }
+1 -1
View File
@@ -82,7 +82,7 @@ PROMPT;
$userContent = json_encode($body); $userContent = json_encode($body);
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent); $result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent);
$parsed = json_decode($result['content'], true); $parsed = ai_backend_decode_json_response($result['content']);
if (!is_array($parsed) || !isset($parsed['procedure'])) { if (!is_array($parsed) || !isset($parsed['procedure'])) {
ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.'); ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.');
} }
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
$pdo = ai_backend_db($config);
$user = ai_backend_authenticate($pdo);
if ((float) $user['balance_usd'] <= 0) {
ai_backend_fail(402, 'Kein Guthaben mehr vorhanden.');
}
$body = json_decode(file_get_contents('php://input'), true);
if (!is_array($body) || !isset($body['document']['pages']) || !is_array($body['candidates'] ?? null)) {
ai_backend_fail(400, 'Dokument oder Kandidaten fehlen.');
}
if (count($body['candidates']) > 500) {
ai_backend_fail(413, 'Zu viele Textkandidaten in einer Anfrage.');
}
$systemPrompt = <<<'PROMPT'
Du klassifizierst Textblöcke aus deutschen Schul- und Verwaltungsdokumenten für einen
Vorlageneditor. Die Geometrie wurde deterministisch aus einem PDF extrahiert und darf von dir
niemals geändert oder ergänzt werden. Du erhältst Seiten, Textblöcke und eine Liste von Kandidaten.
Für jeden Kandidaten:
- vergib einen kurzen stabilen deutschen Placeholder-Namen nur aus Buchstaben und Ziffern,
- wähle type ausschließlich aus text, multiline, date, number,
- wähle confidence ausschließlich aus high, medium, low,
- darfst du benachbarte, logisch zusammengehörige Zeilen gruppieren. Dann enthält blockIds alle
Original-IDs der Gruppe. Jede übernommene ID muss exakt aus der Eingabe stammen.
- typische Namen sind Anrede, Empfaenger, Adresse, PlzOrt, Datum, Betreff, Aktenzeichen, Brieftext.
Antworte AUSSCHLIESSLICH mit gültigem JSON in diesem Schema:
{
"classifications": [
{
"id": "<id eines Kandidaten>",
"blockIds": ["<unveränderte Original-ID>"],
"name": "<PlaceholderName>",
"type": "text|multiline|date|number",
"confidence": "high|medium|low"
}
]
}
PROMPT;
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt,
json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$parsed = ai_backend_decode_json_response($result['content']);
if (!is_array($parsed) || !is_array($parsed['classifications'] ?? null)) {
ai_backend_fail(502, 'Die KI hat kein gültiges Klassifikations-JSON geliefert.');
}
$knownIds = [];
foreach ($body['document']['pages'] as $page) {
foreach (($page['textBlocks'] ?? []) as $block) {
if (isset($block['id'])) $knownIds[(string) $block['id']] = true;
}
}
$candidateIds = array_fill_keys(array_map(fn($x) => (string) ($x['id'] ?? ''), $body['candidates']), true);
$allowedTypes = ['text', 'multiline', 'date', 'number'];
$allowedConfidence = ['high', 'medium', 'low'];
foreach ($parsed['classifications'] as $classification) {
if (!isset($candidateIds[(string) ($classification['id'] ?? '')])
|| !in_array($classification['type'] ?? '', $allowedTypes, true)
|| !in_array($classification['confidence'] ?? '', $allowedConfidence, true)
|| !preg_match('/^[\pL\pN]+$/u', (string) ($classification['name'] ?? ''))
|| !is_array($classification['blockIds'] ?? null)) {
ai_backend_fail(502, 'Die KI-Klassifikation enthält ungültige Werte.');
}
foreach ($classification['blockIds'] as $blockId) {
if (!isset($knownIds[(string) $blockId])) {
ai_backend_fail(502, 'Die KI-Klassifikation referenziert einen unbekannten Textblock.');
}
}
}
echo json_encode(['classifications' => $parsed['classifications']], JSON_UNESCAPED_UNICODE);
+1 -1
View File
@@ -161,7 +161,7 @@ $result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userC
// Erst NACH der Abrechnung validieren: die Token wurden real verbraucht, das wird auch dann // Erst NACH der Abrechnung validieren: die Token wurden real verbraucht, das wird auch dann
// verrechnet, wenn die KI kein valides JSON geliefert hat (siehe Planungsdokument). // verrechnet, wenn die KI kein valides JSON geliefert hat (siehe Planungsdokument).
$parsed = json_decode($result['content'], true); $parsed = ai_backend_decode_json_response($result['content']);
if (!is_array($parsed) || !isset($parsed['lessons'])) { if (!is_array($parsed) || !isset($parsed['lessons'])) {
ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.'); ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.');
} }
+15
View File
@@ -13,6 +13,21 @@ class FakeProvider implements ProviderInterface
{ {
public function sendMessage(string $systemPrompt, string $userContent, int $maxTokens): array public function sendMessage(string $systemPrompt, string $userContent, int $maxTokens): array
{ {
if (str_contains($systemPrompt, 'Placeholder-Namen')) {
$input = json_decode($userContent, true);
$classifications = array_map(static fn(array $candidate): array => [
'id' => $candidate['id'],
'blockIds' => $candidate['blockIds'] ?? [$candidate['id']],
'name' => $candidate['suggestedName'] ?? 'Feld',
'type' => $candidate['type'] ?? 'text',
'confidence' => $candidate['confidence'] ?? 'low',
], $input['candidates'] ?? []);
return [
'content' => json_encode(['classifications' => $classifications]),
'inputTokens' => 42, 'outputTokens' => 17,
'cacheCreationInputTokens' => 0, 'cacheReadInputTokens' => 0,
];
}
return [ return [
'content' => json_encode([ 'content' => json_encode([
'lessons' => [ 'lessons' => [
+72
View File
@@ -0,0 +1,72 @@
# Templating-Layoutformat 3
Layoutformat 3 trennt feste Seitengestaltung von fortlaufendem Dokumentinhalt.
## Grundaufbau
```text
PAGE 210 297 mm
#pragma format-version 3
#pragma page-template first
IMG briefkopf.png 0 0 210 55
#pragma flow-slot body x=20 y=90 w=170 h=175
#pragma end-page-template
#pragma page-template continuation
IMG folgeseite.png 0 0 210 25
#pragma flow-slot body x=20 y=30 w=170 h=235
#pragma end-page-template
#pragma content-flow body
TEXTBOX $Brieftext size=11 overflow=continue
TEXT "Mit freundlichen Grüßen" gap=8 keep-with-next=true
TEXT $LehrerName gap=3 italic=true
#pragma end-content-flow
```
## Seitentypen
- `first` ist verpflichtend und gestaltet die erste Seite.
- `continuation` gestaltet alle Folgeseiten. Fehlt dieser Typ, wird `first` wiederholt.
- Elemente innerhalb eines `page-template` sind absolut positioniert und beeinflussen den Textfluss nicht.
- Jede Seitenvorlage beschreibt ausschließlich ihre eigenen Elemente und Slots.
## Flow-Slots und Content-Flows
Ein `flow-slot` definiert eine Bounding Box innerhalb einer Seitenvorlage. Ein gleichnamiger
`content-flow` liefert den fortlaufenden Inhalt. Ist `continuation` vorhanden, benötigt jeder
Content-Flow auf `first` und `continuation` einen gleichnamigen Slot.
Elemente in einem Content-Flow werden ohne X/Y-Koordinaten angegeben:
```text
TEXT "Absatz" size=11 gap=4
TEXTBOX $MehrzeiligerText size=11 overflow=continue
TABLE $Zeilen size=9
CHART $Werte type=bar h=50
IMG unterschrift.png w=45 h=18
```
- `gap` ist der Abstand zum Vorgänger in der Seiteneinheit.
- `keep-with-next=true` hält das Element nach Möglichkeit mit seinem Nachfolger zusammen.
- Text und Tabellen dürfen automatisch auf Folgeseiten weiterlaufen.
## Designer
Der Tab **Seiten & Flows** verwaltet Seitentypen, Content-Flows und Flow-Slots. Der visuelle
Editor zeigt den Slot des gewählten Seitentyps violett gestrichelt an. Er kann wie ein anderes
Element verschoben und skaliert werden. Die Vorschauseiten-Auswahl zeigt alle tatsächlich aus den
Beispieldaten erzeugten Dokumentseiten; **Seitentyp anzeigen** rendert stattdessen die feste
Gestaltung der gewählten Seitenvorlage.
Beim Öffnen eines alten absoluten Layouts bettet der Designer dessen Elemente unverändert in den
Seitentyp `first` ein und ergänzt leere `body`-Slots sowie einen leeren Content-Flow. Erst beim
anschließenden Speichern wird das migrierte Layout in das Paket geschrieben.
## Aktuelle Grenzen
Die erste Renderer-Ausbaustufe unterstützt einen mit Inhalt gefüllten, paginierenden Content-Flow.
Weitere Slots und leere Flows können bereits gestaltet werden. Beim primären Flow müssen X-Position
und Breite auf erster und Folgeseite noch identisch sein; Y-Position und Höhe dürfen sich unterscheiden.
Diese Einschränkung wird beim Rendern mit einer verständlichen Fehlermeldung geprüft.
Binary file not shown.
Binary file not shown.