import { PAPER_SIZE } from '../constants/colors'; import { getMiniCodeImage, getImage } from '../utils/index'; 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 = 2; 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 { headerType: PrintHeader = 'wechat'; canvas: WechatMiniprogram.Canvas; ctx: RenderingContext; options: Record; paperSize: PaperSize; currentX: number; currentY: number; constructor( canvas: Canvas, ctx: RenderingContext, options?: Record, ) { options = options || {}; this.canvas = canvas; this.ctx = ctx; this.paperSize = 'A4'; this.options = { appName: '涂鸦丫小程序', appHint: '识字|识图|练字|打印', title: '田字格 练 字 贴', subTitle: '按笔画临摹练习', ...options, }; this.currentX = 0; this.currentY = 0; this.setPrintConfig(); } setPrintConfig() { const printConfig = getApp().getPrintConfig(); this.headerType = printConfig.header; this.options.appName = printConfig.appName; } /** * 绘制页眉和基础布局 */ async drawLayout() { this.setPrintConfig(); this.clear(); this.setPaper(); // 等待页眉绘制完成,确保 this.currentY 被正确设置 if (this.headerType !== 'minimal') { await this.drawHeader(); } else { await this.drawMiniHeader(); } } 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, canvas } = this; const contentStartY = this.currentY; // 清空页眉以下的所有内容 ctx.fillStyle = '#fff'; ctx.fillRect(0, contentStartY, canvas.width, canvas.height - contentStartY); } async drawHeader() { const { canvas, ctx } = this; const { appName, appHint, title, subTitle } = this.options; this.currentX = 80; this.currentY = 80; let titleX = this.currentX + 200 + 48; const titleY = 80; const logoX = 80; const logoY = 60; const logoWidth = 200; const logoHeight = 200; switch (this.headerType) { case 'LogoImage': { const image = await getImage(canvas, '/assets/imgs/doodle-logo.png'); ctx.drawImage(image, logoX, logoY, logoWidth, logoHeight); break; } case 'noLogoImage': { titleX = 120; break; } case 'minimal': { titleX = 120; break; } default: { const image = await getMiniCodeImage(canvas); ctx.drawImage(image, logoX, logoY, logoWidth, logoHeight); break; } } ctx.font = 'bold 64px "Microsoft Yahei"'; ctx.fillStyle = '#000'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText(appName, titleX, titleY); ctx.font = '48px "Microsoft Yahei"'; ctx.fillStyle = '#666'; ctx.fillText(appHint, titleX, 186); ctx.font = 'bold 64px "Microsoft Yahei"'; ctx.fillStyle = '#000'; ctx.fillText(title, 1015, titleY); ctx.font = '48px "Microsoft Yahei"'; ctx.fillStyle = '#666'; ctx.fillText(subTitle, 1015, 186); // 计算页眉的实际高度,包括分割线 const headerHeight = Math.max(logoY + logoHeight, titleY + 64 + 48 + 48) + 20; // 64px字体 + 48px间距 + 48px字体 + 20px边距 // this.currentY = headerHeight; this.currentY += headerHeight; this.drawLine(this.currentY); } drawMiniHeader() { const { canvas, ctx } = this; const { appName, title } = this.options; const titleY = 120; ctx.font = 'bold 64px "Microsoft Yahei"'; ctx.fillStyle = '#000'; ctx.textAlign = 'center'; ctx.fillText(appName + ' ' + title, canvas.width / 2, titleY); // 计算迷你页眉的实际高度 const miniHeaderHeight = titleY + 64 + 20; // 64px字体 + 20px边距 this.currentY = miniHeaderHeight; this.drawLine(this.currentY); } /** * 绘制正文内容:两阶段绘制 - 先绘制空田字格,再绘制练字内容 */ /** * 绘制正文内容:包括田字格的排布和内容 * * minGap 作用解释: * minGap(最小列间距)用于田字格水平方向(每一行格子之间的间距)的初始最小值。它保证多个田字格在一行内不会紧贴排布、而是有一个最小的间隔,整体看起来不会拥挤。后续还会结合画布实际剩余空间动态调整为更合适的实际间距 actualColGap。 * * rowGap 作用解释: * rowGap(行间距)用于田字格竖直方向(每一行田字格之间的间隔)的初始最小值。它防止行与行的田字格上下贴得太紧,增加一定的可视呼吸空间。之后同样会结合剩余空间动态调整为实际间距 actualRowGap。 */ drawContent(characterData: CharacterItem[] | null) { const { canvas, ctx } = this; // 布局参数 const topGap = 50; // 与页眉分割线的距离 const leftMargin = 120; const rightMargin = 120; const bottomMargin = 120; const contentTop = this.currentY + topGap; const contentWidth = canvas.width - leftMargin - rightMargin; const contentHeight = canvas.height - contentTop - bottomMargin; const cellSize = 140; const minGap = 24; // 最小格子列间距,用于保证横向不拥挤,后续可动态调整为实际间距 const rowGap = 36; // 最小格子行间距,用于保证纵向不拥挤,后续可动态调整为实际间距 // 计算每行可容纳的田字格数量 const columnNumber = Math.max(1, Math.floor((contentWidth + minGap) / (cellSize + minGap))); // 计算每列可容纳的田字格数量 const rowNumber = Math.max(1, Math.floor((contentHeight + rowGap) / (cellSize + rowGap))); // 计算总需要的田字格数量 const totalCellsNeeded = rowNumber * columnNumber; // 动态调整间距以充分利用空间(在允许的最小间距基础上弹性扩展以适配画布) const actualRowGap = rowNumber > 1 ? (contentHeight - rowNumber * cellSize) / (rowNumber - 1) : 0; const actualColGap = columnNumber > 1 ? (contentWidth - columnNumber * cellSize) / (columnNumber - 1) : 0; console.log(`布局信息: 总格数=${totalCellsNeeded}, 行数=${rowNumber}, 列数=${columnNumber}, 行间距=${actualRowGap.toFixed(1)}, 列间距=${actualColGap.toFixed(1)}`); // 第一阶段:绘制所有空田字格 this.drawEmptyGrids({ ctx, startX: leftMargin, startY: contentTop, cellSize, colGap: actualColGap, rowGap: actualRowGap, rowNumber, columnNumber }); if (characterData && characterData.length > 0) { // 第二阶段:绘制练字内容 this.drawPracticeContentInternal({ ctx, characterData, startX: leftMargin, startY: contentTop, cellSize, colGap: actualColGap, rowGap: actualRowGap, columnNumber }); }; } /** * 计算总需要的田字格数量 */ // calculateTotalCellsNeeded(characters: string[], wordsMap: Record): number { // let totalCells = 0; // characters.forEach((char) => { // const strokes = wordsMap[char] || []; // const strokeCount = strokes.length; // const preview = 1; // 预览格 // const practice = Math.min(strokeCount, 8); // 最多8个练习格 // const blanks = 2; // 空白格 // totalCells += preview + practice + blanks; // }); // return totalCells; // } /** * 绘制空田字格网格 */ drawEmptyGrids({ ctx, startX, startY, cellSize, colGap, rowGap, rowNumber, columnNumber }: { ctx: RenderingContext; startX: number; startY: number; cellSize: number; colGap: number; rowGap: number; rowNumber: number; // 行数 columnNumber: number; // 列数 }) { console.log('drawEmptyGrids startY----:', startY) for (let row = 0; row < rowNumber; row++) { for (let col = 0; col < columnNumber; 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, columnNumber }: { ctx: RenderingContext; characterData: CharacterItem[]; startX: number; startY: number; cellSize: number; colGap: number; rowGap: number; columnNumber: number; }) { const reservedCells = 2; let rowIndex = 0, columnIndex = 0; // console.log('开始绘制练字内容,汉字数量:', characters.length); characterData.forEach((item, index) => { const character = item.character; const strokes = item.strokes; const strokeCount = strokes.length; console.log(`绘制第 ${index + 1} 个汉字 "${character}",笔画数: ${strokeCount}`); // 1. 预览格:显示完整汉字(黑色) this.drawPreviewCell({ ctx, strokes, startX, startY, cellSize, colGap, rowGap, rowIndex, }); columnIndex++; // 2. 练习格:逐笔画显示(红色) for (let strokeIndex = 0; strokeIndex < strokeCount; strokeIndex++) { // 先绘制当前笔画 this.drawPracticeCell({ ctx, strokes, strokeIndex, startX, startY, cellSize, colGap, rowGap, rowIndex, columnIndex }); columnIndex++; if (columnIndex >= columnNumber) { columnIndex = 0; rowIndex++; } // console.log(`笔画 ${strokeIndex + 1} 绘制完成,当前 columnIndex: ${columnIndex}`); } if (columnIndex + reservedCells > columnNumber) { columnIndex = 0; rowIndex++; } // 4. 强制下一个汉字换行到新行的第一个田字格 if (index < characterData.length - 1) { // 不是最后一个汉字 rowIndex = rowIndex + 1; columnIndex = 0; // console.log(`汉字 "${char}" 绘制完成,强制换行到新行,下一个汉字从 rowIndex: ${rowIndex}, columnIndex: ${columnIndex} 开始`); } }); // console.log('所有汉字绘制完成'); } /** * 绘制预览格(完整汉字) */ 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: 4, // 较粗的线条 }); } /** * 绘制练习格(逐笔画) */ 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: 'rgb(220, 20, 20)', // 深红色填充 strokeStyle: 'rgb(220, 20, 20)', // 深红色描边 lineWidth: 3, // 中等粗细 }); } drawLine(linY: number) { const { canvas, ctx } = this; ctx.strokeStyle = '#000'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(80, linY); ctx.lineTo(canvas.width - 80, linY); ctx.stroke(); } setPaper() { const { ctx, canvas } = this; const { pixelRatio: dpr } = wx.getWindowInfo(); let { width, height } = PAPER_SIZE[this.paperSize]; width = width * dpr; height = height * dpr; canvas.width = width; canvas.height = height; this.clear(); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, width, height); } clear() { const canvas = this.canvas; this.ctx.clearRect(0, 0, canvas.width, canvas.height); } } export default WordDrawService;