From a784e598de5fb46a1be50e650df4e3c529b9e179 Mon Sep 17 00:00:00 2001 From: Elias Fierke Date: Thu, 4 Jun 2026 19:12:52 +0200 Subject: [PATCH] [chore:] forgot to 'git add' these... --- CustomFontResolver.cs | 77 +++++++ PdfExportUtility.cs | 521 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 598 insertions(+) create mode 100644 CustomFontResolver.cs create mode 100644 PdfExportUtility.cs diff --git a/CustomFontResolver.cs b/CustomFontResolver.cs new file mode 100644 index 0000000..5a94a64 --- /dev/null +++ b/CustomFontResolver.cs @@ -0,0 +1,77 @@ +using System; +using System.IO; +using PdfSharp.Drawing; +using PdfSharp.Fonts; + +namespace spplus; + +/// +/// Custom font resolver for PdfSharp that loads fonts from the res/fonts directory. +/// +public class CustomFontResolver : IFontResolver +{ + private static string? _fontPath; + + public CustomFontResolver() + { + // Try to locate the fonts directory + var basePath = AppDomain.CurrentDomain.BaseDirectory; + var possiblePaths = new[] + { + Path.Combine(basePath, "res", "fonts"), + Path.Combine(basePath, "fonts"), + Path.Combine(Directory.GetCurrentDirectory(), "res", "fonts"), + Path.Combine(Directory.GetCurrentDirectory(), "fonts"), + }; + + foreach (var path in possiblePaths) + { + if (Directory.Exists(path)) + { + _fontPath = path; + break; + } + } + + if (_fontPath == null) + { + throw new DirectoryNotFoundException( + $"Font directory not found. Searched: {string.Join(", ", possiblePaths)}"); + } + } + + public FontResolverInfo ResolveTypeface(string familyName, bool isBold, bool isItalic) + { + // Map family name to font file + var fileName = familyName switch + { + "Cantarell" => isBold + ? isItalic ? "Cantarell-BoldItalic.ttf" : "Cantarell-Bold.ttf" + : isItalic ? "Cantarell-Italic.ttf" : "Cantarell-Regular.ttf", + _ => isBold + ? isItalic ? "Cantarell-BoldItalic.ttf" : "Cantarell-Bold.ttf" + : isItalic ? "Cantarell-Italic.ttf" : "Cantarell-Regular.ttf" + }; + + var fontPath = Path.Combine(_fontPath, fileName); + + if (!File.Exists(fontPath)) + { + throw new FileNotFoundException($"Font file not found: {fontPath}"); + } + + // Return a FontResolverInfo with the font path + return new FontResolverInfo(fontPath); + } + + public byte[]? GetFont(string faceName) + { + // faceName is the path returned by ResolveTypeface + if (File.Exists(faceName)) + { + return File.ReadAllBytes(faceName); + } + + return null; + } +} diff --git a/PdfExportUtility.cs b/PdfExportUtility.cs new file mode 100644 index 0000000..4c22356 --- /dev/null +++ b/PdfExportUtility.cs @@ -0,0 +1,521 @@ +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; + } + + 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 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; + } +}