import { PAPER_SIZE } from '../constants/colors'; /** * 仅支持 M/L/Z 的简单 SVG 路径解析与绘制 */ function drawSvgPathCommands( ctx: RenderingContext, pathD: string, offsetX: number, offsetY: number, scale: number, ) { const tokens = pathD .replace(/,/g, ' ') .trim() .split(/\s+/); let i = 0; let currentX = 0; let currentY = 0; while (i < tokens.length) { const cmd = tokens[i++]; switch (cmd) { case 'M': { const x = parseFloat(tokens[i++]); const y = parseFloat(tokens[i++]); currentX = offsetX + x * scale; currentY = offsetY + y * scale; ctx.moveTo(currentX, currentY); break; } case 'L': { const x = parseFloat(tokens[i++]); const y = parseFloat(tokens[i++]); currentX = offsetX + x * scale; currentY = offsetY + y * scale; ctx.lineTo(currentX, currentY); break; } case 'Z': { ctx.closePath(); break; } default: { // 遇到数字(可能因为路径省略了连续的 L),回退一步并按 L 处理 if (!isNaN(parseFloat(cmd))) { i--; const x = parseFloat(tokens[i++]); const y = parseFloat(tokens[i++]); currentX = offsetX + x * scale; currentY = offsetY + y * scale; ctx.lineTo(currentX, currentY); break; } // 其他命令不支持,直接跳过 break; } } } } function drawStrokes( ctx: RenderingContext, strokes: string[], offsetX: number, offsetY: number, size: number, uptoInclusive: number, fillStyle: string, strokeStyle: string, lineWidth: number, ) { // 模板中使用 54x54 的视窗尺寸 const viewBoxSize = 54; const scale = size / viewBoxSize; for (let s = 0; s <= uptoInclusive && s < strokes.length; s++) { ctx.beginPath(); drawSvgPathCommands(ctx, strokes[s], offsetX, offsetY, scale); ctx.fillStyle = fillStyle; ctx.strokeStyle = strokeStyle; ctx.lineWidth = lineWidth; ctx.fill(); ctx.stroke(); ctx.closePath(); } } function drawTianZiGrid( ctx: RenderingContext, x: number, y: number, size: number, lineColor: string = '#e0e0e0', boldColor: string = '#cccccc', ) { // 外框 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; } /** * 生成练字帖 * @param wordsMap 形如 { "其": ["M.. L.. Z", ...], ... },不超过 10 个汉字 */ draw(wordsMap: Record) { this.setPrintConfig(); this.clear(); this.setPaper(); if (this.headerType !== 'minimal') { this.drawHeader(); } else { this.drawMiniHeader(); } this.drawContent(wordsMap); } 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; // 这里沿用小程序码/Logo策略,保持与其它服务一致 try { const { getMiniCodeImage } = await import('../utils/index'); const image = await getMiniCodeImage(canvas); ctx.drawImage(image, logoX, logoY, logoWidth, logoHeight); } catch (_) { // 忽略加载失败 } 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); this.currentY = 304; 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); this.currentY = 200; this.drawLine(this.currentY); } /** * 绘制正文内容:为每个汉字生成“预览 + 逐笔画临摹 + 空白格”的练习单元 */ drawContent(wordsMap: Record) { const { canvas, ctx } = this; const characters = Object.keys(wordsMap).slice(0, 10); if (characters.length === 0) return; // 布局参数 const topGap = 50; // 与页眉分割线的距离 const leftMargin = 120; const rightMargin = 120; const bottomMargin = 120; const contentTop = this.currentY + topGap; const contentWidth = canvas.width - leftMargin - rightMargin; const cellSize = 140; const minGap = 24; const maxPerRow = Math.max(1, Math.floor((contentWidth + minGap) / (cellSize + minGap))); const rowGap = 36; // 为每个汉字生成一串单元:1个预览 + N个逐笔画 + 2个空白 (便于描摹) const unitsPerCharBuilder = (strokeCount: number) => { const preview = 1; const practice = Math.min(strokeCount, 8); // 最多展示 8 步逐笔画,避免过长 const blanks = 2; return preview + practice + blanks; }; let cursorX = leftMargin; let cursorY = contentTop + cellSize / 2; let usedInRow = 0; characters.forEach((char) => { const strokes = wordsMap[char] || []; const totalUnits = unitsPerCharBuilder(strokes.length); let unitIndex = 0; while (unitIndex < totalUnits) { // 如果本行放不下,换行 if (usedInRow >= maxPerRow) { cursorX = leftMargin; cursorY += cellSize + rowGap; usedInRow = 0; // 边界检查 if (cursorY + cellSize / 2 > canvas.height - bottomMargin) return; } // 绘制田字格 drawTianZiGrid(ctx, cursorX + cellSize / 2, cursorY, cellSize); // 单元类型:0 预览;1..practice 逐笔画;最后 blanks 空白 const practiceMax = Math.min(strokes.length, 8); if (unitIndex === 0) { // 预览:全部笔画使用较深颜色 drawStrokes( ctx, strokes, cursorX + (cellSize - cellSize) / 2, cursorY - cellSize / 2, cellSize, strokes.length - 1, 'rgb(0,0,0)', '#000', 1.6, ); } else if (unitIndex <= practiceMax) { // 逐笔画:累进显示到第 k 画,使用灰色/描边 drawStrokes( ctx, strokes, cursorX + (cellSize - cellSize) / 2, cursorY - cellSize / 2, cellSize, unitIndex - 1, 'rgb(184,184,184)', '#999', 1.6, ); } else { // 空白格:不画笔画 } // 前进到下一个单元 cursorX += cellSize + minGap; usedInRow += 1; unitIndex += 1; } }); } 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;