This commit is contained in:
2026-08-11 21:14:12 +08:00 Unverified
parent 02fcf9ae8f
commit e5afe60a11
8 changed files with 256 additions and 65 deletions
@@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.OfficialDocuments;
using Jiaowu.Api.Infrastructure.Timetables; using Jiaowu.Api.Infrastructure.Timetables;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -17,7 +18,8 @@ namespace Jiaowu.Api.Controllers;
public sealed class TimetableManagementController( public sealed class TimetableManagementController(
AppDbContext db, AppDbContext db,
TimetableDataService timetableDataService, TimetableDataService timetableDataService,
ICurrentUserDataScope currentUserDataScope) : ControllerBase ICurrentUserDataScope currentUserDataScope,
OfficialDocumentOptions officialDocumentOptions) : ControllerBase
{ {
private const string ManagementRoles = private const string ManagementRoles =
SystemRoles.SuperAdmin + "," + SystemRoles.SuperAdmin + "," +
@@ -239,7 +241,7 @@ public sealed class TimetableManagementController(
} }
var fileName = $"课表批量导出-{FileName(timetables[0].Term.Name)}.pdf"; 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")] [HttpPost("display-links/export.xlsx")]
@@ -297,7 +299,7 @@ public sealed class TimetableManagementController(
private ActionResult PdfFile(TimetableData timetable) => private ActionResult PdfFile(TimetableData timetable) =>
File( File(
TimetablePdfExporter.Create(timetable), TimetablePdfExporter.Create(timetable, officialDocumentOptions.FontPath),
"application/pdf", "application/pdf",
$"{FileName(timetable.Subject.Name)}-{FileName(timetable.Term.Name)}-课表.pdf"); $"{FileName(timetable.Subject.Name)}-{FileName(timetable.Term.Name)}-课表.pdf");
} }
@@ -5,6 +5,7 @@ using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.OfficialDocuments;
using Jiaowu.Api.Infrastructure.Timetables; using Jiaowu.Api.Infrastructure.Timetables;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -18,7 +19,8 @@ public sealed class TimetablesController(
AppDbContext db, AppDbContext db,
TimetableDataService timetableDataService, TimetableDataService timetableDataService,
PersonalCalendarService personalCalendarService, PersonalCalendarService personalCalendarService,
IAppCache cache) : ControllerBase IAppCache cache,
OfficialDocumentOptions officialDocumentOptions) : ControllerBase
{ {
[HttpGet("options")] [HttpGet("options")]
[AllowAnonymous] [AllowAnonymous]
@@ -517,7 +519,7 @@ public sealed class TimetablesController(
{ {
var fileName = var fileName =
$"{SafeFileName(result.Subject.Name)}-{SafeFileName(result.Term.Name)}-课表.pdf"; $"{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) private static string SafeFileName(string value)
@@ -1,6 +1,7 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using QRCoder; using QRCoder;
using SkiaSharp; using SkiaSharp;
using Jiaowu.Api.Infrastructure.Pdf;
namespace Jiaowu.Api.Infrastructure.OfficialDocuments; namespace Jiaowu.Api.Infrastructure.OfficialDocuments;
@@ -28,7 +29,7 @@ public sealed class OfficialDocumentPdfGenerator(
OfficialDocumentSnapshot snapshot, OfficialDocumentSnapshot snapshot,
string verificationUrl) string verificationUrl)
{ {
using var typeface = ResolveTypeface(); using var typeface = PdfTypefaceResolver.ResolveChineseTypeface(options.FontPath);
using var stream = new MemoryStream(); using var stream = new MemoryStream();
using (var document = SKDocument.CreatePdf(stream)) using (var document = SKDocument.CreatePdf(stream))
{ {
@@ -382,28 +383,5 @@ public sealed class OfficialDocumentPdfGenerator(
return value + "…"; 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.##"); private static string FormatDecimal(decimal value) => value.ToString("0.##");
} }
@@ -0,0 +1,37 @@
using SkiaSharp;
namespace Jiaowu.Api.Infrastructure.Pdf;
/// <summary>
/// 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.
/// </summary>
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;
}
@@ -137,7 +137,6 @@ public static class TimetableExcelExporter
.Font.SetFontColor(XLColor.FromHtml("#173F4C")); .Font.SetFontColor(XLColor.FromHtml("#173F4C"));
} }
} }
sheet.Row(row).Height = 64;
} }
var grid = sheet.Range( var grid = sheet.Range(
@@ -149,6 +148,8 @@ public static class TimetableExcelExporter
grid.Style.Border.InsideBorderColor = XLColor.FromHtml("#D8E1E6"); grid.Style.Border.InsideBorderColor = XLColor.FromHtml("#D8E1E6");
grid.Style.Border.OutsideBorder = XLBorderStyleValues.Medium; grid.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
grid.Style.Border.OutsideBorderColor = XLColor.FromHtml("#AABBC5"); grid.Style.Border.OutsideBorderColor = XLColor.FromHtml("#AABBC5");
MergeContinuousCells(sheet, timedEntries, periods, headerRow);
SetRowHeights(sheet, periods.Length, headerRow, headers.Length);
sheet.Column(1).Width = 16; sheet.Column(1).Width = 16;
for (var column = 2; column <= 8; column++) sheet.Column(column).Width = 24; for (var column = 2; column <= 8; column++) sheet.Column(column).Width = 24;
sheet.SheetView.FreezeRows(headerRow); sheet.SheetView.FreezeRows(headerRow);
@@ -171,6 +172,9 @@ public static class TimetableExcelExporter
sheet.Cell(row, 1).Style sheet.Cell(row, 1).Style
.Fill.SetBackgroundColor(XLColor.FromHtml("#FFF8EA")) .Fill.SetBackgroundColor(XLColor.FromHtml("#FFF8EA"))
.Alignment.SetWrapText(); .Alignment.SetWrapText();
sheet.Row(row).Height = Math.Max(22,
8 + sheet.Cell(row, 1).GetString()
.Split('\n', StringSplitOptions.None).Length * 15);
row++; row++;
} }
} }
@@ -192,6 +196,74 @@ public static class TimetableExcelExporter
: name + suffix; : name + suffix;
} }
private static void MergeContinuousCells(
IXLWorksheet sheet,
IReadOnlyCollection<TimetableEntryDto> entries,
IReadOnlyList<int> 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<TimetableEntryDto> 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<TimetableEntryDto> left,
IReadOnlyList<TimetableEntryDto> right) =>
left.Count == right.Count && left.Select(entry => entry.Id).SequenceEqual(right.Select(entry => entry.Id));
private static string EntryText(TimetableEntryDto entry) => private static string EntryText(TimetableEntryDto entry) =>
entry.IsExperiment entry.IsExperiment
? $"【实验】{entry.ExperimentProjectName}\n" + ? $"【实验】{entry.ExperimentProjectName}\n" +
@@ -1,4 +1,5 @@
using SkiaSharp; using SkiaSharp;
using Jiaowu.Api.Infrastructure.Pdf;
namespace Jiaowu.Api.Infrastructure.Timetables; 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 Rule = new(206, 220, 226);
private static readonly SKColor Pale = new(241, 247, 247); 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<TimetableData> timetables) public static byte[] Create(IReadOnlyCollection<TimetableData> timetables, string? fontPath = null)
{ {
if (timetables.Count == 0) if (timetables.Count == 0)
throw new ArgumentException("至少需要一张课表。", nameof(timetables)); throw new ArgumentException("至少需要一张课表。", nameof(timetables));
using var typeface = ResolveTypeface(); using var typeface = PdfTypefaceResolver.ResolveChineseTypeface(fontPath);
using var stream = new MemoryStream(); using var stream = new MemoryStream();
using var document = SKDocument.CreatePdf(stream); using var document = SKDocument.CreatePdf(stream);
var page = 0; var page = 0;
@@ -29,6 +30,8 @@ public static class TimetablePdfExporter
var periods = Periods(timetable); var periods = Periods(timetable);
foreach (var periodPage in periods.Chunk(9)) foreach (var periodPage in periods.Chunk(9))
DrawPage(document, typeface, timetable, periodPage, ++page); DrawPage(document, typeface, timetable, periodPage, ++page);
foreach (var coursePage in timetable.FlexibleCourses.Chunk(9))
DrawFlexibleCoursesPage(document, typeface, timetable, coursePage, ++page);
} }
document.Close(); document.Close();
return stream.ToArray(); return stream.ToArray();
@@ -107,7 +110,15 @@ public static class TimetablePdfExporter
Style = SKPaintStyle.Fill Style = SKPaintStyle.Fill
}; };
canvas.DrawRect(x, rowY, widths[day], rowHeight, 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]; x += widths[day];
} }
@@ -122,14 +133,23 @@ public static class TimetablePdfExporter
canvas.DrawLine(x, y, x, y + tableHeight, grid); canvas.DrawLine(x, y, x, y + tableHeight, grid);
} }
for (var row = 0; row < periods.Count; row++) 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; DrawFooter(canvas, typeface, page, titleRule);
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(); document.EndPage();
} }
@@ -143,9 +163,57 @@ public static class TimetablePdfExporter
float height) float height)
{ {
var text = string.Join("\n", entries.Select(EntryText)); 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++) 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<FlexibleCourseDto> 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<int> Periods(TimetableData timetable) private static IReadOnlyList<int> Periods(TimetableData timetable)
@@ -158,8 +226,32 @@ public static class TimetablePdfExporter
} }
private static string EntryText(TimetableEntryDto entry) => private static string EntryText(TimetableEntryDto entry) =>
$"{(entry.IsExam ? "" : entry.IsExperiment ? "" : "")}{entry.CourseName} · {string.Join('、', entry.TeacherNames)}" + entry.IsExperiment
$" · {string.Join(" ", new[] { entry.BuildingName, entry.ClassroomName }.Where(item => !string.IsNullOrWhiteSpace(item)))}"; ? $"【实验】{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<string> WrapText(SKTypeface typeface, string text, float size, float width) private static IReadOnlyList<string> WrapText(SKTypeface typeface, string text, float size, float width)
{ {
@@ -183,6 +275,20 @@ public static class TimetablePdfExporter
return lines; return lines;
} }
private static int ConsecutiveSpan(
IReadOnlyList<int> periods,
int startIndex,
IReadOnlyCollection<TimetableEntryDto> 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, 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) 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); 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[] var footerY = PageHeight - 31;
{ canvas.DrawLine(Margin, footerY - 12, PageWidth - Margin, footerY - 12, titleRule);
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "simhei.ttf"), DrawText(canvas, typeface, "明序教务 · 课表导出", Margin, footerY, 7.5f, Muted);
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "msyh.ttc"), DrawText(canvas, typeface, $"第 {page} 页", PageWidth - Margin, footerY, 7.5f, Muted,
"/usr/share/fonts/noto/NotoSansCJK-Regular.ttc", SKTextAlign.Right);
"/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 private static string PlanStatus(Domain.Academic.SchedulePlanStatus status) => status switch
@@ -157,6 +157,10 @@ public sealed class TimetableExcelExporterTests
Assert.Contains("【实验】基础控制实验", sheet.Cell(5, 5).GetString()); Assert.Contains("【实验】基础控制实验", sheet.Cell(5, 5).GetString());
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.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( Assert.Contains(
sheet.CellsUsed(), sheet.CellsUsed(),
cell => cell.GetString().Contains("非排时课程")); cell => cell.GetString().Contains("非排时课程"));
@@ -171,6 +175,6 @@ public sealed class TimetableExcelExporterTests
var pdf = TimetablePdfExporter.Create([timetable, timetable]); var pdf = TimetablePdfExporter.Create([timetable, timetable]);
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4)); Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
Assert.Contains(System.Text.Encoding.ASCII.GetBytes("%%EOF"), pdf); Assert.Contains(System.Text.Encoding.ASCII.GetBytes("%%EOF"), pdf);
Assert.True(pdf.Length > 1_000, "生成的 PDF 不应为空。"); Assert.True(pdf.Length > 2_000, "课表 PDF 必须包含完整课程和非排时课程页面。");
} }
} }
+3 -3
View File
@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<JiaowuBackendVersion>2.4.0</JiaowuBackendVersion> <JiaowuBackendVersion>2.4.1</JiaowuBackendVersion>
<JiaowuFrontendVersion>2.4.0</JiaowuFrontendVersion> <JiaowuFrontendVersion>2.4.1</JiaowuFrontendVersion>
<JiaowuSwaggerVersion>2.4.0</JiaowuSwaggerVersion> <JiaowuSwaggerVersion>2.4.1</JiaowuSwaggerVersion>
</PropertyGroup> </PropertyGroup>
</Project> </Project>