v2.4.1
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user