From e5afe60a1166bbdc5b6d40341bdda37bb160dbb8 Mon Sep 17 00:00:00 2001 From: biss Date: Tue, 11 Aug 2026 21:14:12 +0800 Subject: [PATCH] v2.4.1 --- .../TimetableManagementController.cs | 8 +- .../Controllers/TimetablesController.cs | 6 +- .../OfficialDocumentPdfGenerator.cs | 26 +-- .../Infrastructure/Pdf/PdfTypefaceResolver.cs | 37 ++++ .../Timetables/TimetableExcelExporter.cs | 74 +++++++- .../Timetables/TimetablePdfExporter.cs | 158 ++++++++++++++---- .../TimetableExcelExporterTests.cs | 6 +- versions.props | 6 +- 8 files changed, 256 insertions(+), 65 deletions(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Pdf/PdfTypefaceResolver.cs diff --git a/src/Jiaowu.Api/Controllers/TimetableManagementController.cs b/src/Jiaowu.Api/Controllers/TimetableManagementController.cs index f423c1d..5784bf1 100644 --- a/src/Jiaowu.Api/Controllers/TimetableManagementController.cs +++ b/src/Jiaowu.Api/Controllers/TimetableManagementController.cs @@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.OfficialDocuments; using Jiaowu.Api.Infrastructure.Timetables; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -17,7 +18,8 @@ namespace Jiaowu.Api.Controllers; public sealed class TimetableManagementController( AppDbContext db, TimetableDataService timetableDataService, - ICurrentUserDataScope currentUserDataScope) : ControllerBase + ICurrentUserDataScope currentUserDataScope, + OfficialDocumentOptions officialDocumentOptions) : ControllerBase { private const string ManagementRoles = SystemRoles.SuperAdmin + "," + @@ -239,7 +241,7 @@ public sealed class TimetableManagementController( } var fileName = $"课表批量导出-{FileName(timetables[0].Term.Name)}.pdf"; - return File(TimetablePdfExporter.Create(timetables), "application/pdf", fileName); + return File(TimetablePdfExporter.Create(timetables, officialDocumentOptions.FontPath), "application/pdf", fileName); } [HttpPost("display-links/export.xlsx")] @@ -297,7 +299,7 @@ public sealed class TimetableManagementController( private ActionResult PdfFile(TimetableData timetable) => File( - TimetablePdfExporter.Create(timetable), + TimetablePdfExporter.Create(timetable, officialDocumentOptions.FontPath), "application/pdf", $"{FileName(timetable.Subject.Name)}-{FileName(timetable.Term.Name)}-课表.pdf"); } diff --git a/src/Jiaowu.Api/Controllers/TimetablesController.cs b/src/Jiaowu.Api/Controllers/TimetablesController.cs index 1e8360a..bc0e256 100644 --- a/src/Jiaowu.Api/Controllers/TimetablesController.cs +++ b/src/Jiaowu.Api/Controllers/TimetablesController.cs @@ -5,6 +5,7 @@ using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.OfficialDocuments; using Jiaowu.Api.Infrastructure.Timetables; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -18,7 +19,8 @@ public sealed class TimetablesController( AppDbContext db, TimetableDataService timetableDataService, PersonalCalendarService personalCalendarService, - IAppCache cache) : ControllerBase + IAppCache cache, + OfficialDocumentOptions officialDocumentOptions) : ControllerBase { [HttpGet("options")] [AllowAnonymous] @@ -517,7 +519,7 @@ public sealed class TimetablesController( { var fileName = $"{SafeFileName(result.Subject.Name)}-{SafeFileName(result.Term.Name)}-课表.pdf"; - return File(TimetablePdfExporter.Create(result), "application/pdf", fileName); + return File(TimetablePdfExporter.Create(result, officialDocumentOptions.FontPath), "application/pdf", fileName); } private static string SafeFileName(string value) diff --git a/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentPdfGenerator.cs b/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentPdfGenerator.cs index 030e9b7..56164ec 100644 --- a/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentPdfGenerator.cs +++ b/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentPdfGenerator.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using QRCoder; using SkiaSharp; +using Jiaowu.Api.Infrastructure.Pdf; namespace Jiaowu.Api.Infrastructure.OfficialDocuments; @@ -28,7 +29,7 @@ public sealed class OfficialDocumentPdfGenerator( OfficialDocumentSnapshot snapshot, string verificationUrl) { - using var typeface = ResolveTypeface(); + using var typeface = PdfTypefaceResolver.ResolveChineseTypeface(options.FontPath); using var stream = new MemoryStream(); using (var document = SKDocument.CreatePdf(stream)) { @@ -382,28 +383,5 @@ public sealed class OfficialDocumentPdfGenerator( return value + "…"; } - private SKTypeface ResolveTypeface() - { - var candidates = new[] - { - options.FontPath, - 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.Where(x => !string.IsNullOrWhiteSpace(x))) - { - if (!File.Exists(path)) continue; - var typeface = SKTypeface.FromFile(path); - if (typeface is not null) return typeface; - } - - var fallback = SKTypeface.FromFamilyName("Microsoft YaHei") ?? - SKTypeface.FromFamilyName("Noto Sans CJK SC"); - return fallback ?? throw new InvalidOperationException( - "未找到可用于官方 PDF 的中文字体。请配置 OfficialDocuments:FontPath。"); - } - private static string FormatDecimal(decimal value) => value.ToString("0.##"); } diff --git a/src/Jiaowu.Api/Infrastructure/Pdf/PdfTypefaceResolver.cs b/src/Jiaowu.Api/Infrastructure/Pdf/PdfTypefaceResolver.cs new file mode 100644 index 0000000..7b4b2a5 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Pdf/PdfTypefaceResolver.cs @@ -0,0 +1,37 @@ +using SkiaSharp; + +namespace Jiaowu.Api.Infrastructure.Pdf; + +/// +/// Resolves the CJK typeface used by every server-generated PDF. +/// A configured font takes precedence so production exports do not silently +/// fall back to a font without Chinese glyphs. +/// +public static class PdfTypefaceResolver +{ + public static SKTypeface ResolveChineseTypeface(string? configuredFontPath) + { + if (!string.IsNullOrWhiteSpace(configuredFontPath)) + return Load(configuredFontPath) ?? throw new InvalidOperationException( + $"无法加载已配置的 PDF 中文字体:{configuredFontPath}。"); + + 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 candidate in candidates) + { + var typeface = Load(candidate); + if (typeface is not null) return typeface; + } + + throw new InvalidOperationException( + "未找到可用于 PDF 导出的中文字体。请配置 OfficialDocuments:FontPath。"); + } + + private static SKTypeface? Load(string path) => + File.Exists(path) ? SKTypeface.FromFile(path) : null; +} diff --git a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs index c072b6a..963b877 100644 --- a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs +++ b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs @@ -137,7 +137,6 @@ public static class TimetableExcelExporter .Font.SetFontColor(XLColor.FromHtml("#173F4C")); } } - sheet.Row(row).Height = 64; } var grid = sheet.Range( @@ -149,6 +148,8 @@ public static class TimetableExcelExporter grid.Style.Border.InsideBorderColor = XLColor.FromHtml("#D8E1E6"); grid.Style.Border.OutsideBorder = XLBorderStyleValues.Medium; grid.Style.Border.OutsideBorderColor = XLColor.FromHtml("#AABBC5"); + MergeContinuousCells(sheet, timedEntries, periods, headerRow); + SetRowHeights(sheet, periods.Length, headerRow, headers.Length); sheet.Column(1).Width = 16; for (var column = 2; column <= 8; column++) sheet.Column(column).Width = 24; sheet.SheetView.FreezeRows(headerRow); @@ -171,6 +172,9 @@ public static class TimetableExcelExporter sheet.Cell(row, 1).Style .Fill.SetBackgroundColor(XLColor.FromHtml("#FFF8EA")) .Alignment.SetWrapText(); + sheet.Row(row).Height = Math.Max(22, + 8 + sheet.Cell(row, 1).GetString() + .Split('\n', StringSplitOptions.None).Length * 15); row++; } } @@ -192,6 +196,74 @@ public static class TimetableExcelExporter : name + suffix; } + private static void MergeContinuousCells( + IXLWorksheet sheet, + IReadOnlyCollection entries, + IReadOnlyList periods, + int headerRow) + { + for (var day = 1; day <= 7; day++) + { + var start = 0; + while (start < periods.Count) + { + var currentEntries = EntriesAt(entries, day, periods[start]); + if (currentEntries.Length == 0) + { + start++; + continue; + } + + var end = start; + while (end + 1 < periods.Count && + periods[end + 1] == periods[end] + 1 && + SameEntries(currentEntries, EntriesAt(entries, day, periods[end + 1]))) + end++; + + if (end > start) + sheet.Range(headerRow + start + 1, day + 1, headerRow + end + 1, day + 1).Merge(); + start = end + 1; + } + } + } + + private static void SetRowHeights( + IXLWorksheet sheet, + int periodCount, + int headerRow, + int columnCount) + { + for (var index = 0; index < periodCount; index++) + { + var row = headerRow + index + 1; + var bodyLineCount = Enumerable.Range(2, columnCount - 1) + .Select(column => sheet.Cell(row, column).GetString()) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Split('\n', StringSplitOptions.None).Length) + .DefaultIfEmpty(0) + .Max(); + sheet.Row(row).Height = bodyLineCount == 0 + ? 42 + : Math.Max(52, 14 + bodyLineCount * 15); + } + } + + private static TimetableEntryDto[] EntriesAt( + IReadOnlyCollection entries, + int day, + int period) => + entries.Where(entry => + entry.DayOfWeek == day && + entry.StartPeriod <= period && + entry.StartPeriod + entry.PeriodCount - 1 >= period) + .OrderBy(entry => entry.Id) + .ToArray(); + + private static bool SameEntries( + IReadOnlyList left, + IReadOnlyList right) => + left.Count == right.Count && left.Select(entry => entry.Id).SequenceEqual(right.Select(entry => entry.Id)); + private static string EntryText(TimetableEntryDto entry) => entry.IsExperiment ? $"【实验】{entry.ExperimentProjectName}\n" + diff --git a/src/Jiaowu.Api/Infrastructure/Timetables/TimetablePdfExporter.cs b/src/Jiaowu.Api/Infrastructure/Timetables/TimetablePdfExporter.cs index df5f9f1..1ce0790 100644 --- a/src/Jiaowu.Api/Infrastructure/Timetables/TimetablePdfExporter.cs +++ b/src/Jiaowu.Api/Infrastructure/Timetables/TimetablePdfExporter.cs @@ -1,4 +1,5 @@ using SkiaSharp; +using Jiaowu.Api.Infrastructure.Pdf; namespace Jiaowu.Api.Infrastructure.Timetables; @@ -13,14 +14,14 @@ public static class TimetablePdfExporter 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(TimetableData timetable, string? fontPath = null) => Create([timetable], fontPath); - public static byte[] Create(IReadOnlyCollection timetables) + public static byte[] Create(IReadOnlyCollection timetables, string? fontPath = null) { if (timetables.Count == 0) throw new ArgumentException("至少需要一张课表。", nameof(timetables)); - using var typeface = ResolveTypeface(); + using var typeface = PdfTypefaceResolver.ResolveChineseTypeface(fontPath); using var stream = new MemoryStream(); using var document = SKDocument.CreatePdf(stream); var page = 0; @@ -29,6 +30,8 @@ public static class TimetablePdfExporter var periods = Periods(timetable); foreach (var periodPage in periods.Chunk(9)) DrawPage(document, typeface, timetable, periodPage, ++page); + foreach (var coursePage in timetable.FlexibleCourses.Chunk(9)) + DrawFlexibleCoursesPage(document, typeface, timetable, coursePage, ++page); } document.Close(); return stream.ToArray(); @@ -107,7 +110,15 @@ public static class TimetablePdfExporter Style = SKPaintStyle.Fill }; canvas.DrawRect(x, rowY, widths[day], rowHeight, fill); - DrawCellText(canvas, typeface, cellEntries, x, rowY, widths[day], rowHeight); + var startingEntries = cellEntries + .Where(item => item.StartPeriod == period) + .ToArray(); + if (startingEntries.Length > 0) + { + var spanRows = ConsecutiveSpan(periods, rowIndex, startingEntries); + DrawCellText(canvas, typeface, startingEntries, x, rowY, widths[day], + rowHeight * spanRows); + } } x += widths[day]; } @@ -122,14 +133,23 @@ public static class TimetablePdfExporter 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 lineY = y + headerHeight + row * rowHeight; + canvas.DrawLine(Margin, lineY, Margin + widths[0], lineY, grid); + x = Margin + widths[0]; + for (var day = 1; day <= 7; day++) + { + var crossesBoundary = row > 0 && periods[row] == periods[row - 1] + 1 && + entries.Any(item => item.DayOfWeek == day && + item.StartPeriod < periods[row] && + item.StartPeriod + item.PeriodCount - 1 >= periods[row]); + if (!crossesBoundary) + canvas.DrawLine(x, lineY, x + widths[day], lineY, grid); + x += widths[day]; + } + } - 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); + DrawFooter(canvas, typeface, page, titleRule); document.EndPage(); } @@ -143,9 +163,57 @@ public static class TimetablePdfExporter float height) { var text = string.Join("\n", entries.Select(EntryText)); - var lines = WrapText(typeface, text, 6.6f, width - 8).Take(5).ToArray(); + const float fontSize = 6.5f; + const float lineHeight = 7.8f; + var maxLines = Math.Max(1, (int)Math.Floor((height - 10) / lineHeight)); + var lines = WrapText(typeface, text, fontSize, width - 8).Take(maxLines).ToArray(); for (var index = 0; index < lines.Length; index++) - DrawText(canvas, typeface, lines[index], x + 4, y + 10 + index * 8, 6.6f, Ink); + DrawText(canvas, typeface, lines[index], x + 4, y + 9 + index * lineHeight, fontSize, Ink); + } + + private static void DrawFlexibleCoursesPage( + SKDocument document, + SKTypeface typeface, + TimetableData timetable, + IReadOnlyList courses, + 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 }; + using var titleRule = new SKPaint { Color = Rule, StrokeWidth = .8f }; + using var heading = new SKPaint { Color = new SKColor(128, 92, 39), Style = SKPaintStyle.Fill }; + 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}", Margin, 74, 9, Muted); + canvas.DrawLine(Margin, 86, PageWidth - Margin, 86, titleRule); + + const float contentY = 107; + const float rowHeight = 43; + canvas.DrawRoundRect(new SKRect(Margin, contentY, PageWidth - Margin, contentY + 26), 3, 3, heading); + DrawText(canvas, typeface, "非排时课程(不占正常上课时间与场地)", Margin + 12, contentY + 17, 9, + SKColors.White, bold: true); + for (var index = 0; index < courses.Count; index++) + { + var course = courses[index]; + var rowY = contentY + 26 + index * rowHeight; + using var fill = new SKPaint + { + Color = index % 2 == 0 ? new SKColor(255, 248, 234) : SKColors.White, + Style = SKPaintStyle.Fill + }; + canvas.DrawRect(Margin, rowY, PageWidth - Margin * 2, rowHeight, fill); + DrawText(canvas, typeface, $"{course.CourseCode} · {course.CourseName}", Margin + 12, rowY + 16, + 9, Ink, bold: true); + DrawText(canvas, typeface, + $"教师:{string.Join('、', course.TeacherNames)} · 第 {course.StartWeek}-{course.EndWeek} 周 · 每周 {course.WeeklyHours} 学时", + Margin + 12, rowY + 31, 8, Muted); + canvas.DrawLine(Margin, rowY + rowHeight, PageWidth - Margin, rowY + rowHeight, titleRule); + } + + DrawFooter(canvas, typeface, page, titleRule); + document.EndPage(); } private static IReadOnlyList Periods(TimetableData timetable) @@ -158,8 +226,32 @@ public static class TimetablePdfExporter } 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)))}"; + entry.IsExperiment + ? $"【实验】{entry.ExperimentProjectName}\n" + + $"{entry.CourseName} · {ArrangementMode(entry)}\n" + + $"实验日:{entry.ExperimentDate:yyyy/M/d} · 第 {entry.StartPeriod}-{entry.StartPeriod + entry.PeriodCount - 1} 节\n" + + $"{string.Join('、', entry.TeacherNames)} · {Location(entry)}" + : entry.IsExam + ? $"【考试】{entry.CourseName}\n" + + $"考试日:{entry.ExamDate:yyyy/M/d} · 第 {entry.StartPeriod}-{entry.StartPeriod + entry.PeriodCount - 1} 节\n" + + $"{string.Join('、', entry.TeacherNames)} · {Location(entry)}" + : $"{(entry.Kind == Domain.Academic.ScheduleEntryKind.Experiment ? "【实验课】" : "")}{entry.CourseName}\n" + + $"{string.Join('、', entry.TeacherNames)} · {Location(entry)}\n" + + $"第 {entry.StartWeek}-{entry.EndWeek} 周"; + + private static string ArrangementMode(TimetableEntryDto entry) => entry.ExperimentArrangementMode switch + { + Domain.Academic.ExperimentArrangementMode.Centralized => "集中安排", + Domain.Academic.ExperimentArrangementMode.SelfScheduled => "自主预约", + _ => "实验安排" + }; + + private static string Location(TimetableEntryDto entry) => string.Join(" · ", new[] + { + entry.CampusName, + entry.BuildingName, + entry.ClassroomName + }.Where(item => !string.IsNullOrWhiteSpace(item))); private static IReadOnlyList WrapText(SKTypeface typeface, string text, float size, float width) { @@ -183,6 +275,20 @@ public static class TimetablePdfExporter return lines; } + private static int ConsecutiveSpan( + IReadOnlyList periods, + int startIndex, + IReadOnlyCollection entries) + { + var endPeriod = entries.Max(entry => entry.StartPeriod + entry.PeriodCount - 1); + var count = 1; + for (var index = startIndex + 1; + index < periods.Count && periods[index] == periods[index - 1] + 1 && periods[index] <= endPeriod; + index++) + count++; + return count; + } + 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) { @@ -191,23 +297,13 @@ public static class TimetablePdfExporter canvas.DrawText(text, x, y, align, font, paint); } - private static SKTypeface ResolveTypeface() + private static void DrawFooter(SKCanvas canvas, SKTypeface typeface, int page, SKPaint titleRule) { - 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 的中文字体。"); + 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); } private static string PlanStatus(Domain.Academic.SchedulePlanStatus status) => status switch diff --git a/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs b/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs index 5b204eb..5c7f12a 100644 --- a/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs +++ b/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs @@ -157,6 +157,10 @@ public sealed class TimetableExcelExporterTests Assert.Contains("【实验】基础控制实验", sheet.Cell(5, 5).GetString()); Assert.Contains("自主预约", sheet.Cell(5, 5).GetString()); Assert.Contains("2027-01-05", sheet.Cell(5, 3).GetString()); + Assert.True(sheet.Row(5).Height > 64, + "包含多门课程、考试和实验的节次行必须按内容增高,不能截断文字。"); + Assert.Contains(sheet.MergedRanges, + range => range.RangeAddress.ToString() == "B5:B6"); Assert.Contains( sheet.CellsUsed(), cell => cell.GetString().Contains("非排时课程")); @@ -171,6 +175,6 @@ public sealed class TimetableExcelExporterTests var pdf = TimetablePdfExporter.Create([timetable, timetable]); Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4)); Assert.Contains(System.Text.Encoding.ASCII.GetBytes("%%EOF"), pdf); - Assert.True(pdf.Length > 1_000, "生成的 PDF 不应为空。"); + Assert.True(pdf.Length > 2_000, "课表 PDF 必须包含完整课程和非排时课程页面。"); } } diff --git a/versions.props b/versions.props index a70b09c..8e12cbc 100644 --- a/versions.props +++ b/versions.props @@ -1,7 +1,7 @@ - 2.4.0 - 2.4.0 - 2.4.0 + 2.4.1 + 2.4.1 + 2.4.1