import { BaseDrawService } from '../../../service/baseDraw'; import type { LetterTracingData, TracingRow, } from '../../../core/generators/letter-tracing-generator'; const M = 24; const BLUE = '#4A90E2'; const ORANGE = '#FF8C42'; /** * 字母描红绘制(tracing-writing + letter-tracing 的客户端实现) */ export default class LetterTracingDraw extends BaseDrawService { constructor( canvas: Canvas, ctx: RenderingContext, options?: Record, ) { super(canvas, ctx, options); } async draw(data: LetterTracingData) { this.prepareDraw(); await this.drawHeaderAndDivider(); switch (data.mode) { case 'single-letter': this.drawSingleLetter(data); break; case 'full-alphabet': this.drawFullAlphabet(data.rows); break; case 'upper-lower-split': this.drawUpperLowerSplit(data.upperGlyphs, data.lowerGlyphs); break; case 'two-column': this.drawTwoColumn(data.leftRows, data.rightRows); break; } } /** 四线三格:顶线、中虚线、基线、底线 */ private drawFourLines(x: number, y: number, w: number, h: number) { const ctx = this.ctx; const yTop = y; const yMid = y + h * 0.28; const yBase = y + h * 0.62; const yBot = y + h; ctx.strokeStyle = BLUE; ctx.lineWidth = 1; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(x, yTop); ctx.lineTo(x + w, yTop); ctx.stroke(); ctx.strokeStyle = ORANGE; ctx.setLineDash([6, 6]); ctx.beginPath(); ctx.moveTo(x, yMid); ctx.lineTo(x + w, yMid); ctx.stroke(); ctx.strokeStyle = BLUE; ctx.setLineDash([]); ctx.lineWidth = 1.2; ctx.beginPath(); ctx.moveTo(x, yBase); ctx.lineTo(x + w, yBase); ctx.stroke(); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, yBot); ctx.lineTo(x + w, yBot); ctx.stroke(); } private drawCharInCell( ch: string, x: number, y: number, w: number, h: number, opacity: number, bold: boolean, ) { const ctx = this.ctx; if (opacity <= 0) return; const fontSize = Math.min(h * 0.72, w * 0.85); ctx.save(); ctx.globalAlpha = opacity; ctx.fillStyle = bold ? '#111' : '#666'; ctx.font = `${bold ? 'bold ' : ''}${fontSize}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; const baselineY = y + h * 0.62; ctx.fillText(ch, x + w / 2, baselineY); ctx.restore(); } private drawTracingRow( row: TracingRow, startX: number, y: number, rowH: number, totalWidth: number, ) { const gap = 4; const n = row.cells.length; const cellW = (totalWidth - gap * (n - 1)) / n; for (let i = 0; i < n; i++) { const cx = startX + i * (cellW + gap); this.drawFourLines(cx, y, cellW, rowH); const cell = row.cells[i]; this.drawCharInCell( cell.char, cx, y, cellW, rowH, cell.opacity, cell.isGuide, ); } } private drawSingleLetter( data: Extract, ) { const ctx = this.ctx; const { teaching, rows } = data; const cw = this.canvasWidth; let y = this.currentY + 8; const teachH = Math.min(220, (this.canvasHeight - y) * 0.42); // 教学区背景 ctx.fillStyle = '#f8fafc'; ctx.fillRect(M, y, cw - M * 2, teachH); // 左侧 emoji ctx.font = `${Math.min(88, teachH * 0.45)}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(teaching.emoji, M + (cw - M * 2) * 0.22, y + teachH / 2); // 右侧文字 const tx = M + (cw - M * 2) * 0.42; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#111'; ctx.font = 'bold 44px sans-serif'; ctx.fillText(`${teaching.upper} ${teaching.lower}`, tx, y + 16); ctx.font = '22px sans-serif'; ctx.fillStyle = '#333'; ctx.fillText(teaching.sentence, tx, y + 72); ctx.fillStyle = '#666'; ctx.font = '18px sans-serif'; ctx.fillText(teaching.word, tx, y + 104); y += teachH + 12; this.drawDashedDivider(y, M, '#ccc'); y += 14; const rest = this.canvasHeight - y - M; const rowH = Math.max(36, (rest - 8) / rows.length); const rowWidth = cw - M * 2; for (let r = 0; r < rows.length; r++) { this.drawTracingRow(rows[r], M, y + r * (rowH + 10), rowH, rowWidth); } } private drawFullAlphabet(rows: TracingRow[]) { const y0 = this.currentY + 6; const bottom = this.canvasHeight - M; const totalH = bottom - y0; const rowH = Math.max(14, totalH / rows.length - 2); const rowWidth = this.canvasWidth - M * 2; for (let i = 0; i < rows.length; i++) { const y = y0 + i * (rowH + 2); this.drawTracingRow(rows[i], M, y, rowH, rowWidth); } } private drawUpperLowerSplit(upper: string[], lower: string[]) { const ctx = this.ctx; const cw = this.canvasWidth; let y = this.currentY + 6; const innerW = cw - M * 2; const drawBlockTitle = (t: string) => { ctx.fillStyle = '#111'; ctx.font = 'bold 18px sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText(t, M, y); y += 26; }; const drawGlyphGrid = (chars: string[], cols: number) => { const rows = Math.ceil(chars.length / cols); const cellW = (innerW - (cols - 1) * 4) / cols; const cellH = 32; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const idx = r * cols + c; if (idx >= chars.length) break; const cx = M + c * (cellW + 4); this.drawFourLines(cx, y, cellW, cellH); this.drawCharInCell(chars[idx], cx, y, cellW, cellH, 0.85, true); } y += cellH + 6; } }; drawBlockTitle('大写字母 Uppercase'); drawGlyphGrid(upper, 13); y += 8; this.drawDashedDivider(y, M, '#bbb'); y += 14; drawBlockTitle('小写字母 Lowercase'); drawGlyphGrid(lower, 13); } private drawTwoColumn(leftRows: TracingRow[], rightRows: TracingRow[]) { const y0 = this.currentY + 6; const bottom = this.canvasHeight - M; const totalH = bottom - y0; const maxRows = Math.max(leftRows.length, rightRows.length); const rowH = Math.max(16, totalH / maxRows - 3); const gapCol = 16; const colW = (this.canvasWidth - M * 2 - gapCol) / 2; for (let i = 0; i < leftRows.length; i++) { const y = y0 + i * (rowH + 3); this.drawTracingRow(leftRows[i], M, y, rowH, colW); } const xR = M + colW + gapCol; for (let i = 0; i < rightRows.length; i++) { const y = y0 + i * (rowH + 3); this.drawTracingRow(rightRows[i], xR, y, rowH, colW); } } }