Files
doodle-mini/miniprogram/chinesePages/handwritingSheet/draw/wordDrawService.ts
T

450 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 田字格练字服务
* 根据汉字列表绘制到Canvas
*
* 支持的汉字类型:
* - character:汉字
* - number:数字
* - letter:字母
* - symbol:符号
*/
import { BaseDrawService } from '../../../core/draw/baseDraw';
import { TRACING_COLORS } from '../../../core/data/tracingStyles';
import { CharacterItem } from '../../../types/characterType';
import {
getScalingTransform,
drawSvgPath,
drawTianZiGrid,
} from '../../shared/drawUtils';
interface DrawStrokesParams {
ctx: RenderingContext;
strokes: string[];
offsetX: number;
offsetY: number;
size: number;
uptoInclusive: number;
fillStyle: string;
strokeStyle: string;
lineWidth: number;
}
/**
* 绘制笔画(参考 cnchar.draw 的实现方式)
* 使用 Canvas transform 进行坐标变换,支持 1024x1024 坐标系
*/
function drawStrokes({
ctx,
strokes,
offsetX,
offsetY,
size,
uptoInclusive,
fillStyle,
strokeStyle: _strokeStyle, // 保留接口兼容性,cnchar 模式只使用 fill
lineWidth: _lineWidth, // 保留接口兼容性,cnchar 模式只使用 fill
}: DrawStrokesParams) {
// 抑制未使用变量警告
void _strokeStyle;
void _lineWidth;
// 设置 Canvas 绘制质量
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// 设置路径绘制属性
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.miterLimit = 10;
// 添加内边距:在田字格四周预留空间,避免笔画贴边
const padding = size * 0.1;
// 计算缩放变换参数(参考 cnchar.draw 的 getScalingTransform
const transform = getScalingTransform(size, size, padding);
for (let s = 0; s <= uptoInclusive && s < strokes.length; s++) {
ctx.save();
// 应用变换矩阵(参考 cnchar.draw 的方式)
// 1. 先平移到目标位置
// 2. 再应用 Y 轴翻转和缩放
// transform: translate(xOffset, height - yOffset) scale(scale, -scale)
ctx.translate(
offsetX + transform.xOffset,
offsetY + size - transform.yOffset,
);
ctx.scale(transform.scale, -transform.scale);
// 绘制路径
ctx.beginPath();
drawSvgPath(ctx, strokes[s]);
// 使用 fill 绘制(cnchar 只使用 fill
ctx.fillStyle = fillStyle;
ctx.fill();
ctx.restore();
}
}
class WordDrawService extends BaseDrawService {
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
options = options || {};
super(canvas, ctx, {
title: '田字格 练 字 贴',
subTitle: '按笔画临摹练习',
...options,
});
}
/**
* 绘制页眉和基础布局
*/
async drawLayout() {
this.setPrintConfig();
this.clear();
this.setPaper();
await this.drawHeader();
this.drawDivider();
await this.drawContentEmpty();
}
async drawContentEmpty() {
this.clearContentArea();
this.drawContent(null);
// await this.drawPrintFooter();
}
/**
* 绘制田字格和练字内容
* @param wordsMap 形如 { "其": ["M.. L.. Z", ...], ... },不超过 10 个汉字
*/
async drawPracticeContent(characters: CharacterItem[]) {
// 清空内容区域(页眉以下的部分)
this.clearContentArea();
// 绘制田字格和练字内容
this.drawContent(characters);
// await this.drawPrintFooter();
}
/**
* 清空内容区域(页眉以下的部分)
*/
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, cx: x, cy: 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}`);
// 绘制完整汉字(深灰色,Fill + Stroke模式)
// 参考一般练字贴:预览字使用深灰色,不是纯黑色,更柔和护眼
drawStrokes({
ctx,
strokes,
offsetX: x,
offsetY: y,
size: cellSize,
uptoInclusive: strokes.length - 1,
fillStyle: TRACING_COLORS.reference,
strokeStyle: TRACING_COLORS.reference,
lineWidth: 1.2,
});
}
/**
* 绘制练习格(逐笔画)
*/
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}`);
// 绘制到指定笔画的汉字(浅灰色,Fill + Stroke模式)
// 参考一般练字贴:练习字使用浅灰色,便于临摹
drawStrokes({
ctx,
strokes,
offsetX: x,
offsetY: y,
size: cellSize,
uptoInclusive: strokeIndex,
fillStyle: TRACING_COLORS.guide,
strokeStyle: TRACING_COLORS.guide,
lineWidth: 1.2,
});
}
// drawDivider 已在基类中实现,此方法保留以保持兼容
drawDivider() {
super.drawDivider();
}
}
export default WordDrawService;