feat: 新增幼儿测字表
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user