import { BaseDrawService } from './baseDraw'; import { CharacterItem } from '../types/characterType'; /** * 仅支持 M/L/Z 的简单 SVG 路径解析与绘制 */ interface DrawSvgPathCommandsParams { ctx: RenderingContext; pathD: string; offsetX: number; offsetY: number; scale: number; } function drawSvgPathCommands({ ctx, pathD, offsetX, offsetY, scale, }: DrawSvgPathCommandsParams) { // console.log('drawSvgPathCommands 参数:', { pathD, offsetX, offsetY, scale }); // 精度处理函数:避免浮点数精度问题 const roundToPrecision = (num: number, precision: number = 2): number => { return ( Math.round(num * Math.pow(10, precision)) / Math.pow(10, precision) ); }; // 新的解析方法:按命令片段解析 // 使用正则表达式匹配从字母开头到下一个字母(或结尾)的片段 const commandRegex = /([MLZ])([^MLZ]*?)(?=[MLZ]|$)/g; const commands: Array<{ cmd: string; coords: string }> = []; let match; while ((match = commandRegex.exec(pathD)) !== null) { const cmd = match[1]; const coords = match[2].trim(); commands.push({ cmd, coords }); } // console.log('解析的命令片段:', commands); let commandCount = 0; // 优化的坐标解析和绘制函数 const parseAndDrawCoords = ( coords: string, cmd: string, drawFunction: (x: number, y: number) => void, ) => { // 更严格的坐标解析:支持负数和小数 const coordParts = coords .split(/\s+/) .filter((part) => part.trim() !== ''); if (coordParts.length >= 2) { const x = parseFloat(coordParts[0]); const y = parseFloat(coordParts[1]); if (!isNaN(x) && !isNaN(y) && isFinite(x) && isFinite(y)) { // 计算最终坐标并处理精度 const finalX = roundToPrecision(offsetX + x * scale); const finalY = roundToPrecision(offsetY + y * scale); // console.log(`${cmd}: 绘制到 (${finalX}, ${finalY}) [原始: (${x}, ${y})]`); drawFunction(finalX, finalY); } else { console.warn( `${cmd}: 坐标解析失败 - x=${x}, y=${y}, 原始坐标: "${coords}"`, ); } } else { console.warn( `${cmd}: 坐标数量不足,需要2个,实际${coordParts.length}个,原始: "${coords}"`, ); } }; // 命令处理映射表,提高可读性和可扩展性 const commandHandlers: Record void> = { M: (coords) => parseAndDrawCoords(coords, 'M', (x, y) => ctx.moveTo(x, y)), L: (coords) => parseAndDrawCoords(coords, 'L', (x, y) => ctx.lineTo(x, y)), Z: () => { // console.log('Z: 闭合路径'); // 注意:不要在这里调用 closePath(),因为 drawStrokes 中会统一处理 }, }; for (const { cmd, coords } of commands) { commandCount++; // console.log(`命令 ${commandCount}: ${cmd}, 坐标: "${coords}"`); const handler = commandHandlers[cmd]; if (handler) { handler(coords); } else { console.warn(`未知命令: ${cmd}`); } } // console.log(`总共处理了 ${commandCount} 个命令`); } interface DrawStrokesParams { ctx: RenderingContext; strokes: string[]; offsetX: number; offsetY: number; size: number; uptoInclusive: number; fillStyle: string; strokeStyle: string; lineWidth: number; } function drawStrokes({ ctx, strokes, offsetX, offsetY, size, uptoInclusive, fillStyle, strokeStyle, lineWidth, }: DrawStrokesParams) { // 模板中使用 54x54 的视窗尺寸 const viewBoxSize = 54; // 添加内边距:在田字格四周预留空间,避免笔画贴边 const padding = size * 0.1; // 内边距为田字格大小的10% const contentSize = size - padding * 2; // 实际绘制区域大小 const contentScale = contentSize / viewBoxSize; // 调整后的缩放比例 // 计算内容区域的起始位置(居中) const contentOffsetX = offsetX + padding; const contentOffsetY = offsetY + padding; // console.log('drawStrokes 参数:', { // strokesCount: strokes.length, // uptoInclusive, // offsetX, // offsetY, // size, // padding, // contentSize, // contentOffsetX, // contentOffsetY, // originalScale: scale, // contentScale, // fillStyle, // strokeStyle, // lineWidth // }); for (let s = 0; s <= uptoInclusive && s < strokes.length; s++) { // console.log(`绘制第 ${s} 个笔画:`, strokes[s]); ctx.beginPath(); drawSvgPathCommands({ ctx, pathD: strokes[s], offsetX: contentOffsetX, offsetY: contentOffsetY, scale: contentScale, }); ctx.fillStyle = fillStyle; ctx.strokeStyle = strokeStyle; ctx.lineWidth = lineWidth; ctx.fill(); ctx.stroke(); ctx.closePath(); } } interface DrawTianZiGridParams { ctx: RenderingContext; x: number; y: number; size: number; lineColor?: string; boldColor?: string; } function drawTianZiGrid({ ctx, x, y, size, lineColor = '#e0e0e0', boldColor = '#cccccc', }: DrawTianZiGridParams) { // 外框(逻辑像素) ctx.strokeStyle = boldColor; ctx.lineWidth = 1; // 2/3≈1,逻辑像素 ctx.strokeRect(x - size / 2, y - size / 2, size, size); // 中线(逻辑像素) ctx.strokeStyle = lineColor; ctx.lineWidth = 1; // 逻辑像素 ctx.beginPath(); // 竖线 ctx.moveTo(x, y - size / 2); ctx.lineTo(x, y + size / 2); // 横线 ctx.moveTo(x - size / 2, y); ctx.lineTo(x + size / 2, y); ctx.stroke(); ctx.closePath(); // 对角线(淡,逻辑像素) ctx.strokeStyle = '#eeeeee'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x - size / 2, y - size / 2); ctx.lineTo(x + size / 2, y + size / 2); ctx.moveTo(x + size / 2, y - size / 2); ctx.lineTo(x - size / 2, y + size / 2); ctx.stroke(); ctx.closePath(); } class WordDrawService extends BaseDrawService { constructor( canvas: Canvas, ctx: RenderingContext, options?: Record, ) { options = options || {}; super(canvas, ctx, { title: '田字格 练 字 贴', subTitle: '按笔画临摹练习', ...options, }); } /** * 绘制页眉和基础布局 */ async drawLayout() { this.setPrintConfig(); this.clear(); this.setPaper(); // 绘制Header if (this.headerType !== 'minimal') { await this.drawHeader(); } else { this.drawMiniHeader(); } this.drawDivider(); this.drawContentEmpty(); } async drawContentEmpty() { this.clearContentArea(); this.drawContent(null); } /** * 绘制田字格和练字内容 * @param wordsMap 形如 { "其": ["M.. L.. Z", ...], ... },不超过 10 个汉字 */ drawPracticeContent(characters: CharacterItem[]) { // 清空内容区域(页眉以下的部分) this.clearContentArea(); // 绘制田字格和练字内容 this.drawContent(characters); } /** * 清空内容区域(页眉以下的部分) */ private clearContentArea() { const { ctx } = this; const contentStartY = this.currentY; // 清空页眉以下的所有内容(使用逻辑像素) ctx.fillStyle = '#fff'; ctx.fillRect( 0, contentStartY, this.canvasWidth, this.canvasHeight - contentStartY, ); } /** * 绘制正文内容:两阶段绘制 - 先绘制空田字格,再绘制练字内容 */ /** * 绘制正文内容:包括田字格的排布和内容 * * minGap 作用解释: * minGap(最小列间距)用于田字格水平方向(每一行格子之间的间距)的初始最小值。它保证多个田字格在一行内不会紧贴排布、而是有一个最小的间隔,整体看起来不会拥挤。后续还会结合画布实际剩余空间动态调整为更合适的实际间距 actualColGap。 * * rowGap 作用解释: * rowGap(行间距)用于田字格竖直方向(每一行田字格之间的间隔)的初始最小值。它防止行与行的田字格上下贴得太紧,增加一定的可视呼吸空间。之后同样会结合剩余空间动态调整为实际间距 actualRowGap。 */ drawContent(characterData: CharacterItem[] | null) { const { ctx } = this; // 布局参数(逻辑像素,原始尺寸除以3) const topGap = 17; // 50/3≈17 const leftMargin = 40; // 120/3=40 const rightMargin = 40; // 120/3=40 const bottomMargin = 40; // 120/3=40 const contentTop = this.currentY + topGap; const contentWidth = this.canvasWidth - leftMargin - rightMargin; const contentHeight = this.canvasHeight - contentTop - bottomMargin; const cellSize = 47; // 140/3≈47 // 统一通过 getMaxGridLayout 获取最大行列数 const { maxRow, maxCol } = this.getMaxGridLayout(); // 动态调整间距以充分利用空间(在允许的最小间距基础上弹性扩展以适配画布) const actualRowGap = maxRow > 1 ? (contentHeight - maxRow * cellSize) / (maxRow - 1) : 0; const actualColGap = maxCol > 1 ? (contentWidth - maxCol * cellSize) / (maxCol - 1) : 0; // console.log(`布局信息: 行数=${rowNumber}, 列数=${columnNumber}, 行间距=${actualRowGap.toFixed(1)}, 列间距=${actualColGap.toFixed(1)}`); // 第一阶段:绘制所有空田字格 this.drawEmptyGrids({ ctx, startX: leftMargin, startY: contentTop, cellSize, colGap: actualColGap, rowGap: actualRowGap, maxRow, maxCol, }); if (characterData && characterData.length > 0) { // 第二阶段:绘制练字内容 this.drawPracticeContentInternal({ ctx, characterData, startX: leftMargin, startY: contentTop, cellSize, colGap: actualColGap, rowGap: actualRowGap, maxCol, maxRow, }); } } /** * 获取当前页面可绘制田字格的最大行数与列数(与绘制使用同一套计算规则) */ getMaxGridLayout(): { maxRow: number; maxCol: number } { // 布局参数需与 drawContent 保持一致(逻辑像素) const topGap = 17; // 50/3≈17 const leftMargin = 40; // 120/3=40 const rightMargin = 40; // 120/3=40 const bottomMargin = 40; // 120/3=40 const contentTop = this.currentY + topGap; const contentWidth = this.canvasWidth - leftMargin - rightMargin; const contentHeight = this.canvasHeight - contentTop - bottomMargin; const cellSize = 47; // 140/3≈47 const minGap = 8; // 24/3=8 const rowGap = 12; // 36/3=12 const maxCol = Math.max( 1, Math.floor((contentWidth + minGap) / (cellSize + minGap)), ); const maxRow = Math.max( 1, Math.floor((contentHeight + rowGap) / (cellSize + rowGap)), ); return { maxRow, maxCol }; } /** * 绘制空田字格网格 */ drawEmptyGrids({ ctx, startX, startY, cellSize, colGap, rowGap, maxRow, maxCol, }: { ctx: RenderingContext; startX: number; startY: number; cellSize: number; colGap: number; rowGap: number; maxRow: number; // 行数 maxCol: number; // 列数 }) { for (let row = 0; row < maxRow; row++) { for (let col = 0; col < maxCol; col++) { const x = startX + col * (cellSize + colGap) + cellSize / 2; const y = startY + row * (cellSize + rowGap) + cellSize / 2; drawTianZiGrid({ ctx, x, y, size: cellSize }); } } } /** * 绘制练字内容(参考 template.html 的实现) */ drawPracticeContentInternal({ ctx, characterData, startX, startY, cellSize, colGap, rowGap, maxCol, maxRow, }: { ctx: RenderingContext; characterData: CharacterItem[]; startX: number; startY: number; cellSize: number; colGap: number; rowGap: number; maxCol: number; maxRow: number; }) { let rowIndex = 0; for (let i = 0; i < characterData.length; i++) { const item = characterData[i]; const strokes = item.strokes; const strokeCount = strokes.length; const totalCells = 1 + strokeCount; // 预览 + 练习 const totalRows = Math.ceil(totalCells / maxCol); if (rowIndex + totalRows > maxRow) { break; // 放不下,停止绘制 } for (let cellIdx = 0; cellIdx < totalCells; cellIdx++) { const localRowOffset = Math.floor(cellIdx / maxCol); const localColIndex = cellIdx % maxCol; const globalRow = rowIndex + localRowOffset; if (globalRow >= maxRow) { break; } if (cellIdx === 0) { this.drawPreviewCell({ ctx, strokes, startX, startY, cellSize, colGap, rowGap, rowIndex: globalRow, }); } else { const strokeIndex = cellIdx - 1; this.drawPracticeCell({ ctx, strokes, strokeIndex, startX, startY, cellSize, colGap, rowGap, rowIndex: globalRow, columnIndex: localColIndex, }); } } rowIndex = rowIndex + totalRows; } } /** * 绘制预览格(完整汉字) */ drawPreviewCell({ ctx, strokes, startX, startY, cellSize, colGap, rowGap, rowIndex, }: { ctx: RenderingContext; strokes: string[]; startX: number; startY: number; cellSize: number; colGap: number; rowGap: number; rowIndex: number; }) { const columnIndex = 0; const x = startX + columnIndex * (cellSize + colGap); const y = startY + rowIndex * (cellSize + rowGap); // console.log(`预览格 [${rowIndex}, ${columnIndex}] 坐标: (${x}, ${y}), 笔画数: ${strokes.length}`); // 绘制完整汉字(黑色,较粗) drawStrokes({ ctx, strokes, offsetX: x, offsetY: y, size: cellSize, uptoInclusive: strokes.length - 1, fillStyle: 'rgb(0,0,0)', // 黑色填充 strokeStyle: 'rgb(0,0,0)', // 黑色描边 lineWidth: 1, // 较粗的线条(4/3≈1,逻辑像素) }); } /** * 绘制练习格(逐笔画) */ drawPracticeCell({ ctx, strokes, strokeIndex, startX, startY, cellSize, colGap, rowGap, rowIndex, columnIndex, }: { ctx: RenderingContext; strokes: string[]; strokeIndex: number; startX: number; startY: number; cellSize: number; colGap: number; rowGap: number; rowIndex: number; columnIndex: number; }) { const x = startX + columnIndex * (cellSize + colGap); const y = startY + rowIndex * (cellSize + rowGap); // console.log(`练习格 [${rowIndex}, ${columnIndex}] 坐标: (${x}, ${y}), 笔画: ${strokeIndex + 1}/${strokes.length}`); // 绘制到指定笔画的汉字(灰色,中等粗细) drawStrokes({ ctx, strokes, offsetX: x, offsetY: y, size: cellSize, uptoInclusive: strokeIndex, fillStyle: '#ccc', strokeStyle: '#ccc', lineWidth: 1, // 中等粗细(3/3=1,逻辑像素) }); } // drawDivider 已在基类中实现,此方法保留以保持兼容 drawDivider(linY?: number) { super.drawDivider(); } } export default WordDrawService;