using QuestPDF.Fluent; using QuestPDF.Helpers; using QuestPDF.Infrastructure; namespace LehrerApp.Desktop.Services; // ── Druckdaten (bewusst schlanke DTOs statt ViewModels: die PDF-Erzeugung bleibt dadurch ohne // Avalonia-/MVVM-Bezug testbar, und die Views entscheiden selbst, was gedruckt wird) ──────────── /// Alle Plätze des Rasters in Zeilen-Hauptordnung, auch ausgeblendete /// (IsHidden) - sie belegen ihre Rasterposition weiterhin, werden aber leer gedruckt /// (gleiche Logik wie SeatingPlanPanel/SeatCellViewModel.ShowSeat). public sealed record SeatingPlanPrintData( string PlanTitle, string PlanSubtitle, int Columns, bool IsBoardAtBottom, IReadOnlyList ColumnGapWidths, IReadOnlyList Seats); public sealed record SeatPrintCell(int Row, int Column, string? StudentName, bool IsHidden); public sealed record GradeListPrintData( string GroupLabel, string SchoolYear, string PeriodLabel, IReadOnlyList ColumnHeaders, IReadOnlyList Rows); public sealed record GradeListPrintRow(string Name, IReadOnlyList Cells, string Total); public sealed record LabelValue(string Label, string Value); public sealed record ExamStatisticsPrintData( string ExamTitle, string DateLabel, string NiveauLabel, IReadOnlyList Stats, IReadOnlyList Distribution, IReadOnlyList TaskAnalysis); public sealed record GradeDistributionPrintRow(string Grade, int Count, double Percent); public sealed record TaskAnalysisPrintRow(string Label, double AvgPercent, bool IsWeak); public sealed record CompetencyReportPrintData( string GroupLabel, string StudentName, string Summary, string ThresholdLabel, IReadOnlyList Rows); public sealed record CompetencyReportPrintRow( string Domain, string Code, string Description, string InstructionCoverage, string ExamCoverage, string GroupResult, string StudentResult, string Recommendation); public sealed record StudentDocumentationPrintData( string StudentName, IReadOnlyList Entries); public sealed record DocumentationPrintEntry( string DateDisplay, string TypeLabel, string Title, string Content, IReadOnlyList Tags, bool IsConfidential, bool IsDraft); public sealed record HazardAssessmentPrintData( string Title, string GroupLabel, string DateDisplay, string KindLabel, string Procedure, IReadOnlyList Substances, IReadOnlyList Hazards, IReadOnlyList ProtectiveMeasures, string FirstAid, string Disposal, string Notes, bool IsAiAssisted); public sealed record HazardSubstancePrintRow( string Name, string Cas, string Amount, string Pictograms, string SignalWord, string HStatements, string PStatements, string ActivityRestriction); /// /// PDF-Erzeugung über QuestPDF (11.3). Liefert fertige Bytes - der Speicherdialog läuft wie bei /// allen Exporten über . Lebt bewusst in LehrerApp.Desktop statt Core: /// QuestPDF zieht SkiaSharp-Native-Binaries mit, die im Server-Image (LehrerApp.Api, referenziert /// Core transitiv über Sync) nichts verloren haben. /// public sealed class PdfExportService { static PdfExportService() => QuestPDF.Settings.License = LicenseType.Community; // Bildschirm-Pixel (Tischabstände, 0-300) → Druckpunkte. 0.4 hält auch den Maximalabstand // (120pt ≈ 4cm) auf einer A4-Querseite praktikabel. private const float GapPixelToPoint = 0.4f; public byte[] BuildSeatingPlanPdf(SeatingPlanPrintData data) => Document.Create(container => container.Page(page => { page.Size(PageSizes.A4.Landscape()); page.Margin(36); page.DefaultTextStyle(style => style.FontSize(10)); page.Header().Element(header => Header(header, data.PlanTitle, data.PlanSubtitle)); page.Content().PaddingVertical(10).Column(column => { column.Spacing(10); if (!data.IsBoardAtBottom) column.Item().Element(BoardBanner); column.Item().Element(grid => SeatGrid(grid, data)); if (data.IsBoardAtBottom) column.Item().Element(BoardBanner); }); page.Footer().Element(Footer); })).GeneratePdf(); public byte[] BuildGradeListPdf(GradeListPrintData data) => Document.Create(container => container.Page(page => { page.Size(PageSizes.A4.Landscape()); page.Margin(36); page.DefaultTextStyle(style => style.FontSize(8)); page.Header().Element(header => Header(header, $"Notenliste {data.GroupLabel}", $"{data.SchoolYear} · {data.PeriodLabel}")); page.Content().PaddingVertical(10).Table(table => { table.ColumnsDefinition(columns => { columns.RelativeColumn(2.4f); foreach (var _ in data.ColumnHeaders) columns.RelativeColumn(); columns.RelativeColumn(0.8f); }); table.Header(header => { header.Cell().Element(HeaderCell).Text("Name").SemiBold(); foreach (var columnHeader in data.ColumnHeaders) header.Cell().Element(HeaderCell).Text(columnHeader).SemiBold(); header.Cell().Element(HeaderCell).AlignRight().Text("Schnitt").SemiBold(); }); foreach (var row in data.Rows) { table.Cell().Element(BodyCell).Text(row.Name); foreach (var cell in row.Cells) table.Cell().Element(BodyCell).AlignCenter().Text(cell); table.Cell().Element(BodyCell).AlignRight().Text(row.Total).SemiBold(); } }); page.Footer().Element(Footer); })).GeneratePdf(); public byte[] BuildExamStatisticsPdf(ExamStatisticsPrintData data) => Document.Create(container => container.Page(page => { page.Size(PageSizes.A4); page.Margin(36); page.DefaultTextStyle(style => style.FontSize(10)); var subtitle = string.IsNullOrWhiteSpace(data.NiveauLabel) ? data.DateLabel : $"{data.DateLabel} · Niveau {data.NiveauLabel}"; page.Header().Element(header => Header(header, data.ExamTitle, subtitle)); page.Content().PaddingVertical(10).Column(column => { column.Spacing(14); column.Item().Table(table => { table.ColumnsDefinition(columns => { columns.RelativeColumn(2); columns.RelativeColumn(); }); foreach (var stat in data.Stats) { table.Cell().Element(BodyCell).Text(stat.Label); table.Cell().Element(BodyCell).AlignRight().Text(stat.Value).SemiBold(); } }); column.Item().Text("Notenspiegel").FontSize(12).SemiBold(); column.Item().Table(table => { table.ColumnsDefinition(columns => { columns.ConstantColumn(60); columns.ConstantColumn(60); columns.ConstantColumn(60); columns.RelativeColumn(); }); table.Header(header => { header.Cell().Element(HeaderCell).Text("Note").SemiBold(); header.Cell().Element(HeaderCell).AlignRight().Text("Anzahl").SemiBold(); header.Cell().Element(HeaderCell).AlignRight().Text("Anteil").SemiBold(); header.Cell().Element(HeaderCell); }); var maxCount = data.Distribution.Count == 0 ? 0 : data.Distribution.Max(d => d.Count); foreach (var entry in data.Distribution) { table.Cell().Element(BodyCell).Text(entry.Grade); table.Cell().Element(BodyCell).AlignRight().Text(entry.Count.ToString()); table.Cell().Element(BodyCell).AlignRight().Text($"{entry.Percent:0.#} %"); table.Cell().Element(BodyCell).AlignMiddle().Element(bar => Bar(bar, maxCount == 0 ? 0 : entry.Count * 100.0 / maxCount, Colors.Blue.Medium)); } }); if (data.TaskAnalysis.Count > 0) { column.Item().Text("Aufgabenanalyse").FontSize(12).SemiBold(); column.Item().Table(table => { table.ColumnsDefinition(columns => { columns.RelativeColumn(2); columns.ConstantColumn(80); columns.RelativeColumn(); }); table.Header(header => { header.Cell().Element(HeaderCell).Text("Aufgabe").SemiBold(); header.Cell().Element(HeaderCell).AlignRight().Text("Ø Erfüllung").SemiBold(); header.Cell().Element(HeaderCell); }); foreach (var task in data.TaskAnalysis) { table.Cell().Element(BodyCell).Text(text => { text.Span(task.Label); if (task.IsWeak) text.Span(" auffällig schwach") .FontColor(Colors.Red.Darken1).FontSize(8); }); table.Cell().Element(BodyCell).AlignRight().Text($"{task.AvgPercent:0.#} %"); table.Cell().Element(BodyCell).AlignMiddle().Element(bar => Bar(bar, task.AvgPercent, task.IsWeak ? Colors.Red.Medium : Colors.Green.Medium)); } }); } }); page.Footer().Element(Footer); })).GeneratePdf(); public byte[] BuildCompetencyReportPdf(CompetencyReportPrintData data) => Document.Create(container => container.Page(page => { page.Size(PageSizes.A4.Landscape()); page.Margin(36); page.DefaultTextStyle(style => style.FontSize(8)); page.Header().Element(header => Header(header, $"Kompetenzbericht · {data.StudentName}", $"{data.GroupLabel} · {data.Summary}")); page.Content().PaddingVertical(10).Column(column => { column.Spacing(8); column.Item().Text(data.ThresholdLabel).FontSize(8).FontColor(Colors.Grey.Darken1); column.Item().Table(table => { table.ColumnsDefinition(columns => { columns.RelativeColumn(1.4f); // Bereich columns.RelativeColumn(0.5f); // Code columns.RelativeColumn(2.3f); // Beschreibung columns.RelativeColumn(1.1f); // Unterricht columns.RelativeColumn(1.0f); // Klausuren columns.RelativeColumn(1.4f); // Gruppe columns.RelativeColumn(1.1f); // Schüler // "Wiederholungsbedarf" (längster Wert) muss ohne Wortumbruch passen. columns.ConstantColumn(88); // Empfehlung }); table.Header(header => { header.Cell().Element(HeaderCell).Text("Bereich").SemiBold(); header.Cell().Element(HeaderCell).Text("Code").SemiBold(); header.Cell().Element(HeaderCell).Text("Kompetenz").SemiBold(); header.Cell().Element(HeaderCell).Text("Unterricht").SemiBold(); header.Cell().Element(HeaderCell).Text("Klausuren").SemiBold(); header.Cell().Element(HeaderCell).Text("Gruppenergebnis").SemiBold(); header.Cell().Element(HeaderCell).Text("Schüler").SemiBold(); header.Cell().Element(HeaderCell).Text("Empfehlung").SemiBold(); }); foreach (var row in data.Rows) { table.Cell().Element(BodyCell).Text(row.Domain); table.Cell().Element(BodyCell).Text(row.Code); table.Cell().Element(BodyCell).Text(row.Description); table.Cell().Element(BodyCell).Text(row.InstructionCoverage); table.Cell().Element(BodyCell).Text(row.ExamCoverage); table.Cell().Element(BodyCell).Text(row.GroupResult); table.Cell().Element(BodyCell).Text(row.StudentResult).SemiBold(); table.Cell().Element(BodyCell).Text(row.Recommendation) .FontColor(row.Recommendation == "Wiederholungsbedarf" ? Colors.Red.Darken1 : row.Recommendation == "Stand solide" ? Colors.Green.Darken1 : Colors.Grey.Darken1); } }); }); page.Footer().Element(Footer); })).GeneratePdf(); public byte[] BuildStudentDocumentationPdf(StudentDocumentationPrintData data) => Document.Create(container => container.Page(page => { page.Size(PageSizes.A4); page.Margin(36); page.DefaultTextStyle(style => style.FontSize(9)); page.Header().Element(header => Header(header, $"Dokumentation · {data.StudentName}", data.Entries.Count == 1 ? "1 Eintrag" : $"{data.Entries.Count} Einträge")); page.Content().PaddingVertical(10).Column(column => { column.Spacing(10); foreach (var entry in data.Entries) column.Item().Border(0.8f).BorderColor(Colors.Grey.Lighten2).Padding(8) .Column(block => { block.Spacing(3); block.Item().Row(row => { row.RelativeItem().Text(text => { text.Span(entry.Title).FontSize(10).SemiBold(); if (entry.IsConfidential) text.Span(" vertraulich").FontSize(8).FontColor(Colors.Red.Darken1); if (entry.IsDraft) text.Span(" Entwurf").FontSize(8).FontColor(Colors.Orange.Darken2); }); row.ConstantItem(130).AlignRight() .Text($"{entry.TypeLabel} · {entry.DateDisplay}") .FontSize(8).FontColor(Colors.Grey.Darken1); }); if (!string.IsNullOrWhiteSpace(entry.Content)) block.Item().Text(entry.Content); if (entry.Tags.Count > 0) block.Item().Text(string.Join(" · ", entry.Tags)) .FontSize(8).FontColor(Colors.Grey.Darken1); }); }); page.Footer().Element(Footer); })).GeneratePdf(); public byte[] BuildHazardAssessmentPdf(HazardAssessmentPrintData data) => Document.Create(container => container.Page(page => { page.Size(PageSizes.A4); page.Margin(36); page.DefaultTextStyle(style => style.FontSize(9)); page.Header().Element(header => Header(header, $"Gefährdungsbeurteilung · {data.Title}", $"{data.GroupLabel} · {data.KindLabel} · {data.DateDisplay}")); page.Content().PaddingVertical(10).Column(column => { column.Spacing(10); if (data.IsAiAssisted) column.Item().Element(AiDisclaimerBanner); if (!string.IsNullOrWhiteSpace(data.Procedure)) column.Item().Element(c => TextSection(c, "Durchführung", data.Procedure)); if (data.Substances.Count > 0) column.Item().Element(c => SubstanceBlocks(c, data.Substances)); if (data.Hazards.Count > 0) column.Item().Element(c => BulletSection(c, "Gefährdungen", data.Hazards)); if (data.ProtectiveMeasures.Count > 0) column.Item().Element(c => BulletSection(c, "Schutzmaßnahmen", data.ProtectiveMeasures)); if (!string.IsNullOrWhiteSpace(data.FirstAid)) column.Item().Element(c => TextSection(c, "Erste Hilfe", data.FirstAid)); if (!string.IsNullOrWhiteSpace(data.Disposal)) column.Item().Element(c => TextSection(c, "Entsorgung", data.Disposal)); if (!string.IsNullOrWhiteSpace(data.Notes)) column.Item().Element(c => TextSection(c, "Weitere Hinweise", data.Notes)); }); page.Footer().Element(Footer); })).GeneratePdf(); // ── Bausteine ─────────────────────────────────────────────────────────────── private static void AiDisclaimerBanner(IContainer container) => container.Background(Colors.Orange.Lighten4).Padding(8).Text( "⚠️ Enthält KI-generierte Anteile — kein rechtssicheres Dokument. Vor Verwendung " + "eigenverantwortlich prüfen, insbesondere H-/P-Sätze und Mengenangaben gegen das " + "Sicherheitsdatenblatt.").FontSize(9).FontColor(Colors.Orange.Darken3).SemiBold(); private static void TextSection(IContainer container, string title, string text) => container.Column(column => { column.Item().Text(title).FontSize(11).SemiBold(); column.Item().Text(text); }); private static void BulletSection(IContainer container, string title, IReadOnlyList items) => container.Column(column => { column.Item().Text(title).FontSize(11).SemiBold(); foreach (var item in items) column.Item().Text($"• {item}"); }); /// Eigener Block je Gefahrstoff statt einer engen Tabelle — bei sieben Datenpunkten pro Stoff /// (inkl. CAS, Signalwort, Altersbeschränkung) wäre eine Tabelle auf A4-Hochformat zu schmal /// für lesbare H-/P-Satz-Texte. private static void SubstanceBlocks(IContainer container, IReadOnlyList rows) => container.Column(column => { column.Spacing(6); column.Item().Text("Gefahrstoffe").FontSize(11).SemiBold(); foreach (var row in rows) column.Item().Element(c => SubstanceBlock(c, row)); }); private static void SubstanceBlock(IContainer container, HazardSubstancePrintRow row) => container.Border(0.8f).BorderColor(Colors.Grey.Lighten2).Padding(6).Column(block => { block.Spacing(2); block.Item().Row(header => { header.RelativeItem().Text(text => { text.Span(row.Name).SemiBold(); if (!string.IsNullOrWhiteSpace(row.Cas)) text.Span($" · CAS {row.Cas}").FontColor(Colors.Grey.Darken1); if (!string.IsNullOrWhiteSpace(row.Amount)) text.Span($" · {row.Amount}").FontColor(Colors.Grey.Darken1); }); if (!string.IsNullOrWhiteSpace(row.SignalWord)) header.ConstantItem(70).AlignRight().Text(row.SignalWord).SemiBold() .FontColor(Colors.Red.Darken2); }); if (!string.IsNullOrWhiteSpace(row.Pictograms)) block.Item().Text($"GHS: {row.Pictograms}").FontSize(8).FontColor(Colors.Grey.Darken1); if (!string.IsNullOrWhiteSpace(row.HStatements)) block.Item().Text($"H-Sätze: {row.HStatements}"); if (!string.IsNullOrWhiteSpace(row.PStatements)) block.Item().Text($"P-Sätze: {row.PStatements}"); if (!string.IsNullOrWhiteSpace(row.ActivityRestriction)) block.Item().Text($"Tätigkeitsbeschränkung: {row.ActivityRestriction}").SemiBold() .FontColor(Colors.Orange.Darken3); }); /// Horizontaler Balken, 0-100 % der verfügbaren Zellenbreite (max. 150pt). private static void Bar(IContainer container, double percent, string color) => container.MaxWidth(150).Height(7).Background(Colors.Grey.Lighten3) .AlignLeft().Width((float)(Math.Clamp(percent, 0, 100) * 1.5)).Background(color); private static void Header(IContainer container, string title, string subtitle) => container.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingBottom(6).Row(row => { row.RelativeItem().Column(column => { column.Item().Text(title).FontSize(16).SemiBold(); if (!string.IsNullOrWhiteSpace(subtitle)) column.Item().Text(subtitle).FontSize(10).FontColor(Colors.Grey.Darken1); }); row.ConstantItem(110).AlignRight().AlignBottom() .Text($"Stand: {DateTime.Now:dd.MM.yyyy}").FontSize(9).FontColor(Colors.Grey.Darken1); }); private static void Footer(IContainer container) => container.AlignCenter().Text(text => { text.DefaultTextStyle(style => style.FontSize(8).FontColor(Colors.Grey.Darken1)); text.Span("Seite "); text.CurrentPageNumber(); text.Span(" von "); text.TotalPages(); }); private static void BoardBanner(IContainer container) => container.AlignCenter().Background(Colors.Grey.Lighten3).PaddingVertical(4) .PaddingHorizontal(40).Text("Tafel / Vorderseite").FontSize(9).SemiBold() .FontColor(Colors.Grey.Darken2); private static void SeatGrid(IContainer container, SeatingPlanPrintData data) => container.Table(table => { // Tischabstände als eigene, schmale Leerspalten zwischen den Sitzspalten - dieselbe // Idee wie SeatingPlanPanel, nur mit Tabellenspalten statt Panel-Arithmetik. Merkt // sich je Sitzspalte den 1-basierten Tabellenspalten-Index. var tableColumnOfSeatColumn = new int[Math.Max(1, data.Columns)]; table.ColumnsDefinition(columns => { var next = 1; for (var c = 0; c < data.Columns; c++) { columns.RelativeColumn(); tableColumnOfSeatColumn[c] = next++; var gap = c < data.ColumnGapWidths.Count ? data.ColumnGapWidths[c] : 0; if (c < data.Columns - 1 && gap > 0) { columns.ConstantColumn((float)gap * GapPixelToPoint); next++; } } }); foreach (var seat in data.Seats) { var cell = table.Cell() .Row((uint)(seat.Row + 1)) .Column((uint)tableColumnOfSeatColumn[seat.Column]) .Padding(3); // Ausgeblendete Plätze (kein Tisch im Raum) bleiben als leere Rasterposition // erhalten, damit die übrigen Plätze der Reihe nicht verrutschen. if (seat.IsHidden) { cell.MinHeight(38); continue; } var box = cell.MinHeight(38).Border(0.8f) .BorderColor(seat.StudentName is null ? Colors.Grey.Lighten2 : Colors.Grey.Darken1) .Padding(4).AlignCenter().AlignMiddle(); if (seat.StudentName is null) box.Text("frei").FontSize(8).FontColor(Colors.Grey.Lighten1); else box.Text(seat.StudentName).FontSize(9).SemiBold(); } }); private static IContainer HeaderCell(IContainer container) => container .BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingVertical(3).PaddingHorizontal(2); private static IContainer BodyCell(IContainer container) => container .BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).PaddingVertical(2.5f).PaddingHorizontal(2); }