Files
doodle-mini/miniprogram/service/wordDrawService.ts
T
2025-09-12 16:56:00 +08:00

360 lines
11 KiB
TypeScript

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<string, any>;
paperSize: PaperSize;
currentX: number;
currentY: number;
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
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<string, string[]>) {
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<string, string[]>) {
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;