import { PINYIN_SECTIONS, type PinyinSection, type PinyinSubCategory, SHENGMU, DAN_YUNMU, FU_YUNMU, QIANBI_YUNMU, HOUBI_YUNMU, TESHU_YUNMU, ZHENGTI_RENDU, PINGSHEYIN, QIAOSHEYIN, } from '../../../core/data/pinyin'; import { PINYIN_STYLE } from '../../shared/pinyinConstants'; import { PinyinBaseDraw } from '../../shared/pinyinBaseDraw'; import type { PinyinDictationData } from './pinyinDictationDraw'; const { MARGIN_X, CELL_H, CELL_H_V2, ROW_GAP, ROW_GAP_V2, SECTION_GAP, TITLE_AFTER_GAP, CONTENT_TOP_GAP, SECTION_TITLE_FONT, SUB_LABEL_FONT, SECTION_TITLE_COLOR, SUB_LABEL_COLOR, PINYIN_FONT_SIZE_RATIO, PINYIN_FONT_SIZE_RATIO_V2, } = PINYIN_STYLE; const MIN_CELL_W = 44; const SUB_INDENT = 20; 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; /** V1 + V2 描红 / 默写练习 */ export default class PinyinDictationSheetDraw extends PinyinBaseDraw { private cellW = MIN_CELL_W; private contentW = 0; private currentCellH = CELL_H; private currentRowGap = ROW_GAP; private currentFontSizeRatio = PINYIN_FONT_SIZE_RATIO; async draw(data: PinyinDictationData) { this.prepareDraw(); this.contentW = this.canvasWidth - 2 * MARGIN_X; if (data.mode.endsWith('-v2')) { this.cellW = this.contentW / 8; this.currentCellH = CELL_H_V2; this.currentRowGap = ROW_GAP_V2; this.currentFontSizeRatio = PINYIN_FONT_SIZE_RATIO_V2; } else { const maxCols = Math.floor(this.contentW / MIN_CELL_W); this.cellW = this.contentW / maxCols; this.currentCellH = CELL_H; this.currentRowGap = ROW_GAP; this.currentFontSizeRatio = PINYIN_FONT_SIZE_RATIO; } await this.drawHeaderAndDivider(); this.drawContent(data); } // ── 内容入口 ── private drawContent(data: PinyinDictationData) { const isTracing = data.mode.includes('tracing'); let y = this.currentY + CONTENT_TOP_GAP; if (data.mode.endsWith('-v2')) { this.drawV2Content(y, isTracing); } else { for (const section of PINYIN_SECTIONS) { y = this.drawV1Section(section, y, isTracing); } } } // ── V1:原有布局(声母 / 韵母 / 整体认读 + 子分类) ── private drawV1Section( section: PinyinSection, startY: number, isTracing: boolean, ): number { let y = startY; y = this.drawTitle(section.title, section.count, y); y += TITLE_AFTER_GAP; y = this.drawItemRows( section.items, MARGIN_X, y, isTracing, this.cellW, this.currentCellH, ); 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.drawLabeledBlock( `${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.drawLabeledBlock( `翘舌音(${qiaoshe.count}个)`, qiaoshe.items, subX, startY, isTracing, ); bottomY = Math.max(bottomY, endY); } if (pingshe) { const endY = this.drawLabeledBlock( `平舌音(${pingshe.count}个)`, pingshe.items, col2X, startY, isTracing, ); bottomY = Math.max(bottomY, endY); } return bottomY + SUB_BLOCK_GAP; } private drawLabeledBlock( 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, this.currentCellH); } // ── V2:8 列 + 扁平分块 ── private drawV2Content(startY: number, isTracing: boolean): void { const sections = [ { title: '声母', count: 23, items: SHENGMU }, { title: '单韵母', count: 6, items: DAN_YUNMU }, { title: '复韵母', count: 8, items: FU_YUNMU }, { title: '前鼻韵母', count: 5, items: QIANBI_YUNMU }, { title: '后鼻韵母', count: 4, items: HOUBI_YUNMU }, { title: '特殊韵母', count: 1, items: TESHU_YUNMU }, { title: '整体认读音节', count: 16, items: ZHENGTI_RENDU }, { title: '平舌音', count: 3, items: PINGSHEYIN }, { title: '翘舌音', count: 4, items: QIAOSHEYIN }, ]; let y = startY; for (const section of sections) { y = this.drawTitle(section.title, section.count, y); y += TITLE_AFTER_GAP; y = this.drawFixedColsRows(section.items, MARGIN_X, y, isTracing, 8); y += SECTION_GAP; } } // ── 共用绘制方法 ── private drawTitle(title: string, count: number, y: number): number { const ctx = this.ctx; ctx.font = SECTION_TITLE_FONT; ctx.fillStyle = SECTION_TITLE_COLOR; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText(title, MARGIN_X, y); const titleW = ctx.measureText(title).width; ctx.fillStyle = SUB_LABEL_COLOR; ctx.fillText(`(${count}个)`, MARGIN_X + titleW, y); return y; } 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)); return this.drawFixedColsRows(items, startX, startY, isTracing, maxCols); } private drawFixedColsRows( items: string[], startX: number, startY: number, isTracing: boolean, cols: number, ): number { let y = startY; const cellW = this.cellW; const cellH = this.currentCellH; for (let i = 0; i < items.length; i += cols) { const rowItems = items.slice(i, i + cols); this.drawFourLineRow(startX, y, cols, cellW, cellH); if (isTracing) { for (let j = 0; j < rowItems.length; j++) { const cx = startX + j * cellW + cellW / 2; this.drawPinyinText(rowItems[j], cx, y, cellH, this.currentFontSizeRatio); } } y += cellH + this.currentRowGap; } return y; } }