Files
2026-08-25 15:40:38 +08:00

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;
}
}
}