316 lines
9.4 KiB
TypeScript
316 lines
9.4 KiB
TypeScript
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
||
import type {
|
||
DailyCheckinRow,
|
||
DailyCheckinSection,
|
||
LetterTracingData,
|
||
} from '../generators/letter-tracing-generator';
|
||
import {
|
||
PRINT_CLEARLY_DASHED,
|
||
PRINT_CLEARLY_REGULAR,
|
||
fontFamilyOf,
|
||
} from '../../shared/data/fontProfiles';
|
||
import {
|
||
calcLetterFontSizes,
|
||
drawClosedFourLineGrid,
|
||
drawLetterInFourLineGrid,
|
||
loadLetterFont,
|
||
} from '../../shared/draw/drawTools';
|
||
|
||
type DailyCheckinData = Extract<
|
||
LetterTracingData,
|
||
{ mode: 'letter-tracing-daily-checkin' }
|
||
>;
|
||
|
||
// 页面左右边距(px)
|
||
const PAGE_MARGIN_X = 14;
|
||
// 页面上下边距(px)
|
||
const PAGE_MARGIN_Y = 14;
|
||
// 每个分区内容区左右内边距(px)
|
||
const SECTION_PADDING_X = 18;
|
||
// 每个分区内容区顶部内边距(px)
|
||
const SECTION_PADDING_TOP = 12;
|
||
// 每个分区内容区底部内边距(px)
|
||
const SECTION_PADDING_BOTTOM = 10;
|
||
|
||
// 标题与上一元素的垂直间距(px)
|
||
const HEADER_TITLE_GAP = 32;
|
||
// 标题与元信息(如日期、学号等)的间距(px)
|
||
const HEADER_META_GAP = 22;
|
||
// 正文内容距标题/元信息顶部的间距(px)
|
||
const CONTENT_TOP_GAP = 14;
|
||
|
||
// 页脚短语与内容之间的间距(px)
|
||
const FOOTER_PHRASE_GAP = 26;
|
||
// 页脚索引与短语之间的间距(px)
|
||
const FOOTER_INDEX_GAP = 22;
|
||
|
||
// 单个格子区列数
|
||
const GRID_COLS = 5;
|
||
// 单个格子区行数
|
||
const GRID_ROWS = 7;
|
||
// 格子列间距(px)
|
||
const GRID_COL_GAP = 6;
|
||
// 格子行间距(px)
|
||
const GRID_ROW_GAP = 8;
|
||
|
||
// 网格描边主色
|
||
const GRID_BORDER = '#9AA9A2';
|
||
// 网格中间线虚线颜色
|
||
const GRID_MIDDLE = 'rgba(154, 169, 162, 0.65)';
|
||
|
||
// 黑色文本主色
|
||
const COLOR_BLACK = '#1a1a1a';
|
||
// 数据红色(例:未签到状态)
|
||
const COLOR_LIGHT_RED = '#FF8E8E';
|
||
|
||
export default class DailyCheckinDraw extends BaseDrawService {
|
||
async draw(data: DailyCheckinData) {
|
||
this.prepareDraw();
|
||
await Promise.all([
|
||
loadLetterFont(PRINT_CLEARLY_REGULAR),
|
||
loadLetterFont(PRINT_CLEARLY_DASHED),
|
||
]);
|
||
|
||
this.drawPageDividers();
|
||
|
||
const sections = this.getSectionRects();
|
||
for (let i = 0; i < sections.length; i++) {
|
||
this.drawSection(sections[i], data.sections[i] ?? null);
|
||
}
|
||
}
|
||
|
||
private drawPageDividers() {
|
||
const verticalX = this.canvasWidth / 2;
|
||
const horizontalY = this.canvasHeight / 2;
|
||
|
||
this.drawLine(
|
||
verticalX,
|
||
PAGE_MARGIN_Y,
|
||
verticalX,
|
||
this.canvasHeight - PAGE_MARGIN_Y,
|
||
{
|
||
isDashed: true,
|
||
dashPattern: [5, 5],
|
||
color: '#D6D2CC',
|
||
lineWidth: 1,
|
||
},
|
||
);
|
||
|
||
this.drawLine(
|
||
PAGE_MARGIN_X,
|
||
horizontalY,
|
||
this.canvasWidth - PAGE_MARGIN_X,
|
||
horizontalY,
|
||
{
|
||
isDashed: true,
|
||
dashPattern: [5, 5],
|
||
color: '#D6D2CC',
|
||
lineWidth: 1,
|
||
},
|
||
);
|
||
}
|
||
|
||
private getSectionRects() {
|
||
const halfW = this.canvasWidth / 2;
|
||
const halfH = this.canvasHeight / 2;
|
||
|
||
return [
|
||
{
|
||
x: PAGE_MARGIN_X,
|
||
y: PAGE_MARGIN_Y,
|
||
w: halfW - PAGE_MARGIN_X,
|
||
h: halfH - PAGE_MARGIN_Y,
|
||
},
|
||
{
|
||
x: halfW,
|
||
y: PAGE_MARGIN_Y,
|
||
w: this.canvasWidth - PAGE_MARGIN_X - halfW,
|
||
h: halfH - PAGE_MARGIN_Y,
|
||
},
|
||
{
|
||
x: PAGE_MARGIN_X,
|
||
y: halfH,
|
||
w: halfW - PAGE_MARGIN_X,
|
||
h: this.canvasHeight - PAGE_MARGIN_Y - halfH,
|
||
},
|
||
{
|
||
x: halfW,
|
||
y: halfH,
|
||
w: this.canvasWidth - PAGE_MARGIN_X - halfW,
|
||
h: this.canvasHeight - PAGE_MARGIN_Y - halfH,
|
||
},
|
||
];
|
||
}
|
||
|
||
private drawSection(
|
||
rect: { x: number; y: number; w: number; h: number },
|
||
section: DailyCheckinSection | null,
|
||
) {
|
||
if (!section) return;
|
||
|
||
this.drawSectionHeader(rect);
|
||
this.drawSectionFooter(rect, section.pageNumber);
|
||
this.drawSectionGrid(rect, section.rows);
|
||
}
|
||
|
||
private drawSectionHeader(rect: {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
}) {
|
||
const cx = rect.x + rect.w / 2;
|
||
const titleY = rect.y + SECTION_PADDING_TOP + 12;
|
||
const metaY = titleY + HEADER_TITLE_GAP;
|
||
const metaUnderlineY = metaY + 5;
|
||
|
||
this.ctx.save();
|
||
this.ctx.fillStyle = '#322E25';
|
||
this.ctx.textAlign = 'center';
|
||
this.ctx.textBaseline = 'middle';
|
||
|
||
this.ctx.font = '20px serif';
|
||
this.ctx.fillText('每日打卡', cx, titleY);
|
||
|
||
this.ctx.font = '12px serif';
|
||
this.ctx.fillText('月', rect.x + 76, metaY);
|
||
this.ctx.fillText('日', rect.x + 122, metaY);
|
||
this.ctx.fillText('姓名:', rect.x + 176, metaY);
|
||
|
||
this.ctx.strokeStyle = '#8E897E';
|
||
this.ctx.lineWidth = 1;
|
||
this.ctx.setLineDash([]);
|
||
this.ctx.beginPath();
|
||
this.ctx.moveTo(rect.x + 40, metaUnderlineY);
|
||
this.ctx.lineTo(rect.x + 68, metaUnderlineY);
|
||
this.ctx.moveTo(rect.x + 85, metaUnderlineY);
|
||
this.ctx.lineTo(rect.x + 113, metaUnderlineY);
|
||
this.ctx.moveTo(rect.x + 195, metaUnderlineY);
|
||
this.ctx.lineTo(rect.x + 248, metaUnderlineY);
|
||
this.ctx.stroke();
|
||
|
||
this.ctx.restore();
|
||
}
|
||
|
||
private drawSectionFooter(
|
||
rect: { x: number; y: number; w: number; h: number },
|
||
pageNumber: number,
|
||
) {
|
||
const cx = rect.x + rect.w / 2;
|
||
const phraseY =
|
||
rect.y + rect.h - SECTION_PADDING_BOTTOM - FOOTER_PHRASE_GAP;
|
||
const indexY = rect.y + rect.h - SECTION_PADDING_BOTTOM - 6;
|
||
|
||
this.ctx.save();
|
||
this.ctx.fillStyle = '#3E3A33';
|
||
this.ctx.textAlign = 'center';
|
||
this.ctx.textBaseline = 'middle';
|
||
this.ctx.font = '14px serif';
|
||
this.ctx.fillText('日拱一卒 不期而至', cx, phraseY);
|
||
this.ctx.font = '12px serif';
|
||
this.ctx.fillText(`- ${pageNumber} -`, cx, indexY);
|
||
this.ctx.restore();
|
||
}
|
||
|
||
private drawSectionGrid(
|
||
rect: { x: number; y: number; w: number; h: number },
|
||
rows: DailyCheckinRow[],
|
||
) {
|
||
const contentTop =
|
||
rect.y +
|
||
SECTION_PADDING_TOP +
|
||
HEADER_TITLE_GAP +
|
||
HEADER_META_GAP +
|
||
CONTENT_TOP_GAP;
|
||
const contentBottom =
|
||
rect.y +
|
||
rect.h -
|
||
SECTION_PADDING_BOTTOM -
|
||
FOOTER_PHRASE_GAP -
|
||
FOOTER_INDEX_GAP;
|
||
const contentHeight = contentBottom - contentTop;
|
||
const contentWidth = rect.w - SECTION_PADDING_X * 2;
|
||
|
||
const cellW =
|
||
(contentWidth - GRID_COL_GAP * (GRID_COLS - 1)) / GRID_COLS;
|
||
const cellH =
|
||
(contentHeight - GRID_ROW_GAP * (GRID_ROWS - 1)) / GRID_ROWS;
|
||
const pairGap = 16;
|
||
const regularSizes = calcLetterFontSizes(
|
||
cellH * 0.98,
|
||
PRINT_CLEARLY_REGULAR,
|
||
);
|
||
const dashedSizes = calcLetterFontSizes(
|
||
cellH * 0.98,
|
||
PRINT_CLEARLY_DASHED,
|
||
);
|
||
|
||
for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
|
||
const row = rows[rowIdx];
|
||
const y = contentTop + rowIdx * (cellH + GRID_ROW_GAP);
|
||
|
||
for (let colIdx = 0; colIdx < GRID_COLS; colIdx++) {
|
||
const x =
|
||
rect.x +
|
||
SECTION_PADDING_X +
|
||
colIdx * (cellW + GRID_COL_GAP);
|
||
drawClosedFourLineGrid(this.ctx, x, y, cellW, cellH, {
|
||
ink: GRID_BORDER,
|
||
middleInk: GRID_MIDDLE,
|
||
lineWidth: 1,
|
||
dash: [3, 3],
|
||
});
|
||
|
||
const cell = row.cells[colIdx];
|
||
if (!cell) continue;
|
||
|
||
const sizes =
|
||
row.style === 'dashed' ? dashedSizes : regularSizes;
|
||
const fontFamily = fontFamilyOf(
|
||
row.style === 'dashed'
|
||
? PRINT_CLEARLY_DASHED
|
||
: PRINT_CLEARLY_REGULAR,
|
||
);
|
||
const centerX = x + cellW / 2;
|
||
|
||
this.ctx.save();
|
||
this.ctx.globalAlpha = cell.alpha;
|
||
|
||
drawLetterInFourLineGrid(
|
||
this.ctx,
|
||
cell.upper,
|
||
centerX - pairGap / 2,
|
||
y,
|
||
cellH,
|
||
sizes,
|
||
{
|
||
fontFamily,
|
||
color:
|
||
cell.color === COLOR_BLACK
|
||
? COLOR_BLACK
|
||
: COLOR_LIGHT_RED,
|
||
},
|
||
);
|
||
|
||
drawLetterInFourLineGrid(
|
||
this.ctx,
|
||
cell.lower,
|
||
centerX + pairGap / 2,
|
||
y,
|
||
cellH,
|
||
sizes,
|
||
{
|
||
fontFamily,
|
||
color:
|
||
cell.color === COLOR_BLACK
|
||
? COLOR_BLACK
|
||
: COLOR_LIGHT_RED,
|
||
},
|
||
);
|
||
|
||
this.ctx.restore();
|
||
}
|
||
}
|
||
}
|
||
}
|