import { BaseDrawService } from '../../../core/draw/baseDraw'; import { GRID_COLORS, GRID_DASH, TRACING_COLORS, } from '../../../core/data/tracingStyles'; import { TONEOZ_PINYIN, fontFamilyOf } from '../../../core/font/fontProfiles'; import { loadFontFace } from '../../../core/font/fontLoader'; import { SECTION_PADDING_TOP, SECTION_PADDING_BOTTOM, HEADER_TITLE_GAP, HEADER_META_GAP, CONTENT_TOP_GAP, FOOTER_PHRASE_GAP, FOOTER_INDEX_GAP, drawSectionHeader, drawSectionFooter, getRandomEncouragementPhrase, } from '../../../core/draw/dailyPracticeDrawHelper'; import { getScalingTransform, drawSvgPath, drawTianZiGrid, } from '../../shared/drawUtils'; export interface DailyCheckinHanziItem { character: string; pinyin: string; strokes: string[]; } const PAGE_MARGIN = 14; const SECTION_PADDING_X = 18; const COLUMNS = 11; const ROWS_PER_SECTION = 4; const BLOCK_GAP = 0; const PINYIN_ROW_RATIO = 0.52; // 拼音/笔画行高度占田字格高度比例 interface DrawSingleStrokeParams { ctx: RenderingContext; strokes: string[]; offsetX: number; offsetY: number; width: number; height: number; fromIndex: number; toIndex: number; fillStyle: string; } function drawStrokesRange({ ctx, strokes, offsetX, offsetY, width, height, fromIndex, toIndex, fillStyle, }: DrawSingleStrokeParams) { const padding = Math.min(width, height) * 0.1; const transform = getScalingTransform(width, height, padding); ctx.save(); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.miterLimit = 10; ctx.fillStyle = fillStyle; for (let s = fromIndex; s <= toIndex && s < strokes.length; s++) { ctx.save(); ctx.translate( offsetX + transform.xOffset, offsetY + height - transform.yOffset, ); ctx.scale(transform.scale, -transform.scale); ctx.beginPath(); drawSvgPath(ctx, strokes[s]); ctx.fill(); ctx.restore(); } ctx.restore(); } export default class WordDailyCheckinDraw extends BaseDrawService { async draw(items: DailyCheckinHanziItem[]) { this.prepareDraw(); await loadFontFace(TONEOZ_PINYIN); this.drawPageDivider(); const sectionRects = this.getSectionRects(); const itemsPerSection = ROWS_PER_SECTION; for (let i = 0; i < sectionRects.length; i++) { const rect = sectionRects[i]; const sectionItems = items.slice( i * itemsPerSection, (i + 1) * itemsPerSection, ); if (sectionItems.length === 0) continue; this.drawSection(rect, sectionItems, i + 1); } } private drawPageDivider() { const cy = this.canvasHeight / 2; this.drawLine(PAGE_MARGIN, cy, this.canvasWidth - PAGE_MARGIN, cy, { isDashed: true, dashPattern: [5, 5], color: '#D6D2CC', lineWidth: 1, }); } private getSectionRects() { const halfH = this.canvasHeight / 2; const fullW = this.canvasWidth; return [ { x: PAGE_MARGIN, y: PAGE_MARGIN, w: fullW - PAGE_MARGIN * 2, h: halfH - PAGE_MARGIN, }, { x: PAGE_MARGIN, y: halfH, w: fullW - PAGE_MARGIN * 2, h: this.canvasHeight - PAGE_MARGIN - halfH, }, ]; } private drawSection( rect: { x: number; y: number; w: number; h: number }, items: DailyCheckinHanziItem[], sectionIndex: number, ) { const phrase = getRandomEncouragementPhrase(); drawSectionHeader(this.ctx, rect, '汉字每日打卡'); drawSectionFooter(this.ctx, rect, phrase, `- ${sectionIndex} -`); this.drawSectionContent(rect, items); } private drawSectionContent( rect: { x: number; y: number; w: number; h: number }, items: DailyCheckinHanziItem[], ) { 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 contentLeft = rect.x + SECTION_PADDING_X; const contentWidth = rect.w - SECTION_PADDING_X * 2; // 单元尺寸:田字格为正方形,受宽高约束取较小值 const cellWFromWidth = contentWidth / COLUMNS; // 每个 hanzi 块高度 = pinyinRow + tianzigeRow,取 pinyin = ratio * tianzige const blockHeightFactor = 1 + PINYIN_ROW_RATIO; const totalGap = (ROWS_PER_SECTION - 1) * BLOCK_GAP; const cellWFromHeight = (contentHeight - totalGap) / (blockHeightFactor * ROWS_PER_SECTION); const cellW = Math.min(cellWFromWidth, cellWFromHeight); const tianzigeH = cellW; const pinyinH = cellW * PINYIN_ROW_RATIO; const blockH = tianzigeH + pinyinH; const totalH = ROWS_PER_SECTION * blockH + totalGap; const startX = contentLeft + (contentWidth - cellW * COLUMNS) / 2; const startY = contentTop + (contentHeight - totalH) / 2; for (let i = 0; i < items.length && i < ROWS_PER_SECTION; i++) { const blockY = startY + i * (blockH + BLOCK_GAP); this.drawHanziBlock(items[i], startX, blockY, cellW, pinyinH); } } private drawHanziBlock( item: DailyCheckinHanziItem, startX: number, startY: number, cellW: number, pinyinH: number, ) { const tianzigeH = cellW; const totalW = cellW * COLUMNS; // 拼音 + 笔画行(顶部) this.drawPinyinStrokeRow(item, startX, startY, totalW, pinyinH, cellW); // 田字格练习行(底部) const tianzigeTop = startY + pinyinH; this.drawTianzigeRow(item, startX, tianzigeTop, cellW, tianzigeH); } private drawPinyinStrokeRow( item: DailyCheckinHanziItem, startX: number, startY: number, totalW: number, rowH: number, cellW: number, ) { const ctx = this.ctx; const y1 = startY + rowH / 3; const y2 = startY + (rowH * 2) / 3; const yBot = startY + rowH; ctx.save(); // 外边框 ctx.strokeStyle = GRID_COLORS.border; ctx.lineWidth = 1; ctx.setLineDash([]); ctx.strokeRect(startX, startY, totalW, rowH); // 中间两条虚线(四线三格)—— 仅第一格 ctx.strokeStyle = GRID_COLORS.middleLine; ctx.setLineDash([...GRID_DASH]); ctx.beginPath(); ctx.moveTo(startX, y1); ctx.lineTo(startX + cellW, y1); ctx.moveTo(startX, y2); ctx.lineTo(startX + cellW, y2); ctx.stroke(); ctx.beginPath(); ctx.setLineDash([]); const vx = startX + cellW; ctx.moveTo(vx, startY); ctx.lineTo(vx, yBot); ctx.stroke(); ctx.restore(); // 列分隔线 // ctx.strokeStyle = GRID_COLORS.border; // ctx.setLineDash([]); // for (let c = 1; c < COLUMNS; c++) { // const vx = startX + c * cellW; // ctx.beginPath(); // ctx.moveTo(vx, startY); // ctx.lineTo(vx, yBot); // ctx.stroke(); // } // 第一格:绘制拼音 if (item.pinyin) { const cx = startX + cellW / 2; const fontSize = Math.round(rowH * 0.65); ctx.save(); ctx.font = `${fontSize}px ${fontFamilyOf(TONEOZ_PINYIN)}`; ctx.fillStyle = TRACING_COLORS.strong; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.fillText(item.pinyin, cx, y2); ctx.restore(); } // 笔画格:第 2 格起,按笔画顺序展示当前进度 const strokeCount = item.strokes.length; for (let k = 1; k <= strokeCount && k < COLUMNS; k++) { const cellX = startX + cellW / 2 + k * 15; const currentIdx = k - 1; // 已绘制的历史笔画:浅灰 if (currentIdx > 0) { drawStrokesRange({ ctx, strokes: item.strokes, offsetX: cellX, offsetY: startY, width: cellW, height: rowH, fromIndex: 0, toIndex: currentIdx - 1, fillStyle: TRACING_COLORS.guide, }); } // 当前笔画:红色高亮 drawStrokesRange({ ctx, strokes: item.strokes, offsetX: cellX, offsetY: startY, width: cellW, height: rowH, fromIndex: currentIdx, toIndex: currentIdx, fillStyle: '#D94B4B', }); } } private drawTianzigeRow( item: DailyCheckinHanziItem, startX: number, startY: number, cellW: number, cellH: number, ) { const ctx = this.ctx; const TRACING_INDICES = new Set([5, 8]); // 第 6 个和第 9 个(0-based: 5, 8) for (let c = 0; c < COLUMNS; c++) { const cellX = startX + c * cellW; const cx = cellX + cellW / 2; const cy = startY + cellH / 2; drawTianZiGrid({ ctx, cx, cy, size: cellW }); if (c === 0) { drawStrokesRange({ ctx, strokes: item.strokes, offsetX: cellX, offsetY: startY, width: cellW, height: cellH, fromIndex: 0, toIndex: item.strokes.length - 1, fillStyle: TRACING_COLORS.strong, }); } else if (TRACING_INDICES.has(c)) { drawStrokesRange({ ctx, strokes: item.strokes, offsetX: cellX, offsetY: startY, width: cellW, height: cellH, fromIndex: 0, toIndex: item.strokes.length - 1, fillStyle: TRACING_COLORS.guide, }); } } } }