feat: 新增幼儿测字表

This commit is contained in:
R524809
2026-06-11 14:39:06 +08:00
parent a6e8f4df02
commit 25a37fc5e2
15 changed files with 1046 additions and 52 deletions
@@ -0,0 +1,227 @@
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<string, unknown>,
) {
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();
}
}