using SkiaSharp; namespace Jiaowu.Api.Infrastructure.Timetables; public static class TimetablePdfExporter { private const float PageWidth = 842; private const float PageHeight = 595; private const float Margin = 24; private static readonly SKColor Ink = new(27, 53, 74); private static readonly SKColor Muted = new(92, 111, 126); private static readonly SKColor Accent = new(32, 105, 99); private static readonly SKColor Rule = new(206, 220, 226); private static readonly SKColor Pale = new(241, 247, 247); public static byte[] Create(TimetableData timetable) => Create([timetable]); public static byte[] Create(IReadOnlyCollection timetables) { if (timetables.Count == 0) throw new ArgumentException("至少需要一张课表。", nameof(timetables)); using var typeface = ResolveTypeface(); using var stream = new MemoryStream(); using var document = SKDocument.CreatePdf(stream); var page = 0; foreach (var timetable in timetables) { var periods = Periods(timetable); foreach (var periodPage in periods.Chunk(9)) DrawPage(document, typeface, timetable, periodPage, ++page); } document.Close(); return stream.ToArray(); } private static void DrawPage( SKDocument document, SKTypeface typeface, TimetableData timetable, IReadOnlyList periods, int page) { using var canvas = document.BeginPage(PageWidth, PageHeight); canvas.Clear(SKColors.White); using var frame = new SKPaint { Color = Accent, Style = SKPaintStyle.Stroke, StrokeWidth = 1.1f }; canvas.DrawRect(14, 14, PageWidth - 28, PageHeight - 28, frame); DrawText(canvas, typeface, timetable.Subject.Name, Margin, 53, 19, Ink, bold: true); DrawText(canvas, typeface, "课程表", PageWidth - Margin, 53, 11, Accent, SKTextAlign.Right, true); DrawText(canvas, typeface, $"{timetable.Subject.Code} · {timetable.Term.Name}" + (timetable.Plan is null ? "" : $" · {timetable.Plan.Version} · {PlanStatus(timetable.Plan.Status)}"), Margin, 74, 9, Muted); DrawText(canvas, typeface, $"导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}", PageWidth - Margin, 74, 8, Muted, SKTextAlign.Right); using var titleRule = new SKPaint { Color = Rule, StrokeWidth = .8f }; canvas.DrawLine(Margin, 86, PageWidth - Margin, 86, titleRule); var widths = new[] { 84f, 101f, 101f, 101f, 101f, 101f, 101f, 101f }; var headers = new[] { "节次 / 时间", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日" }; const float headerHeight = 24; const float rowHeight = 46; var y = 103f; using var header = new SKPaint { Color = Accent, Style = SKPaintStyle.Fill }; using var grid = new SKPaint { Color = Rule, Style = SKPaintStyle.Stroke, StrokeWidth = .55f }; canvas.DrawRect(Margin, y, widths.Sum(), headerHeight, header); var x = Margin; for (var column = 0; column < headers.Length; column++) { DrawText(canvas, typeface, headers[column], x + widths[column] / 2, y + 16, 8.5f, SKColors.White, SKTextAlign.Center, true); x += widths[column]; } var entries = timetable.Entries.Concat(timetable.ExamEntries).Concat(timetable.ExperimentEntries).ToArray(); for (var rowIndex = 0; rowIndex < periods.Count; rowIndex++) { var period = periods[rowIndex]; var rowY = y + headerHeight + rowIndex * rowHeight; using var leftFill = new SKPaint { Color = Pale, Style = SKPaintStyle.Fill }; canvas.DrawRect(Margin, rowY, widths[0], rowHeight, leftFill); var slot = timetable.Slots.FirstOrDefault(item => item.PeriodNumber == period); DrawText(canvas, typeface, $"第 {period} 节", Margin + widths[0] / 2, rowY + 18, 8.5f, Ink, SKTextAlign.Center, true); if (slot is not null) DrawText(canvas, typeface, $"{slot.StartsAt:HH\\:mm}-{slot.EndsAt:HH\\:mm}", Margin + widths[0] / 2, rowY + 32, 7, Muted, SKTextAlign.Center); x = Margin + widths[0]; for (var day = 1; day <= 7; day++) { var cellEntries = entries.Where(item => item.DayOfWeek == day && item.StartPeriod <= period && item.StartPeriod + item.PeriodCount - 1 >= period).ToArray(); if (cellEntries.Length > 0) { using var fill = new SKPaint { Color = cellEntries.Any(item => item.IsExam) ? new SKColor(255, 243, 230) : cellEntries.Any(item => item.IsExperiment) ? new SKColor(232, 246, 241) : new SKColor(234, 245, 244), Style = SKPaintStyle.Fill }; canvas.DrawRect(x, rowY, widths[day], rowHeight, fill); DrawCellText(canvas, typeface, cellEntries, x, rowY, widths[day], rowHeight); } x += widths[day]; } } var tableHeight = headerHeight + periods.Count * rowHeight; canvas.DrawRect(Margin, y, widths.Sum(), tableHeight, grid); x = Margin; for (var column = 0; column < widths.Length - 1; column++) { x += widths[column]; canvas.DrawLine(x, y, x, y + tableHeight, grid); } for (var row = 0; row < periods.Count; row++) canvas.DrawLine(Margin, y + headerHeight + row * rowHeight, Margin + widths.Sum(), y + headerHeight + row * rowHeight, grid); var footerY = PageHeight - 31; canvas.DrawLine(Margin, footerY - 12, PageWidth - Margin, footerY - 12, titleRule); DrawText(canvas, typeface, "明序教务 · 课表导出", Margin, footerY, 7.5f, Muted); DrawText(canvas, typeface, $"第 {page} 页", PageWidth - Margin, footerY, 7.5f, Muted, SKTextAlign.Right); document.EndPage(); } private static void DrawCellText( SKCanvas canvas, SKTypeface typeface, IReadOnlyList entries, float x, float y, float width, float height) { var text = string.Join("\n", entries.Select(EntryText)); var lines = WrapText(typeface, text, 6.6f, width - 8).Take(5).ToArray(); for (var index = 0; index < lines.Length; index++) DrawText(canvas, typeface, lines[index], x + 4, y + 10 + index * 8, 6.6f, Ink); } private static IReadOnlyList Periods(TimetableData timetable) { var entries = timetable.Entries.Concat(timetable.ExamEntries).Concat(timetable.ExperimentEntries); var periods = timetable.Slots.Select(item => item.PeriodNumber) .Concat(entries.SelectMany(item => Enumerable.Range(item.StartPeriod, item.PeriodCount))) .Distinct().Order().ToArray(); return periods.Length == 0 ? Enumerable.Range(1, 8).ToArray() : periods; } private static string EntryText(TimetableEntryDto entry) => $"{(entry.IsExam ? "【考试】" : entry.IsExperiment ? "【实验】" : "")}{entry.CourseName} · {string.Join('、', entry.TeacherNames)}" + $" · {string.Join(" ", new[] { entry.BuildingName, entry.ClassroomName }.Where(item => !string.IsNullOrWhiteSpace(item)))}"; private static IReadOnlyList WrapText(SKTypeface typeface, string text, float size, float width) { using var font = new SKFont(typeface, size); var lines = new List(); foreach (var paragraph in text.Split('\n')) { var current = ""; foreach (var character in paragraph) { var candidate = current + character; if (current.Length > 0 && font.MeasureText(candidate) > width) { lines.Add(current); current = character.ToString(); } else current = candidate; } if (current.Length > 0) lines.Add(current); } return lines; } private static void DrawText(SKCanvas canvas, SKTypeface typeface, string text, float x, float y, float size, SKColor color, SKTextAlign align = SKTextAlign.Left, bool bold = false) { using var font = new SKFont(typeface, size) { Embolden = bold }; using var paint = new SKPaint { Color = color, IsAntialias = true }; canvas.DrawText(text, x, y, align, font, paint); } private static SKTypeface ResolveTypeface() { var candidates = new[] { Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "simhei.ttf"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "msyh.ttc"), "/usr/share/fonts/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc" }; foreach (var path in candidates) { if (File.Exists(path) && SKTypeface.FromFile(path) is { } typeface) return typeface; } return SKTypeface.FromFamilyName("Microsoft YaHei") ?? SKTypeface.FromFamilyName("Noto Sans CJK SC") ?? throw new InvalidOperationException("未找到可用于课表 PDF 的中文字体。"); } private static string PlanStatus(Domain.Academic.SchedulePlanStatus status) => status switch { Domain.Academic.SchedulePlanStatus.Draft => "草稿", Domain.Academic.SchedulePlanStatus.Published => "已发布", _ => "已归档" }; }