220 lines
9.8 KiB
C#
220 lines
9.8 KiB
C#
using SkiaSharp;
|
|
|
|
namespace Jiaowu.Api.Infrastructure.Timetables;
|
|
|
|
public static class TimetablePdfExporter
|
|
{
|
|
private const float PageWidth = 842;
|
|
private const float PageHeight = 595;
|
|
private const float Margin = 24;
|
|
private static readonly SKColor Ink = new(27, 53, 74);
|
|
private static readonly SKColor Muted = new(92, 111, 126);
|
|
private static readonly SKColor Accent = new(32, 105, 99);
|
|
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(IReadOnlyCollection<TimetableData> timetables)
|
|
{
|
|
if (timetables.Count == 0)
|
|
throw new ArgumentException("至少需要一张课表。", nameof(timetables));
|
|
|
|
using var typeface = ResolveTypeface();
|
|
using var stream = new MemoryStream();
|
|
using var document = SKDocument.CreatePdf(stream);
|
|
var page = 0;
|
|
foreach (var timetable in timetables)
|
|
{
|
|
var periods = Periods(timetable);
|
|
foreach (var periodPage in periods.Chunk(9))
|
|
DrawPage(document, typeface, timetable, periodPage, ++page);
|
|
}
|
|
document.Close();
|
|
return stream.ToArray();
|
|
}
|
|
|
|
private static void DrawPage(
|
|
SKDocument document,
|
|
SKTypeface typeface,
|
|
TimetableData timetable,
|
|
IReadOnlyList<int> periods,
|
|
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 };
|
|
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}" +
|
|
(timetable.Plan is null ? "" : $" · {timetable.Plan.Version} · {PlanStatus(timetable.Plan.Status)}"),
|
|
Margin, 74, 9, Muted);
|
|
DrawText(canvas, typeface,
|
|
$"导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}",
|
|
PageWidth - Margin, 74, 8, Muted, SKTextAlign.Right);
|
|
using var titleRule = new SKPaint { Color = Rule, StrokeWidth = .8f };
|
|
canvas.DrawLine(Margin, 86, PageWidth - Margin, 86, titleRule);
|
|
|
|
var widths = new[] { 84f, 101f, 101f, 101f, 101f, 101f, 101f, 101f };
|
|
var headers = new[] { "节次 / 时间", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日" };
|
|
const float headerHeight = 24;
|
|
const float rowHeight = 46;
|
|
var y = 103f;
|
|
using var header = new SKPaint { Color = Accent, Style = SKPaintStyle.Fill };
|
|
using var grid = new SKPaint { Color = Rule, Style = SKPaintStyle.Stroke, StrokeWidth = .55f };
|
|
canvas.DrawRect(Margin, y, widths.Sum(), headerHeight, header);
|
|
var x = Margin;
|
|
for (var column = 0; column < headers.Length; column++)
|
|
{
|
|
DrawText(canvas, typeface, headers[column], x + widths[column] / 2, y + 16, 8.5f,
|
|
SKColors.White, SKTextAlign.Center, true);
|
|
x += widths[column];
|
|
}
|
|
|
|
var entries = timetable.Entries.Concat(timetable.ExamEntries).Concat(timetable.ExperimentEntries).ToArray();
|
|
for (var rowIndex = 0; rowIndex < periods.Count; rowIndex++)
|
|
{
|
|
var period = periods[rowIndex];
|
|
var rowY = y + headerHeight + rowIndex * rowHeight;
|
|
using var leftFill = new SKPaint { Color = Pale, Style = SKPaintStyle.Fill };
|
|
canvas.DrawRect(Margin, rowY, widths[0], rowHeight, leftFill);
|
|
var slot = timetable.Slots.FirstOrDefault(item => item.PeriodNumber == period);
|
|
DrawText(canvas, typeface, $"第 {period} 节", Margin + widths[0] / 2, rowY + 18, 8.5f,
|
|
Ink, SKTextAlign.Center, true);
|
|
if (slot is not null)
|
|
DrawText(canvas, typeface, $"{slot.StartsAt:HH\\:mm}-{slot.EndsAt:HH\\:mm}",
|
|
Margin + widths[0] / 2, rowY + 32, 7, Muted, SKTextAlign.Center);
|
|
|
|
x = Margin + widths[0];
|
|
for (var day = 1; day <= 7; day++)
|
|
{
|
|
var cellEntries = entries.Where(item =>
|
|
item.DayOfWeek == day &&
|
|
item.StartPeriod <= period &&
|
|
item.StartPeriod + item.PeriodCount - 1 >= period).ToArray();
|
|
if (cellEntries.Length > 0)
|
|
{
|
|
using var fill = new SKPaint
|
|
{
|
|
Color = cellEntries.Any(item => item.IsExam)
|
|
? new SKColor(255, 243, 230)
|
|
: cellEntries.Any(item => item.IsExperiment)
|
|
? new SKColor(232, 246, 241)
|
|
: new SKColor(234, 245, 244),
|
|
Style = SKPaintStyle.Fill
|
|
};
|
|
canvas.DrawRect(x, rowY, widths[day], rowHeight, fill);
|
|
DrawCellText(canvas, typeface, cellEntries, x, rowY, widths[day], rowHeight);
|
|
}
|
|
x += widths[day];
|
|
}
|
|
}
|
|
|
|
var tableHeight = headerHeight + periods.Count * rowHeight;
|
|
canvas.DrawRect(Margin, y, widths.Sum(), tableHeight, grid);
|
|
x = Margin;
|
|
for (var column = 0; column < widths.Length - 1; column++)
|
|
{
|
|
x += widths[column];
|
|
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 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);
|
|
document.EndPage();
|
|
}
|
|
|
|
private static void DrawCellText(
|
|
SKCanvas canvas,
|
|
SKTypeface typeface,
|
|
IReadOnlyList<TimetableEntryDto> entries,
|
|
float x,
|
|
float y,
|
|
float width,
|
|
float height)
|
|
{
|
|
var text = string.Join("\n", entries.Select(EntryText));
|
|
var lines = WrapText(typeface, text, 6.6f, width - 8).Take(5).ToArray();
|
|
for (var index = 0; index < lines.Length; index++)
|
|
DrawText(canvas, typeface, lines[index], x + 4, y + 10 + index * 8, 6.6f, Ink);
|
|
}
|
|
|
|
private static IReadOnlyList<int> Periods(TimetableData timetable)
|
|
{
|
|
var entries = timetable.Entries.Concat(timetable.ExamEntries).Concat(timetable.ExperimentEntries);
|
|
var periods = timetable.Slots.Select(item => item.PeriodNumber)
|
|
.Concat(entries.SelectMany(item => Enumerable.Range(item.StartPeriod, item.PeriodCount)))
|
|
.Distinct().Order().ToArray();
|
|
return periods.Length == 0 ? Enumerable.Range(1, 8).ToArray() : periods;
|
|
}
|
|
|
|
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)))}";
|
|
|
|
private static IReadOnlyList<string> WrapText(SKTypeface typeface, string text, float size, float width)
|
|
{
|
|
using var font = new SKFont(typeface, size);
|
|
var lines = new List<string>();
|
|
foreach (var paragraph in text.Split('\n'))
|
|
{
|
|
var current = "";
|
|
foreach (var character in paragraph)
|
|
{
|
|
var candidate = current + character;
|
|
if (current.Length > 0 && font.MeasureText(candidate) > width)
|
|
{
|
|
lines.Add(current);
|
|
current = character.ToString();
|
|
}
|
|
else current = candidate;
|
|
}
|
|
if (current.Length > 0) lines.Add(current);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
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)
|
|
{
|
|
using var font = new SKFont(typeface, size) { Embolden = bold };
|
|
using var paint = new SKPaint { Color = color, IsAntialias = true };
|
|
canvas.DrawText(text, x, y, align, font, paint);
|
|
}
|
|
|
|
private static SKTypeface ResolveTypeface()
|
|
{
|
|
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 的中文字体。");
|
|
}
|
|
|
|
private static string PlanStatus(Domain.Academic.SchedulePlanStatus status) => status switch
|
|
{
|
|
Domain.Academic.SchedulePlanStatus.Draft => "草稿",
|
|
Domain.Academic.SchedulePlanStatus.Published => "已发布",
|
|
_ => "已归档"
|
|
};
|
|
}
|