import { BaseDrawService } from '../../../core/draw/baseDraw'; import { getImage } from '../../../utils/index'; import type { LetterTracingData, HighlightGrid, } from '../generators/letter-tracing-generator'; import { fontFamilyOf } from '../data/fontProfiles'; import { calcLetterFontSizes, drawFourLineGrid, drawLetterInFourLineGrid, FOUR_LINE_GRID_H, getLetterFontSize, type LetterFontSizes, } from './drawTools'; const LINE_INK_ALPHA = 'rgba(50, 46, 37, 1)'; const ACCENT_RED = 'rgba(252, 46, 0, 1)'; const TITLE_TEXT_FONT = '14px sans-serif'; const TITLE_ICON_GAP = 4; type SingleLetterData = Extract< LetterTracingData, { mode: 'letter-tracing-single' } >; export default class SingleLetterDraw extends BaseDrawService { private letterSizes: LetterFontSizes = calcLetterFontSizes(FOUR_LINE_GRID_H); private get letterFace(): string { return fontFamilyOf(this.letterSizes._profile); } async draw(data: SingleLetterData) { this.prepareDraw(); await this.drawHeaderAndDivider(); await this.drawContent(data); this.drawPrintFooter(); } private async drawContent(data: SingleLetterData) { const { teaching, highlightGrid, traceImgPath } = data; const ctx = this.ctx; // ── 上区:left:78 top:120 w:451 h:180 ── const TOP_X = 78; const TOP_Y = 120; const letterImgX = TOP_X + 15; const letterImgY = TOP_Y + 9; const letterImgW = 180; const letterImgH = 103.5; try { const letterImg = (await getImage( this.canvas, teaching.letterImgPath, )) as WechatMiniprogram.Image; const aspect = (letterImg.width || 1) / (letterImg.height || 1); let dw = letterImgW; let dh = dw / aspect; if (dh > letterImgH) { dh = letterImgH; dw = dh * aspect; } ctx.drawImage( letterImg, letterImgX + (letterImgW - dw) / 2, letterImgY + (letterImgH - dh) / 2, dw, dh, ); } catch { ctx.font = `bold 60px ${this.letterFace}`; ctx.fillStyle = '#111'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText( `${teaching.upper} ${teaching.lower}`, letterImgX + letterImgW / 2, letterImgY + letterImgH / 2, ); } this.drawSingleLetterSentence( teaching.sentence, teaching.upper, TOP_X, TOP_Y + 9 + 120, ); // 右侧配图 const appleX = TOP_X + 271; const appleY = TOP_Y; const appleW = 180; const appleH = 180; try { const wordImg = (await getImage( this.canvas, teaching.wordImgPath, )) as WechatMiniprogram.Image; const aspect = (wordImg.width || 1) / (wordImg.height || 1); let dw = appleW; let dh = dw / aspect; if (dh > appleH) { dh = appleH; dw = dh * aspect; } ctx.drawImage( wordImg, appleX + (appleW - dw) / 2, appleY + (appleH - dh) / 2, dw, dh, ); } catch { ctx.font = `${Math.min(80, appleH * 0.5)}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText( teaching.emoji, appleX + appleW / 2, appleY + appleH / 2, ); } // ── 中区 ── const HL_X = 26, HL_Y = 320, HL_W = 272, HL_H = 181; const TR_X = 298, TR_Y = 320, TR_W = 272, TR_H = 181; ctx.strokeStyle = LINE_INK_ALPHA; ctx.lineWidth = 1; ctx.setLineDash([]); ctx.strokeRect(HL_X, HL_Y, HL_W, HL_H); ctx.strokeRect(TR_X, TR_Y, TR_W, TR_H); this.drawHighlightSection(highlightGrid, HL_X, HL_Y); await this.drawTraceItSection( traceImgPath, teaching.upper, teaching.lower, TR_X, TR_Y, ); // ── 下区:4 行四线三格 + 字母临摹(倒置直角三角形)── // 每行:left:24 w:547 h:43,top 分别 524.5 / 590.5 / 656.5 / 722.5 const lineX = 24; const lineW = 547; const lineTops = [524.5, 590.5, 656.5, 722.5]; // 8 → 6 → 3 → 1 组(每组为 Upper+Lower),左对齐,形成倒置直角三角形 const groupsPerLine = [8, 6, 3, 1]; const slotW = lineW / groupsPerLine[0]; // 以第一行的 8 组为基准 for (let r = 0; r < lineTops.length; r++) { const y = lineTops[r]; drawFourLineGrid(ctx, lineX, y, lineW, FOUR_LINE_GRID_H); const n = groupsPerLine[r] ?? 0; for (let i = 0; i < n; i++) { const alpha = n <= 1 ? 1 : 1 - (i / (n - 1)) * 0.7; this.drawUpperLowerPairInFourLines( teaching.upper, teaching.lower, lineX + i * slotW, y, slotW, alpha, ); } } } /** * 在四线三格中绘制 Upper+Lower 对(共享基线,间距远小于组间距) */ private drawUpperLowerPairInFourLines( upper: string, lower: string, x: number, y: number, w: number, alpha: number, ) { const ctx = this.ctx; const sizes = this.letterSizes; ctx.save(); ctx.globalAlpha = Math.max(0, Math.min(1, alpha)); const pairGap = 3; const upperFs = getLetterFontSize(upper, sizes); ctx.font = `${upperFs}px ${this.letterFace}`; const upperW = ctx.measureText(upper).width; const lowerFs = getLetterFontSize(lower, sizes); ctx.font = `${lowerFs}px ${this.letterFace}`; const lowerW = ctx.measureText(lower).width; const totalPairW = upperW + pairGap + lowerW; const pairStartX = x + (w - totalPairW) / 2; const color = 'rgba(26, 26, 26, 1)'; drawLetterInFourLineGrid( ctx, upper, pairStartX + upperW / 2, y, FOUR_LINE_GRID_H, sizes, { fontFamily: this.letterFace, color }, ); drawLetterInFourLineGrid( ctx, lower, pairStartX + upperW + pairGap + lowerW / 2, y, FOUR_LINE_GRID_H, sizes, { fontFamily: this.letterFace, color }, ); ctx.restore(); } private drawSingleLetterSentence( sentence: string, letter: string, x: number, y: number, ) { const ctx = this.ctx; ctx.font = `36px ${this.letterFace}`; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; let cx = x; for (let i = 0; i < sentence.length; i++) { const ch = sentence[i]; ctx.fillStyle = ch.toUpperCase() === letter ? ACCENT_RED : 'rgba(0, 0, 0, 1)'; ctx.fillText(ch, cx, y); cx += ctx.measureText(ch).width; } } private drawHighlightSection(grid: HighlightGrid, sx: number, sy: number) { const ctx = this.ctx; const titleX = sx + 16; const titleY = sy + 12; ctx.fillStyle = '#000'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; const hlIcon = '\u{1F50D}'; ctx.font = "16px 'Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif"; ctx.fillText(hlIcon, titleX, titleY + 1); const hlIconW = ctx.measureText(hlIcon).width; ctx.font = TITLE_TEXT_FONT; ctx.fillText( 'Highlight it !', titleX + hlIconW + TITLE_ICON_GAP, titleY, ); const gridX = sx + 16; const gridY = sy + 45; const cellSize = 40; const cols = grid.rows[0]?.length ?? 6; const rows = grid.rows.length; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const cx = gridX + c * cellSize; const cy = gridY + r * cellSize; ctx.strokeStyle = 'rgba(0, 0, 0, 1)'; ctx.lineWidth = 1; ctx.setLineDash([]); ctx.strokeRect(cx, cy, cellSize, cellSize); ctx.font = `18px ${this.letterFace}`; ctx.fillStyle = '#000'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText( grid.rows[r][c], cx + cellSize / 2, cy + cellSize / 2 + 1, ); } } } private async drawTraceItSection( traceImgPath: string, upper: string, lower: string, sx: number, sy: number, ) { const ctx = this.ctx; const titleX = sx + 25; const titleY = sy + 12; ctx.fillStyle = '#000'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; const traceIcon = '\u{1F58A}\u{FE0F}'; ctx.font = "16px 'Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif"; ctx.fillText(traceIcon, titleX, titleY); const traceIconW = ctx.measureText(traceIcon).width; ctx.font = TITLE_TEXT_FONT; ctx.fillText( 'Trace It !', titleX + traceIconW + TITLE_ICON_GAP, titleY, ); // 四线三格(设计稿绝对坐标) const lx = 316, lw = 236; const lines = [364.5, 404.5, 444.5, 484.5]; ctx.strokeStyle = LINE_INK_ALPHA; ctx.lineWidth = 1; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(lx, lines[0]); ctx.lineTo(lx + lw, lines[0]); ctx.stroke(); ctx.setLineDash([4, 4]); for (const ly of [lines[1], lines[2]]) { ctx.beginPath(); ctx.moveTo(lx, ly); ctx.lineTo(lx + lw, ly); ctx.stroke(); } ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(lx, lines[3]); ctx.lineTo(lx + lw, lines[3]); ctx.stroke(); // 笔顺图 const imgX = 337, imgY = 365, imgW = 159, imgH = 80; try { const img = (await getImage( this.canvas, traceImgPath, )) as WechatMiniprogram.Image; const aspect = (img.width || 1) / (img.height || 1); let dw = imgW; let dh = dw / aspect; if (dh > imgH) { dh = imgH; dw = dh * aspect; } ctx.drawImage( img, imgX + (imgW - dw) / 2, imgY + (imgH - dh) / 2, dw, dh, ); } catch { const fs = Math.min(imgH * 0.6, imgW * 0.18); ctx.font = `bold ${fs}px ${this.letterFace}`; ctx.fillStyle = '#222'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(`${upper} ${lower}`, imgX + imgW / 2, imgY + imgH / 2); } } }