feat: 优化字帖功能
This commit is contained in:
@@ -2,120 +2,118 @@ import { BaseDrawService } from './baseDraw';
|
||||
import { CharacterItem } from '../types/characterType';
|
||||
|
||||
/**
|
||||
* 仅支持 M/L/Z 的简单 SVG 路径解析与绘制
|
||||
* cnchar-data 坐标系配置
|
||||
* 参考 hanzi-writer 的实现逻辑
|
||||
* 字符数据的边界框:左上角 (0, -124),右下角 (1024, 900)
|
||||
*/
|
||||
interface DrawSvgPathCommandsParams {
|
||||
ctx: RenderingContext;
|
||||
pathD: string;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
const CHAR_BOUNDS = {
|
||||
minX: 0,
|
||||
minY: -124,
|
||||
maxX: 1024,
|
||||
maxY: 900,
|
||||
};
|
||||
const CHAR_WIDTH = CHAR_BOUNDS.maxX - CHAR_BOUNDS.minX; // 1024
|
||||
const CHAR_HEIGHT = CHAR_BOUNDS.maxY - CHAR_BOUNDS.minY; // 1024
|
||||
|
||||
/**
|
||||
* 计算缩放变换参数(模仿 cnchar.draw 的 getScalingTransform 函数)
|
||||
* 用于将 1024x1024 坐标系的字符数据变换到目标画布尺寸
|
||||
*/
|
||||
interface ScalingTransform {
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
function drawSvgPathCommands({
|
||||
ctx,
|
||||
pathD,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
}: DrawSvgPathCommandsParams) {
|
||||
// console.log('drawSvgPathCommands 参数:', { pathD, offsetX, offsetY, scale });
|
||||
function getScalingTransform(
|
||||
width: number,
|
||||
height: number,
|
||||
padding: number,
|
||||
): ScalingTransform {
|
||||
// 计算可用空间
|
||||
const availableWidth = width - 2 * padding;
|
||||
const availableHeight = height - 2 * padding;
|
||||
|
||||
// 精度处理函数:避免浮点数精度问题(提高到6位小数精度,确保高精度)
|
||||
const roundToPrecision = (num: number, precision: number = 6): number => {
|
||||
return (
|
||||
Math.round(num * Math.pow(10, precision)) / Math.pow(10, precision)
|
||||
);
|
||||
};
|
||||
// 计算缩放比例(取较小的比例以确保字符完整显示)
|
||||
const scaleX = availableWidth / CHAR_WIDTH;
|
||||
const scaleY = availableHeight / CHAR_HEIGHT;
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
// 像素对齐函数:优化对齐策略,提升平滑度
|
||||
// 对于Fill+Stroke模式,使用更精细的对齐策略,避免过度对齐导致的锯齿
|
||||
// 对齐到0.5像素可以获得更好的平滑效果
|
||||
const alignToPixel = (num: number): number => {
|
||||
// 对齐到0.5像素,而不是整数像素,可以获得更平滑的线条
|
||||
return Math.round(num * 2) / 2;
|
||||
};
|
||||
// 计算偏移量(居中显示)
|
||||
const scaledWidth = CHAR_WIDTH * scale;
|
||||
const scaledHeight = CHAR_HEIGHT * scale;
|
||||
const centerX = padding + (availableWidth - scaledWidth) / 2;
|
||||
const centerY = padding + (availableHeight - scaledHeight) / 2;
|
||||
|
||||
// 新的解析方法:按命令片段解析
|
||||
// 使用正则表达式匹配从字母开头到下一个字母(或结尾)的片段
|
||||
const commandRegex = /([MLZ])([^MLZ]*?)(?=[MLZ]|$)/g;
|
||||
// 计算 xOffset 和 yOffset(考虑字符边界框的偏移)
|
||||
const xOffset = -CHAR_BOUNDS.minX * scale + centerX;
|
||||
const yOffset = -CHAR_BOUNDS.minY * scale + centerY;
|
||||
|
||||
return { xOffset, yOffset, scale };
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG 路径解析与绘制(支持 M/L/Q/Z 命令)
|
||||
* 参考 cnchar.draw 的实现,使用 Canvas transform 进行坐标变换
|
||||
*/
|
||||
interface DrawSvgPathParams {
|
||||
ctx: RenderingContext;
|
||||
pathD: string;
|
||||
}
|
||||
|
||||
function drawSvgPath({ ctx, pathD }: DrawSvgPathParams) {
|
||||
// 使用正则表达式匹配 SVG 路径命令
|
||||
// 支持 M(moveTo)、L(lineTo)、Q(二次贝塞尔曲线)、Z(closePath)
|
||||
const commandRegex = /([MLQZ])([^MLQZ]*?)(?=[MLQZ]|$)/gi;
|
||||
const commands: Array<{ cmd: string; coords: string }> = [];
|
||||
|
||||
let match;
|
||||
while ((match = commandRegex.exec(pathD)) !== null) {
|
||||
const cmd = match[1];
|
||||
const cmd = match[1].toUpperCase();
|
||||
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 calculatedX = offsetX + x * scale;
|
||||
const calculatedY = offsetY + y * scale;
|
||||
const preciseX = roundToPrecision(calculatedX);
|
||||
const preciseY = roundToPrecision(calculatedY);
|
||||
const finalX = alignToPixel(preciseX);
|
||||
const finalY = alignToPixel(preciseY);
|
||||
|
||||
// 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<string, (coords: string) => void> = {
|
||||
M: (coords) =>
|
||||
parseAndDrawCoords(coords, 'M', (x, y) => ctx.moveTo(x, y)),
|
||||
L: (coords) =>
|
||||
parseAndDrawCoords(coords, 'L', (x, y) => ctx.lineTo(x, y)),
|
||||
Z: () => {
|
||||
// 关键修复:处理Z命令,闭合路径
|
||||
ctx.closePath();
|
||||
},
|
||||
// 解析坐标的辅助函数
|
||||
const parseCoords = (coords: string): number[] => {
|
||||
return coords
|
||||
.split(/[\s,]+/)
|
||||
.filter((part) => part.trim() !== '')
|
||||
.map((part) => parseFloat(part));
|
||||
};
|
||||
|
||||
for (const { cmd, coords } of commands) {
|
||||
commandCount++;
|
||||
// console.log(`命令 ${commandCount}: ${cmd}, 坐标: "${coords}"`);
|
||||
const coordValues = parseCoords(coords);
|
||||
|
||||
const handler = commandHandlers[cmd];
|
||||
if (handler) {
|
||||
handler(coords);
|
||||
} else {
|
||||
console.warn(`未知命令: ${cmd}`);
|
||||
switch (cmd) {
|
||||
case 'M':
|
||||
if (coordValues.length >= 2) {
|
||||
ctx.moveTo(coordValues[0], coordValues[1]);
|
||||
}
|
||||
break;
|
||||
case 'L':
|
||||
if (coordValues.length >= 2) {
|
||||
ctx.lineTo(coordValues[0], coordValues[1]);
|
||||
}
|
||||
break;
|
||||
case 'Q':
|
||||
// 二次贝塞尔曲线:Q cpx cpy x y
|
||||
if (coordValues.length >= 4) {
|
||||
ctx.quadraticCurveTo(
|
||||
coordValues[0],
|
||||
coordValues[1],
|
||||
coordValues[2],
|
||||
coordValues[3],
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'Z':
|
||||
ctx.closePath();
|
||||
break;
|
||||
default:
|
||||
console.warn(`未知的 SVG 路径命令: ${cmd}`);
|
||||
}
|
||||
}
|
||||
|
||||
// console.log(`总共处理了 ${commandCount} 个命令`);
|
||||
}
|
||||
|
||||
interface DrawStrokesParams {
|
||||
@@ -130,6 +128,10 @@ interface DrawStrokesParams {
|
||||
lineWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制笔画(参考 cnchar.draw 的实现方式)
|
||||
* 使用 Canvas transform 进行坐标变换,支持 1024x1024 坐标系
|
||||
*/
|
||||
function drawStrokes({
|
||||
ctx,
|
||||
strokes,
|
||||
@@ -137,93 +139,51 @@ function drawStrokes({
|
||||
offsetY,
|
||||
size,
|
||||
uptoInclusive,
|
||||
fillStyle, // 填充颜色,用于 Fill + Stroke 模式
|
||||
strokeStyle, // 描边颜色,用于绘制笔画
|
||||
lineWidth, // 线条宽度,控制笔画粗细
|
||||
fillStyle,
|
||||
strokeStyle: _strokeStyle, // 保留接口兼容性,cnchar 模式只使用 fill
|
||||
lineWidth: _lineWidth, // 保留接口兼容性,cnchar 模式只使用 fill
|
||||
}: DrawStrokesParams) {
|
||||
// 设置 Canvas 绘制质量(关键优化:提升线条流畅度)
|
||||
// 抑制未使用变量警告
|
||||
void _strokeStyle;
|
||||
void _lineWidth;
|
||||
// 设置 Canvas 绘制质量
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high'; // 高质量平滑
|
||||
|
||||
// 设置路径绘制属性(关键:这些设置直接影响笔画粗细和流畅度)
|
||||
// 注意:不使用 save/restore,与测试页面保持一致,避免状态管理问题
|
||||
ctx.lineCap = 'round'; // 圆角端点,使笔画末端更自然
|
||||
ctx.lineJoin = 'round'; // 圆角连接,使转折处更平滑
|
||||
ctx.miterLimit = 10; // 斜接限制
|
||||
|
||||
// 优化渲染策略:使用 Fill + Stroke 模式
|
||||
// 先 fill 填充内部,再 stroke 描边,stroke 宽度略小,避免双重渲染导致的毛糙
|
||||
// 这样可以获得自然的笔画粗细,同时保持流畅度
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
// 模板中使用 54x54 的视窗尺寸
|
||||
const viewBoxSize = 54;
|
||||
// 设置路径绘制属性
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.miterLimit = 10;
|
||||
|
||||
// 添加内边距:在田字格四周预留空间,避免笔画贴边
|
||||
const padding = size * 0.1; // 内边距为田字格大小的10%
|
||||
const contentSize = size - padding * 2; // 实际绘制区域大小
|
||||
|
||||
// 优化缩放比例:使用更精确的缩放,不进行四舍五入,保持原始精度
|
||||
const contentScale = contentSize / viewBoxSize;
|
||||
const padding = size * 0.1;
|
||||
|
||||
// 计算内容区域的起始位置(居中),优化对齐策略提升平滑度
|
||||
const rawOffsetX = offsetX + padding;
|
||||
const rawOffsetY = offsetY + padding;
|
||||
// 对齐到0.5像素,而不是整数像素,可以获得更平滑的渲染效果
|
||||
const contentOffsetX = Math.round(rawOffsetX * 2) / 2;
|
||||
const contentOffsetY = Math.round(rawOffsetY * 2) / 2;
|
||||
|
||||
// console.log('drawStrokes 参数:', {
|
||||
// strokesCount: strokes.length,
|
||||
// uptoInclusive,
|
||||
// offsetX,
|
||||
// offsetY,
|
||||
// size,
|
||||
// padding,
|
||||
// contentSize,
|
||||
// contentOffsetX,
|
||||
// contentOffsetY,
|
||||
// originalScale: scale,
|
||||
// contentScale,
|
||||
// fillStyle,
|
||||
// strokeStyle,
|
||||
// lineWidth
|
||||
// });
|
||||
// 计算缩放变换参数(参考 cnchar.draw 的 getScalingTransform)
|
||||
const transform = getScalingTransform(size, size, padding);
|
||||
|
||||
for (let s = 0; s <= uptoInclusive && s < strokes.length; s++) {
|
||||
// console.log(`绘制第 ${s} 个笔画:`, strokes[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();
|
||||
|
||||
// 记录路径是否包含Z命令(已闭合)
|
||||
const hasClosePath = strokes[s].includes('Z');
|
||||
|
||||
drawSvgPathCommands({
|
||||
ctx,
|
||||
pathD: strokes[s],
|
||||
offsetX: contentOffsetX,
|
||||
offsetY: contentOffsetY,
|
||||
scale: contentScale,
|
||||
});
|
||||
|
||||
// 优化渲染策略:使用 Fill + Stroke 模式
|
||||
// 先 fill 填充内部,再 stroke 描边,stroke 宽度略小,避免双重渲染导致的毛糙
|
||||
// 如果路径没有Z命令,手动闭合(fill需要闭合路径)
|
||||
if (!hasClosePath) {
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
// 1. 先填充内部
|
||||
drawSvgPath({ ctx, pathD: strokes[s] });
|
||||
|
||||
// 使用 fill 绘制(cnchar 只使用 fill)
|
||||
ctx.fillStyle = fillStyle;
|
||||
ctx.fill();
|
||||
|
||||
// 2. 再描边(stroke宽度设为fill视觉宽度的70%,避免明显的双重渲染)
|
||||
// 与测试页面保持一致:使用相同的stroke宽度计算方式
|
||||
ctx.strokeStyle = strokeStyle;
|
||||
const strokeWidth = Math.max(1.5, lineWidth * 0.7);
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 注意:不使用 restore,与测试页面保持一致
|
||||
}
|
||||
|
||||
interface DrawTianZiGridParams {
|
||||
@@ -580,7 +540,7 @@ class WordDrawService extends BaseDrawService {
|
||||
uptoInclusive: strokes.length - 1,
|
||||
fillStyle: 'rgb(85,85,85)', // 深灰色填充(#555555),比#666666更深一点
|
||||
strokeStyle: 'rgb(85,85,85)', // 深灰色描边
|
||||
lineWidth: 0.6, // Fill + Stroke 模式,线条宽度调小一点(逻辑像素)
|
||||
lineWidth: 1.2, // Fill + Stroke 模式,线条宽度调小一点(逻辑像素)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -626,7 +586,7 @@ class WordDrawService extends BaseDrawService {
|
||||
uptoInclusive: strokeIndex,
|
||||
fillStyle: 'rgb(170,170,170)', // 浅灰色填充(#aaaaaa),参考练字贴颜色
|
||||
strokeStyle: 'rgb(170,170,170)', // 浅灰色描边
|
||||
lineWidth: 0.6, // Fill + Stroke 模式,线条宽度调小一点(逻辑像素)
|
||||
lineWidth: 1.2, // Fill + Stroke 模式,线条宽度调小一点(逻辑像素)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user