683 lines
24 KiB
TypeScript
683 lines
24 KiB
TypeScript
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
|
import type { PaperType } from '../paperSheet.config';
|
|
|
|
/** 页面内容区边距(逻辑像素) */
|
|
const LAYOUT = {
|
|
topGap: 14,
|
|
leftMargin: 36,
|
|
rightMargin: 36,
|
|
bottomMargin: 36,
|
|
} as const;
|
|
|
|
interface ContentRect {
|
|
top: number;
|
|
left: number;
|
|
width: number;
|
|
height: number;
|
|
right: number;
|
|
bottom: number;
|
|
}
|
|
|
|
/** 将 #RRGGBB 转为带透明度的 rgba 字符串 */
|
|
function withAlpha(hex: string, alpha: number): string {
|
|
const normalized = hex.replace('#', '');
|
|
const full =
|
|
normalized.length === 3
|
|
? normalized
|
|
.split('')
|
|
.map((c) => c + c)
|
|
.join('')
|
|
: normalized;
|
|
const r = parseInt(full.slice(0, 2), 16);
|
|
const g = parseInt(full.slice(2, 4), 16);
|
|
const b = parseInt(full.slice(4, 6), 16);
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
}
|
|
|
|
export default class PaperDrawService extends BaseDrawService {
|
|
/** 主线条颜色 */
|
|
private color: string = '#333333';
|
|
|
|
constructor(
|
|
canvas: Canvas,
|
|
ctx: RenderingContext,
|
|
options?: Record<string, unknown>,
|
|
) {
|
|
super(canvas, ctx, {
|
|
title: '作业纸',
|
|
subTitle: '多种作业纸一键打印',
|
|
...options,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 绘制指定类型 + 颜色的作业纸
|
|
*/
|
|
async draw(paperType: PaperType, color: string) {
|
|
this.color = color;
|
|
this.options.title = this.optionTitleForType(paperType);
|
|
|
|
this.prepareDraw();
|
|
if (PaperDrawService.HEADER_TYPES.has(paperType)) {
|
|
// 听写、作业登记表:需要标注是谁的、哪天的,保留精简页眉
|
|
this.drawCompactHeader();
|
|
} else {
|
|
// 纯书写纸(田字格 / 米字格 / 方格 / 四线三格 / 信纸 / 横线 / 竖线)
|
|
// 不画页眉,最大化书写区,仅留顶部打印留白
|
|
this.currentY = 20;
|
|
}
|
|
|
|
const rect = this.getContentRect();
|
|
switch (paperType) {
|
|
case 'tian':
|
|
this.drawGridCells(rect, { diagonal: false });
|
|
break;
|
|
case 'mi':
|
|
this.drawGridCells(rect, { diagonal: true });
|
|
break;
|
|
case 'square':
|
|
this.drawSquareGrid(rect);
|
|
break;
|
|
case 'four-line':
|
|
this.drawFourLine(rect);
|
|
break;
|
|
case 'letter':
|
|
this.drawLetterPaper(rect);
|
|
break;
|
|
case 'horizontal':
|
|
this.drawHorizontalLines(rect);
|
|
break;
|
|
case 'vertical':
|
|
this.drawVerticalLines(rect);
|
|
break;
|
|
case 'dictation-hanzi':
|
|
this.drawDictation(rect, 'hanzi');
|
|
break;
|
|
case 'dictation-pinyin':
|
|
this.drawDictation(rect, 'pinyin');
|
|
break;
|
|
case 'homework-log':
|
|
this.drawHomeworkLog(rect);
|
|
break;
|
|
}
|
|
|
|
this.drawCenteredFooter();
|
|
}
|
|
|
|
/** 需要保留精简页眉(标题 + 姓名/日期)的纸张类型 */
|
|
private static readonly HEADER_TYPES = new Set<PaperType>([
|
|
'dictation-hanzi',
|
|
'dictation-pinyin',
|
|
'homework-log',
|
|
]);
|
|
|
|
/**
|
|
* 自定义精简页眉:无 Logo、无分割线,标题字体较小、整体高度更矮,
|
|
* 保留 姓名 / 日期 两个填写项(不含得分)。
|
|
* 绘制完成后设置 currentY 供正文使用。
|
|
*/
|
|
private drawCompactHeader() {
|
|
const { ctx, canvasWidth } = this;
|
|
const marginX = 24;
|
|
|
|
// 标题(较小字号,居中)
|
|
const titleY = 12;
|
|
ctx.fillStyle = '#322e25';
|
|
ctx.font = 'bold 15px "Microsoft Yahei"';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillText(
|
|
String(this.options.title || '作业纸'),
|
|
canvasWidth / 2,
|
|
titleY,
|
|
);
|
|
|
|
// 姓名 / 日期(与标题拉开间距)
|
|
const metaY = 48;
|
|
const metaFontPx = 11;
|
|
ctx.font = `${metaFontPx}px "Microsoft Yahei"`;
|
|
ctx.fillStyle = '#7c766a';
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'top';
|
|
|
|
const innerLeft = marginX + 6;
|
|
const innerRight = canvasWidth - marginX - 6;
|
|
const innerW = innerRight - innerLeft;
|
|
const colW = innerW / 2;
|
|
|
|
const drawMetaField = (label: string, x: number, right: number) => {
|
|
ctx.fillText(label, x, metaY);
|
|
const labelW = ctx.measureText(label).width;
|
|
const lineY = metaY + metaFontPx + 3;
|
|
ctx.strokeStyle = '#E5DCC9';
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + labelW + 2, lineY);
|
|
ctx.lineTo(Math.max(x + labelW + 2, right), lineY);
|
|
ctx.stroke();
|
|
};
|
|
|
|
drawMetaField('姓名:', innerLeft, innerLeft + colW - 24);
|
|
drawMetaField('日期:', innerLeft + colW, innerRight);
|
|
|
|
// 正文起始 Y(不画分割线)
|
|
this.currentY = metaY + metaFontPx + 12;
|
|
}
|
|
|
|
/** 底部品牌水印:居中展示 */
|
|
private drawCenteredFooter() {
|
|
const { ctx, canvasWidth, canvasHeight } = this;
|
|
ctx.save();
|
|
ctx.font = 'bold 13px "Microsoft Yahei"';
|
|
ctx.fillStyle = '#7c766a';
|
|
ctx.globalAlpha = 0.35;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'bottom';
|
|
ctx.fillText('涂鸦丫小程序', canvasWidth / 2, canvasHeight - 16);
|
|
ctx.restore();
|
|
}
|
|
|
|
private optionTitleForType(paperType: PaperType): string {
|
|
const map: Record<PaperType, string> = {
|
|
tian: '田字格作业纸',
|
|
mi: '米字格作业纸',
|
|
square: '方格作业纸',
|
|
'four-line': '四线三格作业纸',
|
|
letter: '信纸',
|
|
horizontal: '横线本',
|
|
vertical: '竖格作业纸',
|
|
'dictation-hanzi': '汉字听写',
|
|
'dictation-pinyin': '拼音 / 英语听写',
|
|
'homework-log': '作业登记表',
|
|
};
|
|
return map[paperType];
|
|
}
|
|
|
|
private getContentRect(): ContentRect {
|
|
const top = this.currentY + LAYOUT.topGap;
|
|
const left = LAYOUT.leftMargin;
|
|
const width = this.canvasWidth - LAYOUT.leftMargin - LAYOUT.rightMargin;
|
|
const height = this.canvasHeight - top - LAYOUT.bottomMargin;
|
|
return {
|
|
top,
|
|
left,
|
|
width,
|
|
height,
|
|
right: left + width,
|
|
bottom: top + height,
|
|
};
|
|
}
|
|
|
|
/** 主色 / 辅助色 */
|
|
private get borderColor(): string {
|
|
return this.color;
|
|
}
|
|
private get midColor(): string {
|
|
return withAlpha(this.color, 0.55);
|
|
}
|
|
private get diagColor(): string {
|
|
return withAlpha(this.color, 0.4);
|
|
}
|
|
|
|
// ─────────────────────────── 田字格 / 米字格 ───────────────────────────
|
|
private drawGridCells(rect: ContentRect, opts: { diagonal: boolean }) {
|
|
const { ctx } = this;
|
|
const targetCell = 46;
|
|
const gap = 8;
|
|
|
|
const cols = Math.max(
|
|
1,
|
|
Math.floor((rect.width + gap) / (targetCell + gap)),
|
|
);
|
|
const rows = Math.max(
|
|
1,
|
|
Math.floor((rect.height + gap) / (targetCell + gap)),
|
|
);
|
|
|
|
const cell = Math.min(
|
|
(rect.width - (cols - 1) * gap) / cols,
|
|
(rect.height - (rows - 1) * gap) / rows,
|
|
);
|
|
|
|
// 居中排布
|
|
const gridW = cols * cell + (cols - 1) * gap;
|
|
const startX = rect.left + (rect.width - gridW) / 2;
|
|
const startY = rect.top;
|
|
|
|
for (let r = 0; r < rows; r++) {
|
|
for (let c = 0; c < cols; c++) {
|
|
const x = startX + c * (cell + gap);
|
|
const y = startY + r * (cell + gap);
|
|
this.drawSingleGridCell(ctx, x, y, cell, opts.diagonal);
|
|
}
|
|
}
|
|
}
|
|
|
|
private drawSingleGridCell(
|
|
ctx: RenderingContext,
|
|
x: number,
|
|
y: number,
|
|
size: number,
|
|
diagonal: boolean,
|
|
) {
|
|
// 外框实线
|
|
ctx.strokeStyle = this.borderColor;
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([]);
|
|
ctx.strokeRect(x, y, size, size);
|
|
|
|
const cx = x + size / 2;
|
|
const cy = y + size / 2;
|
|
|
|
// 十字虚线
|
|
ctx.strokeStyle = this.midColor;
|
|
ctx.setLineDash([4, 3]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, y);
|
|
ctx.lineTo(cx, y + size);
|
|
ctx.moveTo(x, cy);
|
|
ctx.lineTo(x + size, cy);
|
|
ctx.stroke();
|
|
|
|
// 对角虚线(米字格)
|
|
if (diagonal) {
|
|
ctx.strokeStyle = this.diagColor;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, y);
|
|
ctx.lineTo(x + size, y + size);
|
|
ctx.moveTo(x + size, y);
|
|
ctx.lineTo(x, y + size);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.setLineDash([]);
|
|
}
|
|
|
|
// ─────────────────────────── 方格 ───────────────────────────
|
|
private drawSquareGrid(rect: ContentRect) {
|
|
const { ctx } = this;
|
|
const targetCell = 27;
|
|
|
|
const cols = Math.max(1, Math.round(rect.width / targetCell));
|
|
const cell = rect.width / cols;
|
|
const rows = Math.max(1, Math.floor(rect.height / cell));
|
|
|
|
const gridW = cols * cell;
|
|
const gridH = rows * cell;
|
|
const startX = rect.left;
|
|
const startY = rect.top;
|
|
|
|
ctx.strokeStyle = this.borderColor;
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([]);
|
|
|
|
ctx.beginPath();
|
|
for (let c = 0; c <= cols; c++) {
|
|
const x = startX + c * cell;
|
|
ctx.moveTo(x, startY);
|
|
ctx.lineTo(x, startY + gridH);
|
|
}
|
|
for (let r = 0; r <= rows; r++) {
|
|
const y = startY + r * cell;
|
|
ctx.moveTo(startX, y);
|
|
ctx.lineTo(startX + gridW, y);
|
|
}
|
|
ctx.stroke();
|
|
}
|
|
|
|
// ─────────────────────────── 四线三格 ───────────────────────────
|
|
private drawFourLine(rect: ContentRect) {
|
|
const { ctx } = this;
|
|
const groupHeight = 34; // 一组四线三格的总高
|
|
const groupGap = 24; // 组间距
|
|
const unit = groupHeight + groupGap;
|
|
|
|
const count = Math.max(1, Math.floor((rect.height + groupGap) / unit));
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const topY = rect.top + i * unit;
|
|
const l1 = topY;
|
|
const l2 = topY + groupHeight / 3;
|
|
const l3 = topY + (groupHeight * 2) / 3;
|
|
const l4 = topY + groupHeight;
|
|
|
|
// 上下两条实线
|
|
ctx.strokeStyle = this.borderColor;
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([]);
|
|
this.strokeHLine(rect.left, rect.right, l1);
|
|
this.strokeHLine(rect.left, rect.right, l4);
|
|
|
|
// 中间两条虚线
|
|
ctx.strokeStyle = this.midColor;
|
|
ctx.setLineDash([4, 3]);
|
|
this.strokeHLine(rect.left, rect.right, l2);
|
|
this.strokeHLine(rect.left, rect.right, l3);
|
|
ctx.setLineDash([]);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────── 信纸 ───────────────────────────
|
|
private drawLetterPaper(rect: ContentRect) {
|
|
const { ctx } = this;
|
|
const lineGap = 34;
|
|
const innerTop = rect.top + 10;
|
|
const innerBottom = rect.bottom - 24;
|
|
const lineCount = Math.floor((innerBottom - innerTop) / lineGap);
|
|
|
|
// 顶部双线
|
|
ctx.strokeStyle = this.borderColor;
|
|
ctx.lineWidth = 1.6;
|
|
ctx.setLineDash([]);
|
|
this.strokeHLine(rect.left, rect.right, rect.top);
|
|
this.strokeHLine(rect.left, rect.right, rect.top + 4);
|
|
|
|
// 中间横线
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeStyle = withAlpha(this.color, 0.75);
|
|
for (let i = 1; i <= lineCount; i++) {
|
|
const y = innerTop + i * lineGap;
|
|
if (y >= innerBottom) break;
|
|
this.strokeHLine(rect.left, rect.right, y);
|
|
}
|
|
|
|
// 底部双线
|
|
ctx.strokeStyle = this.borderColor;
|
|
ctx.lineWidth = 1.6;
|
|
this.strokeHLine(rect.left, rect.right, innerBottom);
|
|
this.strokeHLine(rect.left, rect.right, innerBottom + 4);
|
|
|
|
// 右下角「第 页」
|
|
ctx.fillStyle = this.borderColor;
|
|
ctx.font = '13px "Microsoft Yahei"';
|
|
ctx.textAlign = 'right';
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillText('第 页', rect.right, innerBottom + 10);
|
|
}
|
|
|
|
// ─────────────────────────── 横线 ───────────────────────────
|
|
private drawHorizontalLines(rect: ContentRect) {
|
|
const { ctx } = this;
|
|
const lineGap = 34;
|
|
const count = Math.floor(rect.height / lineGap);
|
|
|
|
ctx.strokeStyle = this.borderColor;
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([]);
|
|
for (let i = 0; i <= count; i++) {
|
|
const y = rect.top + i * lineGap;
|
|
if (y > rect.bottom) break;
|
|
this.strokeHLine(rect.left, rect.right, y);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────── 竖线 ───────────────────────────
|
|
private drawVerticalLines(rect: ContentRect) {
|
|
const { ctx } = this;
|
|
const targetGap = 34;
|
|
const count = Math.max(1, Math.round(rect.width / targetGap));
|
|
const gap = rect.width / count;
|
|
|
|
// 上下边界实线
|
|
ctx.strokeStyle = this.borderColor;
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([]);
|
|
this.strokeHLine(rect.left, rect.right, rect.top);
|
|
this.strokeHLine(rect.left, rect.right, rect.bottom);
|
|
|
|
// 竖线
|
|
ctx.beginPath();
|
|
for (let c = 0; c <= count; c++) {
|
|
const x = rect.left + c * gap;
|
|
ctx.moveTo(x, rect.top);
|
|
ctx.lineTo(x, rect.bottom);
|
|
}
|
|
ctx.stroke();
|
|
}
|
|
|
|
// ─────────────────────────── 听写本(汉字 / 拼音英语) ───────────────────────────
|
|
/** 汉字听写:田字格行布局参数 */
|
|
private static readonly DICT_HANZI = { cell: 40, rowGap: 20 };
|
|
/** 拼音 / 英语听写:四线三格行布局参数 */
|
|
private static readonly DICT_PINYIN = { groupHeight: 34, groupGap: 22 };
|
|
|
|
private drawDictation(rect: ContentRect, variant: 'hanzi' | 'pinyin') {
|
|
// 先按行布局算出左侧书写区实际占用高度,使右侧订正栏与其等高
|
|
const usedHeight = this.dictationUsedHeight(rect.height, variant);
|
|
const alignedRect: ContentRect = {
|
|
...rect,
|
|
height: usedHeight,
|
|
bottom: rect.top + usedHeight,
|
|
};
|
|
|
|
// 右侧订正栏(与左侧等高),返回左侧书写区右边界
|
|
const leftAreaRight = this.drawCorrectionColumn(alignedRect);
|
|
const leftRect: ContentRect = {
|
|
...alignedRect,
|
|
width: leftAreaRight - rect.left,
|
|
right: leftAreaRight,
|
|
};
|
|
|
|
if (variant === 'hanzi') {
|
|
this.drawDictationHanzi(leftRect);
|
|
} else {
|
|
this.drawDictationPinyin(leftRect);
|
|
}
|
|
}
|
|
|
|
/** 计算听写本左侧行区实际占用高度(末行不含尾部间距) */
|
|
private dictationUsedHeight(
|
|
available: number,
|
|
variant: 'hanzi' | 'pinyin',
|
|
): number {
|
|
if (variant === 'hanzi') {
|
|
const { cell, rowGap } = PaperDrawService.DICT_HANZI;
|
|
const unit = cell + rowGap;
|
|
const rows = Math.max(1, Math.floor((available + rowGap) / unit));
|
|
return rows * unit - rowGap;
|
|
}
|
|
const { groupHeight, groupGap } = PaperDrawService.DICT_PINYIN;
|
|
const unit = groupHeight + groupGap;
|
|
const count = Math.max(1, Math.floor((available + groupGap) / unit));
|
|
return count * unit - groupGap;
|
|
}
|
|
|
|
/** 绘制右侧订正栏,返回左侧书写区的右边界 x */
|
|
private drawCorrectionColumn(rect: ContentRect): number {
|
|
const { ctx } = this;
|
|
const correctW = 120;
|
|
const gapToCorrect = 16;
|
|
const correctX = rect.right - correctW;
|
|
|
|
ctx.strokeStyle = this.borderColor;
|
|
ctx.lineWidth = 1.2;
|
|
ctx.setLineDash([]);
|
|
ctx.strokeRect(correctX, rect.top, correctW, rect.height);
|
|
|
|
ctx.fillStyle = this.borderColor;
|
|
ctx.font = 'bold 15px "Microsoft Yahei"';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillText('订正栏', correctX + correctW / 2, rect.top + 14);
|
|
this.strokeHLine(correctX, correctX + correctW, rect.top + 40);
|
|
|
|
return correctX - gapToCorrect;
|
|
}
|
|
|
|
/** 汉字听写:整齐的田字格行(无上方提示线) */
|
|
private drawDictationHanzi(rect: ContentRect) {
|
|
const { ctx } = this;
|
|
const { cell, rowGap } = PaperDrawService.DICT_HANZI;
|
|
const unit = cell + rowGap;
|
|
const rowCount = Math.max(1, Math.round((rect.height + rowGap) / unit));
|
|
|
|
const cols = Math.max(1, Math.floor(rect.width / cell));
|
|
|
|
for (let r = 0; r < rowCount; r++) {
|
|
const rowTop = rect.top + r * unit;
|
|
for (let c = 0; c < cols; c++) {
|
|
const x = rect.left + c * cell;
|
|
this.drawSingleGridCell(ctx, x, rowTop, cell, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 拼音 / 英语听写:四线三格行 */
|
|
private drawDictationPinyin(rect: ContentRect) {
|
|
const { ctx } = this;
|
|
const { groupHeight, groupGap } = PaperDrawService.DICT_PINYIN;
|
|
const unit = groupHeight + groupGap;
|
|
const count = Math.max(1, Math.round((rect.height + groupGap) / unit));
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const topY = rect.top + i * unit;
|
|
const l1 = topY;
|
|
const l2 = topY + groupHeight / 3;
|
|
const l3 = topY + (groupHeight * 2) / 3;
|
|
const l4 = topY + groupHeight;
|
|
|
|
// 上下实线
|
|
ctx.strokeStyle = this.borderColor;
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([]);
|
|
this.strokeHLine(rect.left, rect.right, l1);
|
|
this.strokeHLine(rect.left, rect.right, l4);
|
|
|
|
// 中间两条虚线
|
|
ctx.strokeStyle = this.midColor;
|
|
ctx.setLineDash([4, 3]);
|
|
this.strokeHLine(rect.left, rect.right, l2);
|
|
this.strokeHLine(rect.left, rect.right, l3);
|
|
ctx.setLineDash([]);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────── 作业登记表 ───────────────────────────
|
|
private drawHomeworkLog(rect: ContentRect) {
|
|
const { ctx } = this;
|
|
|
|
const subjects = ['语文', '数学', '英语', '其他'];
|
|
const rowsPerSubject = 5;
|
|
const totalRows = subjects.length * rowsPerSubject;
|
|
|
|
const subjectColW = 60; // 左侧科目列
|
|
const numColW = 44; // 序号列
|
|
const checkColW = 56; // 右侧完成勾选列
|
|
const contentColX = rect.left + subjectColW + numColW;
|
|
const checkColX = rect.right - checkColW;
|
|
|
|
const rowHeight = rect.height / totalRows;
|
|
const tableTop = rect.top;
|
|
const tableBottom = rect.top + rowHeight * totalRows;
|
|
|
|
ctx.setLineDash([]);
|
|
|
|
// 外框
|
|
ctx.strokeStyle = this.borderColor;
|
|
ctx.lineWidth = 1.2;
|
|
ctx.strokeRect(rect.left, tableTop, rect.width, tableBottom - tableTop);
|
|
|
|
// 竖分割线
|
|
ctx.lineWidth = 1;
|
|
this.strokeVLine(rect.left + subjectColW, tableTop, tableBottom);
|
|
this.strokeVLine(contentColX, tableTop, tableBottom);
|
|
this.strokeVLine(checkColX, tableTop, tableBottom);
|
|
|
|
ctx.font = '13px "Microsoft Yahei"';
|
|
|
|
for (let s = 0; s < subjects.length; s++) {
|
|
const blockTop = tableTop + s * rowsPerSubject * rowHeight;
|
|
const blockBottom = blockTop + rowsPerSubject * rowHeight;
|
|
|
|
// 科目分组分隔(实线)
|
|
ctx.strokeStyle = this.borderColor;
|
|
ctx.lineWidth = 1.2;
|
|
this.strokeHLine(rect.left, rect.right, blockBottom);
|
|
|
|
// 科目名称(竖排居中)
|
|
ctx.fillStyle = this.borderColor;
|
|
ctx.font = 'bold 15px "Microsoft Yahei"';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
const subjectCx = rect.left + subjectColW / 2;
|
|
const subjectCy = (blockTop + blockBottom) / 2;
|
|
this.drawVerticalText(subjects[s], subjectCx, subjectCy, 18);
|
|
|
|
for (let r = 0; r < rowsPerSubject; r++) {
|
|
const rowTop = blockTop + r * rowHeight;
|
|
const rowCy = rowTop + rowHeight / 2;
|
|
|
|
// 序号
|
|
ctx.fillStyle = this.borderColor;
|
|
ctx.font = '13px "Microsoft Yahei"';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(
|
|
String(r + 1),
|
|
rect.left + subjectColW + numColW / 2,
|
|
rowCy,
|
|
);
|
|
|
|
// 内容区书写虚线
|
|
ctx.strokeStyle = this.midColor;
|
|
ctx.lineWidth = 0.8;
|
|
ctx.setLineDash([3, 3]);
|
|
this.strokeHLine(
|
|
contentColX + 8,
|
|
checkColX - 8,
|
|
rowCy + rowHeight / 2 - 4,
|
|
);
|
|
ctx.setLineDash([]);
|
|
|
|
// 勾选方框
|
|
const boxSize = Math.min(18, rowHeight - 10);
|
|
ctx.strokeStyle = this.borderColor;
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(
|
|
checkColX + (checkColW - boxSize) / 2,
|
|
rowCy - boxSize / 2,
|
|
boxSize,
|
|
boxSize,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────── 工具方法 ───────────────────────────
|
|
private strokeHLine(x1: number, x2: number, y: number) {
|
|
const { ctx } = this;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x1, y);
|
|
ctx.lineTo(x2, y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
private strokeVLine(x: number, y1: number, y2: number) {
|
|
const { ctx } = this;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, y1);
|
|
ctx.lineTo(x, y2);
|
|
ctx.stroke();
|
|
}
|
|
|
|
/** 竖排文字(逐字向下) */
|
|
private drawVerticalText(
|
|
text: string,
|
|
cx: number,
|
|
cy: number,
|
|
lineHeight: number,
|
|
) {
|
|
const { ctx } = this;
|
|
const chars = text.split('');
|
|
const totalH = chars.length * lineHeight;
|
|
let y = cy - totalH / 2 + lineHeight / 2;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
for (const ch of chars) {
|
|
ctx.fillText(ch, cx, y);
|
|
y += lineHeight;
|
|
}
|
|
}
|
|
}
|