using System; using System.Collections.Generic; using System.Linq; using PdfSharp; using PdfSharp.Drawing; using PdfSharp.Pdf; namespace spplus; public static class PdfExportUtility { private const string FontFamily = "Cantarell"; private const double Margin = 36.0; private const double FooterHeight = 28.0; private static readonly XBrush FooterBrush = XBrushes.Gray; public static void ExportGeneratedCourses(string filepath) { var document = new PdfDocument { Info = { Title = "spplus - generierte Kurse", Author = "spplus", Creator = "spplus" } }; var courses = CourseCrafter.GeneratedCourses .OrderBy(course => course.Semester) .ThenBy(course => course.Instance.Sport.Name) .ToList(); if (courses.Count == 0) { RenderEmptyDocument(document); document.Save(filepath); return; } RenderCoursesOverview(document, courses); var studentsById = Settings.Instance.Students .GroupBy(student => student.ID, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); foreach (var course in courses) { RenderCourse(document, course, studentsById); } RenderMissingStudentsReport(document, studentsById, courses); document.Save(filepath); } private static void RenderMissingStudentsReport( PdfDocument document, IReadOnlyDictionary studentsById, IReadOnlyList<(int Semester, CourseCrafter.CourseInstance Instance)> courses) { var assignedSemesters = studentsById.Values .ToDictionary(student => student.ID, _ => new bool[4], StringComparer.OrdinalIgnoreCase); foreach (var course in courses) { if (course.Semester < 1 || course.Semester > 4) continue; foreach (var studentId in course.Instance.Students) { if (assignedSemesters.TryGetValue(studentId, out var assignment)) assignment[course.Semester - 1] = true; } } var missingStudents = studentsById.Values .Where(student => assignedSemesters.TryGetValue(student.ID, out var assignment) && assignment.Any(a => !a)) .OrderBy(student => student.Name) .ThenBy(student => student.ID) .ToList(); var columns = new double[] { 30.0, 90.0, 190.0, 40.0, 40.0, 40.0, 40.0 }; var headerRow = new[] { "Nr.", "ID", "Name", "Sem1", "Sem2", "Sem3", "Sem4" }; int rowIndex = 0; int pageIndex = 0; while (rowIndex < missingStudents.Count || pageIndex == 0) { var page = document.AddPage(); ConfigurePage(page); using var gfx = XGraphics.FromPdfPage(page); var titleFont = new XFont(FontFamily, 18, XFontStyleEx.Bold); var subtitleFont = new XFont(FontFamily, 10, XFontStyleEx.Regular); var tableHeaderFont = new XFont(FontFamily, 9, XFontStyleEx.Bold); var tableBodyFont = new XFont(FontFamily, 8.5, XFontStyleEx.Regular); double pageWidth = page.Width.Point; double pageHeight = page.Height.Point; double contentWidth = pageWidth - (Margin * 2); double contentBottom = pageHeight - Margin - FooterHeight; double y = Margin; var headerTitle = pageIndex == 0 ? "Nicht zugeordnete Schülerinnen und Schüler" : "Fortsetzung: Nicht zugeordnete Schülerinnen und Schüler"; gfx.DrawString(headerTitle, titleFont, XBrushes.Black, new XRect(Margin, y, contentWidth, 24), XStringFormats.TopLeft); y += 26; if (pageIndex == 0) { gfx.DrawString("Es werden nur Schülerinnen und Schüler aufgeführt, die in mindestens einem Semester keine Zuordnung erhalten haben.", subtitleFont, XBrushes.Gray, new XRect(Margin, y, contentWidth, 20), XStringFormats.TopLeft); y += 24; } if (missingStudents.Count == 0) { var bodyFont = new XFont(FontFamily, 11, XFontStyleEx.Regular); gfx.DrawString("Alle Schülerinnen und Schüler sind in allen vier Semestern zugeordnet.", bodyFont, XBrushes.Black, new XRect(Margin, y, contentWidth, 20), XStringFormats.TopLeft); DrawFooter(gfx, page); break; } y += DrawTableRow(gfx, y, columns, headerRow, tableHeaderFont, fillHeader: true); while (rowIndex < missingStudents.Count) { var student = missingStudents[rowIndex]; var assignment = assignedSemesters[student.ID]; var row = BuildMissingStudentRow(rowIndex + 1, student, assignment); double rowHeight = MeasureRowHeight(gfx, columns, row, tableBodyFont); if (y + rowHeight > contentBottom) break; y += DrawTableRow(gfx, y, columns, row, tableBodyFont, fillHeader: false); rowIndex++; } DrawFooter(gfx, page); pageIndex++; if (rowIndex >= missingStudents.Count) break; } } private static string[] BuildMissingStudentRow(int rowNumber, Student student, bool[] assignment) { return new[] { rowNumber.ToString(), student.ID, student.Name, assignment[0] ? string.Empty : "X", assignment[1] ? string.Empty : "X", assignment[2] ? string.Empty : "X", assignment[3] ? string.Empty : "X", }; } private static void RenderEmptyDocument(PdfDocument document) { var page = document.AddPage(); ConfigurePage(page); using var gfx = XGraphics.FromPdfPage(page); var titleFont = new XFont(FontFamily, 18, XFontStyleEx.Bold); var bodyFont = new XFont(FontFamily, 11, XFontStyleEx.Regular); gfx.DrawString("spplus", titleFont, XBrushes.Black, new XRect(Margin, Margin, page.Width.Point - Margin * 2, 24), XStringFormats.TopLeft); gfx.DrawString("Es wurden keine generierten Kurse gefunden.", bodyFont, XBrushes.Black, new XRect(Margin, Margin + 34, page.Width.Point - Margin * 2, 24), XStringFormats.TopLeft); DrawFooter(gfx, page); } private static void RenderCoursesOverview( PdfDocument document, IReadOnlyList<(int Semester, CourseCrafter.CourseInstance Instance)> courses) { var overviewFont = new XFont(FontFamily, 18, XFontStyleEx.Bold); var subtitleFont = new XFont(FontFamily, 10, XFontStyleEx.Regular); var tableHeaderFont = new XFont(FontFamily, 9, XFontStyleEx.Bold); var tableBodyFont = new XFont(FontFamily, 8.5, XFontStyleEx.Regular); int count = 0; var coursesPerSemester = courses .GroupBy(course => course.Semester) .ToDictionary(group => group.Key, group => group.Count()); var courseIndexBySemester = new Dictionary(); var rows = courses.Select(course => { count++; var key = course.Semester; courseIndexBySemester.TryGetValue(key, out var index); index++; courseIndexBySemester[key] = index; return new[] { count.ToString(), course.Semester.ToString(), course.Instance.Sport.Name, $"{index}/{coursesPerSemester[key]}", course.Instance.Students.Count.ToString() }; }).ToList(); var columns = new[] { 50.0, 70.0, 150.0, 150.0, 100.0 }; var headerRow = new[] { "Nr.", "Semester", "Sportart", "Kurs (Semester)", "Anzahl SuS" }; int rowIndex = 0; int pageIndex = 0; while (rowIndex < rows.Count || pageIndex == 0) { var page = document.AddPage(); ConfigurePage(page); using var gfx = XGraphics.FromPdfPage(page); double pageWidth = page.Width.Point; double pageHeight = page.Height.Point; double contentWidth = pageWidth - (Margin * 2); double contentBottom = pageHeight - Margin - FooterHeight; double y = Margin; var headerTitle = pageIndex == 0 ? "Übersicht generierter Kurse" : "Fortsetzung: Übersicht generierter Kurse"; gfx.DrawString(headerTitle, overviewFont, XBrushes.Black, new XRect(Margin, y, contentWidth, 24), XStringFormats.TopLeft); y += 26; if (pageIndex == 0) { gfx.DrawString("Tabellarische Übersicht aller generierten Kurse nach Semester und Sportart.", subtitleFont, XBrushes.Gray, new XRect(Margin, y, contentWidth, 20), XStringFormats.TopLeft); y += 24; } y += DrawTableRow(gfx, y, columns, headerRow, tableHeaderFont, fillHeader: true); while (rowIndex < rows.Count) { var row = rows[rowIndex]; double rowHeight = MeasureRowHeight(gfx, columns, row, tableBodyFont); if (y + rowHeight > contentBottom) break; y += DrawTableRow(gfx, y, columns, row, tableBodyFont, fillHeader: false); rowIndex++; } DrawFooter(gfx, page); pageIndex++; if (rowIndex >= rows.Count) break; } } private static void RenderCourse( PdfDocument document, (int Semester, CourseCrafter.CourseInstance Instance) course, IReadOnlyDictionary studentsById) { var studentIds = course.Instance.Students.ToList(); int index = 0; int pageIndex = 0; do { var page = document.AddPage(); ConfigurePage(page); using var gfx = XGraphics.FromPdfPage(page); var titleFont = new XFont(FontFamily, 18, XFontStyleEx.Bold); var subtitleFont = new XFont(FontFamily, 10, XFontStyleEx.Regular); var labelFont = new XFont(FontFamily, 9, XFontStyleEx.Bold); var valueFont = new XFont(FontFamily, 9, XFontStyleEx.Regular); var tableHeaderFont = new XFont(FontFamily, 9, XFontStyleEx.Bold); var tableBodyFont = new XFont(FontFamily, 8.5, XFontStyleEx.Regular); double pageWidth = page.Width.Point; double pageHeight = page.Height.Point; double contentWidth = pageWidth - (Margin * 2); double contentBottom = pageHeight - Margin - FooterHeight; double y = Margin; var headerTitle = $"Sem. {course.Semester} - {course.Instance.Sport.Name}"; if (pageIndex > 0) headerTitle += " (Fortsetzung)"; gfx.DrawString(headerTitle, titleFont, XBrushes.Black, new XRect(Margin, y, contentWidth, 24), XStringFormats.TopLeft); y += 26; gfx.DrawString($"{course.Instance.Students.Count} Schülerinnen und Schüler", subtitleFont, XBrushes.Gray, new XRect(Margin, y, contentWidth, 18), XStringFormats.TopLeft); y += 24; y = DrawInfoRows(gfx, y, contentWidth, labelFont, valueFont, course); y += 10; var columns = GetTableColumns(contentWidth); var headerRow = new[] { "Nr.", "ID", "Name", }; y += DrawTableRow(gfx, y, columns, headerRow, tableHeaderFont, fillHeader: true); if (studentIds.Count == 0) { var emptyFont = new XFont(FontFamily, 9, XFontStyleEx.Italic); gfx.DrawString("Keine Schülerinnen und Schüler zugeordnet.", emptyFont, XBrushes.Gray, new XRect(Margin + 6, y + 8, contentWidth - 12, 20), XStringFormats.TopLeft); } else { while (index < studentIds.Count) { if (!studentsById.TryGetValue(studentIds[index], out var student)) student = new Student { ID = studentIds[index] }; var row = BuildStudentRow(index + 1, student); double rowHeight = MeasureRowHeight(gfx, columns, row, tableBodyFont); if (y + rowHeight > contentBottom) break; y += DrawTableRow(gfx, y, columns, row, tableBodyFont, fillHeader: false); index++; } } DrawFooter(gfx, page); pageIndex++; } while (index < studentIds.Count); } private static double DrawInfoRows( XGraphics gfx, double startY, double contentWidth, XFont labelFont, XFont valueFont, (int Semester, CourseCrafter.CourseInstance Instance) course) { double y = startY; double labelWidth = 160.0; double valueWidth = contentWidth - labelWidth; double rowPadding = 4.0; var rows = new List<(string Label, string Value)> { ("Kurs-ID", course.Instance.Sport.ID.ToString()), ("Kursname", course.Instance.Sport.Name), ("Semester", course.Semester.ToString()), ("Anzahl SuS", course.Instance.Students.Count.ToString()), ("Min / Max", $"{course.Instance.Sport.MinStudents} / {course.Instance.Sport.MaxStudents}"), ("Semesterangebote", string.Join(", ", course.Instance.Sport.Semester.Select((count, idx) => $"S{idx + 1}:{count}"))), ("Alternativnamen", course.Instance.Sport.AlternativeNames.Count == 0 ? "-" : string.Join(", ", course.Instance.Sport.AlternativeNames)) }; foreach (var row in rows) { double rowHeight = MeasureInfoRowHeight(gfx, labelFont, valueFont, labelWidth, valueWidth, row.Label, row.Value); gfx.DrawRectangle(XPens.LightGray, Margin, y, labelWidth, rowHeight); gfx.DrawRectangle(XPens.LightGray, Margin + labelWidth, y, valueWidth, rowHeight); DrawWrappedText(gfx, row.Label, labelFont, XBrushes.Black, Margin + 4, y + rowPadding, labelWidth - 8); DrawWrappedText(gfx, row.Value, valueFont, XBrushes.Black, Margin + labelWidth + 4, y + rowPadding, valueWidth - 8); y += rowHeight; } return y; } private static double MeasureInfoRowHeight( XGraphics gfx, XFont labelFont, XFont valueFont, double labelWidth, double valueWidth, string label, string value) { double labelHeight = MeasureWrappedTextHeight(gfx, label, labelFont, labelWidth - 8); double valueHeight = MeasureWrappedTextHeight(gfx, value, valueFont, valueWidth - 8); return Math.Max(labelHeight, valueHeight) + 8.0; } private static string[] BuildStudentRow(int rowNumber, Student student) { return new[] { rowNumber.ToString(), student.ID, student.Name, }; } private static string GetCourseName(Student student, int index) { if (index < 0 || index >= student.SelectedCourseNames.Count) return string.Empty; return student.SelectedCourseNames[index]; } private static double[] GetTableColumns(double contentWidth) { return new[] { 30.0, 75.0, 145.0 }; } private static double DrawTableRow( XGraphics gfx, double startY, double[] columns, IReadOnlyList values, XFont font, bool fillHeader) { const double padding = 4.0; double rowHeight = MeasureRowHeight(gfx, columns, values, font); double x = Margin; for (int i = 0; i < columns.Length; i++) { double width = columns[i]; var rect = new XRect(x, startY, width, rowHeight); gfx.DrawRectangle(XPens.LightGray, rect); if (fillHeader) gfx.DrawRectangle(new XSolidBrush(XColor.FromArgb(235, 235, 235)), rect); gfx.DrawRectangle(XPens.LightGray, rect); var lines = WrapText(gfx, values[i], font, width - (padding * 2)); double lineHeight = font.GetHeight(); double textY = startY + padding; foreach (var line in lines) { gfx.DrawString(line, font, XBrushes.Black, new XRect(x + padding, textY, width - (padding * 2), lineHeight), XStringFormats.TopLeft); textY += lineHeight; } x += width; } return rowHeight; } private static double MeasureRowHeight( XGraphics gfx, double[] columns, IReadOnlyList values, XFont font) { const double padding = 4.0; double maxHeight = font.GetHeight() + (padding * 2); for (int i = 0; i < columns.Length; i++) { double height = MeasureWrappedTextHeight(gfx, values[i], font, columns[i] - (padding * 2)) + (padding * 2); if (height > maxHeight) maxHeight = height; } return maxHeight; } private static double MeasureWrappedTextHeight(XGraphics gfx, string text, XFont font, double width) { var lines = WrapText(gfx, text, font, width); return lines.Count * font.GetHeight(); } private static List WrapText(XGraphics gfx, string? text, XFont font, double maxWidth) { var result = new List(); if (string.IsNullOrWhiteSpace(text)) { result.Add(string.Empty); return result; } foreach (var paragraph in text.Replace("\r", string.Empty).Split('\n')) { if (string.IsNullOrWhiteSpace(paragraph)) { result.Add(string.Empty); continue; } string current = string.Empty; foreach (var token in paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries)) { foreach (var wordPart in SplitWordIfNeeded(gfx, token, font, maxWidth)) { var candidate = string.IsNullOrEmpty(current) ? wordPart : $"{current} {wordPart}"; if (gfx.MeasureString(candidate, font).Width <= maxWidth) { current = candidate; } else { if (!string.IsNullOrEmpty(current)) result.Add(current); current = wordPart; } } } if (!string.IsNullOrEmpty(current)) result.Add(current); } return result.Count == 0 ? new List { string.Empty } : result; } private static IEnumerable SplitWordIfNeeded(XGraphics gfx, string word, XFont font, double maxWidth) { if (gfx.MeasureString(word, font).Width <= maxWidth) { yield return word; yield break; } string chunk = string.Empty; foreach (char c in word) { string candidate = chunk + c; if (chunk.Length > 0 && gfx.MeasureString(candidate, font).Width > maxWidth) { yield return chunk; chunk = c.ToString(); } else { chunk = candidate; } } if (!string.IsNullOrEmpty(chunk)) yield return chunk; } private static void DrawWrappedText( XGraphics gfx, string? text, XFont font, XBrush brush, double x, double y, double width) { double lineHeight = font.GetHeight(); foreach (var line in WrapText(gfx, text, font, width)) { gfx.DrawString(line, font, brush, new XRect(x, y, width, lineHeight), XStringFormats.TopLeft); y += lineHeight; } } private static void DrawFooter(XGraphics gfx, PdfPage page) { var footerFont = new XFont(FontFamily, 7, XFontStyleEx.Regular); double width = page.Width.Point - (Margin * 2); double x = Margin; double baseY = page.Height.Point - Margin + 2; gfx.DrawString("generated by spplus", footerFont, FooterBrush, new XRect(x, baseY, width, 9), XStringFormats.Center); gfx.DrawString("(c) 2026 MyPapertown", footerFont, FooterBrush, new XRect(x, baseY + 8, width, 9), XStringFormats.Center); gfx.DrawString("www.mypapercloud.de/spplus", footerFont, FooterBrush, new XRect(x, baseY + 16, width, 9), XStringFormats.Center); } private static void ConfigurePage(PdfPage page) { page.Size = PageSize.A4; page.Orientation = PageOrientation.Portrait; } }