import { BaseDrawService } from '../../../core/draw/baseDraw'; import type { LetterTracingData, TracingRow, } from '../generators/letter-tracing-generator'; const M = 24; /** 四线三格线色:偏黑,实线与虚线统一色相,仅以线型区分 */ const LINE_INK = '#2a2a2a'; /** * 字母描红绘制 * 四线三格:顶线、上中线、下中线为格内三线间距;最上最下为实线,中间两虚线(产品文档) */ 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 'picture-tracing': this.drawPictureTracing(data); this.drawPrintFooter(); break; case 'case-pairing': this.drawCasePairing(data.leftVisualRows, data.rightVisualRows); this.drawPrintFooter(); break; case 'column-compare': this.drawColumnCompare(data.leftRows, data.rightRows); this.drawPrintFooter(); break; case 'overview': this.drawOverview(data.upperRows, data.lowerRows); this.drawPrintFooter(); break; } } /** * 四线三格:y 顶 → y+h 底,中间两条虚线 */ private drawFourLines(x: number, y: number, w: number, h: number) { const ctx = this.ctx; const yTop = y; const yMid1 = y + h * 0.28; const yMid2 = y + h * 0.58; const yBot = y + h; const strokeSolid = () => { ctx.strokeStyle = LINE_INK; ctx.lineWidth = 1; ctx.setLineDash([]); }; const strokeDash = () => { ctx.strokeStyle = LINE_INK; ctx.lineWidth = 1; ctx.setLineDash([5, 5]); }; strokeSolid(); ctx.beginPath(); ctx.moveTo(x, yTop); ctx.lineTo(x + w, yTop); ctx.stroke(); strokeDash(); ctx.beginPath(); ctx.moveTo(x, yMid1); ctx.lineTo(x + w, yMid1); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x, yMid2); ctx.lineTo(x + w, yMid2); ctx.stroke(); strokeSolid(); ctx.setLineDash([]); 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, pairChar?: string, ) { const ctx = this.ctx; if (opacity <= 0) return; ctx.save(); ctx.globalAlpha = opacity; ctx.fillStyle = bold ? '#1a1a1a' : '#4a4a4a'; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; const baselineY = y + h * 0.72; if (pairChar) { const fs = Math.min(h * 0.5, w * 0.22); ctx.font = `${bold ? 'bold ' : ''}${fs}px sans-serif`; const gap = fs * 0.2; const totalW = fs * 2 + gap; const x0 = x + w / 2 - totalW / 2; ctx.fillText(ch, x0 + fs * 0.5, baselineY); ctx.fillText(pairChar, x0 + fs * 1.5 + gap, baselineY); } else { const fontSize = Math.min(h * 0.72, w * 0.85); ctx.font = `${bold ? 'bold ' : ''}${fontSize}px sans-serif`; 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 = n > 0 ? (totalWidth - gap * (n - 1)) / n : totalWidth; 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, cell.pairChar, ); } } /** * 看图描红:上左超大字母+例句,上右「配图」大 emoji;下 6 行四线三格 */ private drawPictureTracing( data: Extract, ) { const ctx = this.ctx; const { teaching, rows } = data; const cw = this.canvasWidth; let y = this.currentY + 8; const teachH = Math.min(240, (this.canvasHeight - y) * 0.38); const innerW = cw - M * 2; const splitX = M + innerW * 0.52; ctx.fillStyle = '#f8fafc'; ctx.fillRect(M, y, innerW, teachH); // 左侧:大小写 + 例句(短句在字母下方) const leftPad = M + 20; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#111'; const bigFs = Math.min(56, teachH * 0.26); ctx.font = `bold ${bigFs}px sans-serif`; ctx.fillText( `${teaching.upper} ${teaching.lower}`, leftPad, y + teachH * 0.12, ); ctx.font = `${Math.min(22, teachH * 0.1)}px sans-serif`; ctx.fillStyle = '#333'; ctx.fillText(teaching.sentence, leftPad, y + teachH * 0.48); // 右侧:配图区(以 emoji 作为插图占位) ctx.font = `${Math.min(120, teachH * 0.55)}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText( teaching.emoji, splitX + (cw - M - splitX) / 2, y + teachH / 2, ); y += teachH + 12; this.drawDashedDivider(y, M, '#ccc'); y += 14; const rest = this.canvasHeight - y - M; const rowH = Math.max(34, (rest - 10) / rows.length); const rowWidth = innerW; for (let r = 0; r < rows.length; r++) { this.drawTracingRow( rows[r], M, y + r * (rowH + 8), rowH, rowWidth, ); } } /** * 大小写配对:左右两栏,每行最多 4 组子行并排 */ private drawCasePairing( leftVisualRows: TracingRow[][], rightVisualRows: TracingRow[][], ) { const y0 = this.currentY + 6; const bottom = this.canvasHeight - M; const gapCol = 18; const colW = (this.canvasWidth - M * 2 - gapCol) / 2; const maxVisual = Math.max( leftVisualRows.length, rightVisualRows.length, ); const rowSlotH = Math.max(40, (bottom - y0) / maxVisual - 6); const drawSide = ( blocks: TracingRow[][], startX: number, width: number, ) => { let y = y0; for (const visualRow of blocks) { const nSeg = visualRow.length; const segGap = 6; const segW = (width - (nSeg - 1) * segGap) / nSeg; for (let s = 0; s < nSeg; s++) { this.drawTracingRow( visualRow[s], startX + s * (segW + segGap), y, rowSlotH, segW, ); } y += rowSlotH + 8; } }; drawSide(leftVisualRows, M, colW); drawSide(rightVisualRows, M + colW + gapCol, colW); } /** 分栏对照:左大写、右小写,逐行对齐 */ private drawColumnCompare(leftRows: TracingRow[], rightRows: TracingRow[]) { const y0 = this.currentY + 6; const bottom = this.canvasHeight - M; const maxR = Math.max(leftRows.length, rightRows.length); const rowH = Math.max(36, (bottom - y0) / maxR - 6); const gapCol = 16; const colW = (this.canvasWidth - M * 2 - gapCol) / 2; for (let i = 0; i < leftRows.length; i++) { const y = y0 + i * (rowH + 6); 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 + 6); this.drawTracingRow(rightRows[i], xR, y, rowH, colW); } } /** * 字母总览:Uppercase Letters / lowercase Letters,各行 9+9+8,四线三格 */ private drawOverview(upperRows: string[][], lowerRows: string[][]) { const ctx = this.ctx; let y = this.currentY + 6; const innerW = this.canvasWidth - M * 2; const title = (t: string) => { ctx.fillStyle = '#111'; ctx.font = 'bold 17px sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText(t, M, y); y += 24; }; const drawBand = (rows: string[][]) => { for (const line of rows) { const n = line.length; const gap = 4; const cellW = (innerW - (n - 1) * gap) / n; const cellH = 30; for (let i = 0; i < n; i++) { const cx = M + i * (cellW + gap); this.drawFourLines(cx, y, cellW, cellH); this.drawCharInCell( line[i], cx, y, cellW, cellH, 0.9, true, ); } y += cellH + 8; } }; title('Uppercase Letters'); drawBand(upperRows); y += 6; this.drawDashedDivider(y, M, '#bbb'); y += 12; title('lowercase Letters'); drawBand(lowerRows); } }