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.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");
}
@@ -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)
@@ -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.##");
}
@@ -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"));
}
}
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<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) =>
entry.IsExperiment
? $"【实验】{entry.ExperimentProjectName}\n" +
@@ -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<TimetableData> timetables)
public static byte[] Create(IReadOnlyCollection<TimetableData> 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<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)
@@ -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<string> WrapText(SKTypeface typeface, string text, float size, float width)
{
@@ -183,6 +275,20 @@ public static class TimetablePdfExporter
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,
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
@@ -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 必须包含完整课程和非排时课程页面。");
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<JiaowuBackendVersion>2.4.0</JiaowuBackendVersion>
<JiaowuFrontendVersion>2.4.0</JiaowuFrontendVersion>
<JiaowuSwaggerVersion>2.4.0</JiaowuSwaggerVersion>
<JiaowuBackendVersion>2.4.1</JiaowuBackendVersion>
<JiaowuFrontendVersion>2.4.1</JiaowuFrontendVersion>
<JiaowuSwaggerVersion>2.4.1</JiaowuSwaggerVersion>
</PropertyGroup>
</Project>