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.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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user