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