228 lines
6.6 KiB
TypeScript
228 lines
6.6 KiB
TypeScript
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();
|
||
}
|
||
}
|