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, type LetterFontSizes, } 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 = 24; // 标题与元信息(如日期、学号等)的间距(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; 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 = '14px serif'; this.ctx.fillText('月', rect.x + rect.w * 0.34, metaY); this.ctx.fillText('日', rect.x + rect.w * 0.48, metaY); this.ctx.fillText('姓名:', rect.x + rect.w * 0.7, metaY); this.ctx.strokeStyle = '#8E897E'; this.ctx.lineWidth = 1; this.ctx.setLineDash([]); this.ctx.beginPath(); this.ctx.moveTo(rect.x + rect.w * 0.14, metaY + 4); this.ctx.lineTo(rect.x + rect.w * 0.3, metaY + 4); this.ctx.moveTo(rect.x + rect.w * 0.38, metaY + 4); this.ctx.lineTo(rect.x + rect.w * 0.44, metaY + 4); this.ctx.moveTo(rect.x + rect.w * 0.52, metaY + 4); this.ctx.lineTo(rect.x + rect.w * 0.58, metaY + 4); this.ctx.moveTo(rect.x + rect.w * 0.78, metaY + 4); this.ctx.lineTo(rect.x + rect.w * 0.94, metaY + 4); 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 = Math.min(16, cellW * 0.28); const regularSizes = calcLetterFontSizes( cellH * 0.86, PRINT_CLEARLY_REGULAR, ); const dashedSizes = calcLetterFontSizes( cellH * 0.86, 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(); } } } }