import { BaseDrawService } from '../../../core/draw/baseDraw'; import { PINYIN_SECTIONS, type PinyinSection, type PinyinSubCategory, } from '../../../core/data/pinyin'; import { GRID_COLORS, GRID_DASH, TRACING_COLORS, } from '../../../core/data/tracingStyles'; import type { PinyinDictationMode } from '../pinyinDictation.config'; import { TONEOZ_PINYIN, fontFamilyOf } from '../../../core/font/fontProfiles'; export interface PinyinDictationData { mode: PinyinDictationMode; } // ── 布局 ── const MARGIN_X = 24; // 左右页边距 const MIN_CELL_W = 44; // 格子最小宽度,实际宽度按内容区等分后可能略大 const CELL_H = 30; // 四线三格高度 const ROW_GAP = 5; // 同一分类中,行与行之间的纵向间距 const SUB_INDENT = 20; // 子分类(翘舌音、单韵母等)相对 MARGIN_X 的额外缩进 // ── 垂直间距 ── const CONTENT_TOP_GAP = 8; // Header 分割线到第一个分类标题的间距 const TITLE_AFTER_GAP = 24; // 分类标题(一、声母)到其下方格子的间距 const SECTION_GAP = 8; // 大分类之间(声母 ↔ 韵母 ↔ 整体认读)的间距 const SECTION_TO_SUB_GAP = 8; // 主格子到「其中——」的间距 const QIZHONG_GAP = 16; // 「其中——」到第一个子分类块的间距 const SUB_LABEL_H = 14; // 子分类标签文字占用高度 const SUB_LABEL_GAP = 4; // 子分类标签到其下方格子的间距 const SUB_BLOCK_GAP = 8; // 相邻子分类块之间的间距 // ── 字体 & 颜色 ── const SECTION_TITLE_FONT = 'bold 14px "Microsoft Yahei"'; // 大分类标题 const SUB_LABEL_FONT = '11px "Microsoft Yahei"'; // 子分类标签 const SECTION_TITLE_COLOR = '#322E25'; // 标题/标签文字色 const SUB_LABEL_COLOR = '#c0392b'; // 「其中——」文字色 export default class PinyinDictationDraw extends BaseDrawService { private cellW = MIN_CELL_W; private contentW = 0; async draw(data: PinyinDictationData) { this.prepareDraw(); this.contentW = this.canvasWidth - 2 * MARGIN_X; const maxCols = Math.floor(this.contentW / MIN_CELL_W); this.cellW = this.contentW / maxCols; await this.drawHeaderAndDivider(); this.drawContent(data); } private drawContent(data: PinyinDictationData) { const isTracing = data.mode === 'pinyin-tracing'; let y = this.currentY + CONTENT_TOP_GAP; for (const section of PINYIN_SECTIONS) { y = this.drawSection(section, y, isTracing); } } private drawSection( section: PinyinSection, startY: number, isTracing: boolean, ): number { const ctx = this.ctx; let y = startY; ctx.font = SECTION_TITLE_FONT; ctx.fillStyle = SECTION_TITLE_COLOR; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText(`${section.title}(${section.count}个)`, MARGIN_X, y); y += TITLE_AFTER_GAP; y = this.drawItemRows( section.items, MARGIN_X, y, isTracing, this.cellW, CELL_H, ); if (section.subCategories && section.subCategories.length > 0) { y += SECTION_TO_SUB_GAP; y = this.drawSubCategories( section.subCategories, y, isTracing, section.key, ); } y += SECTION_GAP; return y; } private drawSubCategories( subCategories: PinyinSubCategory[], startY: number, isTracing: boolean, sectionKey: string, ): number { const ctx = this.ctx; let y = startY; ctx.font = SUB_LABEL_FONT; ctx.fillStyle = SUB_LABEL_COLOR; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText('其中——', MARGIN_X, y); y += QIZHONG_GAP; const subX = MARGIN_X + SUB_INDENT; if (sectionKey === 'shengmu') { y = this.drawShengmuSubs(subCategories, subX, y, isTracing); } else { for (const sub of subCategories) { y = this.drawLabeledSubBlock( `${sub.label}(${sub.count}个)`, sub.items, subX, y, isTracing, ); y += SUB_BLOCK_GAP; } } return y; } /** * 声母子分类:翘舌音 / 平舌音左右并列,标签在格子上方 */ private drawShengmuSubs( subs: PinyinSubCategory[], subX: number, startY: number, isTracing: boolean, ): number { const qiaoshe = subs.find((s) => s.key === 'qiaoshe'); const pingshe = subs.find((s) => s.key === 'pingshe'); const col2X = MARGIN_X + Math.round(this.contentW / 2); let bottomY = startY; if (qiaoshe) { const endY = this.drawLabeledSubBlock( `翘舌音(${qiaoshe.count}个)`, qiaoshe.items, subX, startY, isTracing, ); bottomY = Math.max(bottomY, endY); } if (pingshe) { const endY = this.drawLabeledSubBlock( `平舌音(${pingshe.count}个)`, pingshe.items, col2X, startY, isTracing, ); bottomY = Math.max(bottomY, endY); } return bottomY + SUB_BLOCK_GAP; } /** * 带标签的子分类块:标签在格子上方 */ private drawLabeledSubBlock( label: string, items: string[], startX: number, startY: number, isTracing: boolean, ): number { const ctx = this.ctx; let y = startY; ctx.save(); ctx.font = SUB_LABEL_FONT; ctx.fillStyle = SECTION_TITLE_COLOR; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText(label, startX, y); ctx.restore(); y += SUB_LABEL_H + SUB_LABEL_GAP; return this.drawItemRows( items, startX, y, isTracing, this.cellW, CELL_H, ); } /** * 绘制四线三格拼音行,同一行的格子共享边框 */ private drawItemRows( items: string[], startX: number, startY: number, isTracing: boolean, cellW: number, cellH: number, ): number { const maxWidth = this.canvasWidth - startX - MARGIN_X; const maxCols = Math.max(1, Math.floor(maxWidth / cellW)); let y = startY; for (let i = 0; i < items.length; i += maxCols) { const rowItems = items.slice(i, i + maxCols); const cols = rowItems.length; this.drawFourLineRow(startX, y, cols, cellW, cellH); if (isTracing) { for (let j = 0; j < cols; j++) { const cx = startX + j * cellW + cellW / 2; this.drawPinyinText(rowItems[j], cx, y, cellH); } } y += cellH + ROW_GAP; } return y; } /** * 绘制一行四线三格 */ private drawFourLineRow( x: number, y: number, cols: number, cellW: number, cellH: number, ) { const ctx = this.ctx; const totalW = cols * cellW; const yTop = y; const y1 = y + cellH / 3; const y2 = y + (cellH * 2) / 3; const yBot = y + cellH; ctx.lineWidth = 1; ctx.strokeStyle = GRID_COLORS.border; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(x, yTop); ctx.lineTo(x + totalW, yTop); ctx.stroke(); ctx.strokeStyle = GRID_COLORS.middleLine; ctx.setLineDash([...GRID_DASH]); ctx.beginPath(); ctx.moveTo(x, y1); ctx.lineTo(x + totalW, y1); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x, y2); ctx.lineTo(x + totalW, y2); ctx.stroke(); ctx.strokeStyle = GRID_COLORS.border; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(x, yBot); ctx.lineTo(x + totalW, yBot); ctx.stroke(); for (let i = 0; i <= cols; i++) { const vx = x + i * cellW; ctx.beginPath(); ctx.moveTo(vx, yTop); ctx.lineTo(vx, yBot); ctx.stroke(); } } /** * 在四线三格中绘制拼音: * alphabetic 基线对齐第 3 条线(中下虚线), * 小写字母主体填充中间格(第 2~3 线之间), * 声调标记占据上格(第 1~2 线之间)。 */ private drawPinyinText( pinyin: string, cx: number, gridY: number, gridH: number, ) { const ctx = this.ctx; const fontSize = Math.round(gridH * 0.65); const baselineY = gridY + (gridH * 2) / 3; ctx.save(); ctx.font = `${fontSize}px ${fontFamilyOf(TONEOZ_PINYIN)}`; ctx.fillStyle = TRACING_COLORS.guide; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.fillText(pinyin, cx, baselineY); ctx.restore(); } }