import { BaseDrawService } from '../../../core/draw/baseDraw'; const COLS = 5; const ROWS = 10; const LAYOUT = { topGap: 10, leftMargin: 36, rightMargin: 36, bottomMargin: 40, instructionGap: 18, gridTopGap: 12, instructionFont: 'bold 16px "KaiTi", "STKaiti", "Microsoft Yahei", serif', instructionBoxSize: 12, instructionCheckSize: 14, instructionSymbolGap: 3, rowGap: 10, charBoxGap: 12, boxSize: 14, } as const; export interface WordTestSheetDrawData { words: string[]; pageNumber: number; } export default class WordTestDrawService extends BaseDrawService { constructor( canvas: Canvas, ctx: RenderingContext, options?: Record, ) { super(canvas, ctx, { title: '测字表', ...options, }); } async draw(data: WordTestSheetDrawData) { this.prepareDraw(); await this.drawHeaderAndDivider(); this.drawInstruction(); this.drawWordGrid(data.words); this.drawPageNumber(data.pageNumber); this.drawPrintFooter(); } private getContentRect() { const contentTop = this.currentY; const contentWidth = this.canvasWidth - LAYOUT.leftMargin - LAYOUT.rightMargin; const contentHeight = this.canvasHeight - contentTop - LAYOUT.bottomMargin; return { top: contentTop, left: LAYOUT.leftMargin, width: contentWidth, height: contentHeight, }; } private drawInstruction() { const { ctx, canvasWidth } = this; const y = this.currentY + LAYOUT.instructionGap; const { instructionFont, instructionBoxSize, instructionCheckSize, instructionSymbolGap, } = LAYOUT; const prefix = '认识的字'; const middle = '里打'; ctx.save(); ctx.font = instructionFont; ctx.fillStyle = '#322E25'; ctx.textBaseline = 'middle'; ctx.textAlign = 'left'; const prefixW = ctx.measureText(prefix).width; const middleW = ctx.measureText(middle).width; const totalW = prefixW + instructionSymbolGap + instructionBoxSize + instructionSymbolGap + middleW + instructionSymbolGap + instructionCheckSize; let x = (canvasWidth - totalW) / 2; ctx.fillText(prefix, x, y); x += prefixW + instructionSymbolGap; this.drawBox( ctx, x, y - instructionBoxSize / 2, instructionBoxSize, instructionBoxSize, ); x += instructionBoxSize + instructionSymbolGap; ctx.fillText(middle, x, y); x += middleW + instructionSymbolGap; this.drawCheckMark(ctx, x, y, instructionCheckSize); const metrics = ctx.measureText(prefix); const instructionFontSize = 16; const halfH = Math.max( metrics.actualBoundingBoxAscent ?? instructionFontSize * 0.5, metrics.actualBoundingBoxDescent ?? instructionFontSize * 0.5, ); const contentStartY = y + halfH + LAYOUT.gridTopGap; ctx.restore(); this.currentY = contentStartY; } /** 绘制对勾(替代文字 V) */ private drawCheckMark( ctx: RenderingContext, leftX: number, centerY: number, size: number, ) { ctx.save(); ctx.strokeStyle = '#322E25'; ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(leftX + size * 0.12, centerY + size * 0.02); ctx.lineTo(leftX + size * 0.38, centerY + size * 0.32); ctx.lineTo(leftX + size * 0.88, centerY - size * 0.34); ctx.stroke(); ctx.restore(); } private getCharVerticalMetrics( ctx: RenderingContext, char: string, fontSize: number, ) { ctx.textBaseline = 'alphabetic'; const metrics = ctx.measureText(char); const ascent = metrics.actualBoundingBoxAscent ?? fontSize * 0.82; const descent = metrics.actualBoundingBoxDescent ?? fontSize * 0.18; return { ascent, descent }; } /** 以 centerY 为视觉中心,绘制汉字与右侧方框 */ private drawCharWithBox( ctx: RenderingContext, char: string, startX: number, centerY: number, fontSize: number, ) { const { charBoxGap, boxSize } = LAYOUT; const { ascent, descent } = this.getCharVerticalMetrics( ctx, char, fontSize, ); ctx.textBaseline = 'alphabetic'; const textY = centerY + (ascent - descent) / 2; ctx.fillText(char, startX, textY); const boxX = startX + ctx.measureText(char).width + charBoxGap; const boxY = centerY - boxSize / 2; this.drawBox(ctx, boxX, boxY, boxSize, boxSize); } private drawWordGrid(words: string[]) { const { ctx } = this; const content = this.getContentRect(); const colWidth = content.width / COLS; const rowHeight = content.height / ROWS; const fontSize = 20; ctx.save(); ctx.font = `${fontSize}px "KaiTi", "STKaiti", "Microsoft Yahei", serif`; ctx.fillStyle = '#322E25'; ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; for (let row = 0; row < ROWS; row++) { for (let col = 0; col < COLS; col++) { const index = row * COLS + col; const char = words[index]; if (!char) continue; const cellLeft = content.left + col * colWidth; const cellCenterY = content.top + row * rowHeight + rowHeight / 2; const charWidth = ctx.measureText(char).width; const groupWidth = charWidth + LAYOUT.charBoxGap + LAYOUT.boxSize; const startX = cellLeft + (colWidth - groupWidth) / 2; this.drawCharWithBox(ctx, char, startX, cellCenterY, fontSize); } } ctx.restore(); } private drawPageNumber(pageNumber: number) { const { ctx, canvasWidth, canvasHeight } = this; ctx.save(); ctx.font = '12px "Microsoft Yahei", sans-serif'; ctx.fillStyle = '#7C766A'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(String(pageNumber), canvasWidth / 2, canvasHeight - 28); ctx.restore(); } }