360 lines
11 KiB
TypeScript
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;
|