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, ) { 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([ '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 = { 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; } } }