import { BaseDrawService } from '../core/draw/baseDraw'; import { CharacterItem } from '../types/characterType'; /** * cnchar-data 坐标系配置 * 参考 hanzi-writer 的实现逻辑 * 字符数据的边界框:左上角 (0, -124),右下角 (1024, 900) */ 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 getScalingTransform( width: number, height: number, padding: number, ): ScalingTransform { // 计算可用空间 const availableWidth = width - 2 * padding; const availableHeight = height - 2 * padding; // 计算缩放比例(取较小的比例以确保字符完整显示) const scaleX = availableWidth / CHAR_WIDTH; const scaleY = availableHeight / CHAR_HEIGHT; const scale = Math.min(scaleX, scaleY); // 计算偏移量(居中显示) const scaledWidth = CHAR_WIDTH * scale; const scaledHeight = CHAR_HEIGHT * scale; const centerX = padding + (availableWidth - scaledWidth) / 2; const centerY = padding + (availableHeight - scaledHeight) / 2; // 计算 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].toUpperCase(); const coords = match[2].trim(); commands.push({ cmd, coords }); } // 解析坐标的辅助函数 const parseCoords = (coords: string): number[] => { return coords .split(/[\s,]+/) .filter((part) => part.trim() !== '') .map((part) => parseFloat(part)); }; for (const { cmd, coords } of commands) { const coordValues = parseCoords(coords); 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}`); } } } 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, pathD: strokes[s] }); // 使用 fill 绘制(cnchar 只使用 fill) ctx.fillStyle = fillStyle; ctx.fill(); ctx.restore(); } } interface DrawTianZiGridParams { ctx: RenderingContext; x: number; y: number; size: number; lineColor?: string; boldColor?: string; } function drawTianZiGrid({ ctx, x, y, size, lineColor = '#a8d5a8', // 浅绿色(中线) boldColor = '#7fb069', // 中等绿色(外框) }: DrawTianZiGridParams) { // 外框(逻辑像素)- 使用中等绿色,清晰可见 ctx.strokeStyle = boldColor; ctx.lineWidth = 1; // 2/3≈1,逻辑像素 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 = '#d4f0d4'; // 很淡的绿色 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 extends BaseDrawService { constructor( canvas: Canvas, ctx: RenderingContext, options?: Record, ) { options = options || {}; super(canvas, ctx, { title: '田字格 练 字 贴', subTitle: '按笔画临摹练习', ...options, }); } /** * 绘制页眉和基础布局 */ async drawLayout() { this.setPrintConfig(); this.clear(); this.setPaper(); // 绘制Header if (this.headerType !== 'minimal') { await this.drawHeader(); } else { this.drawMiniHeader(); } this.drawDivider(); this.drawContentEmpty(); } async drawContentEmpty() { this.clearContentArea(); this.drawContent(null); } /** * 绘制田字格和练字内容 * @param wordsMap 形如 { "其": ["M.. L.. Z", ...], ... },不超过 10 个汉字 */ drawPracticeContent(characters: CharacterItem[]) { // 清空内容区域(页眉以下的部分) this.clearContentArea(); // 绘制田字格和练字内容 this.drawContent(characters); } /** * 清空内容区域(页眉以下的部分) */ 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, x, 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: 'rgb(85,85,85)', // 深灰色填充(#555555),比#666666更深一点 strokeStyle: 'rgb(85,85,85)', // 深灰色描边 lineWidth: 1.2, // Fill + Stroke 模式,线条宽度调小一点(逻辑像素) }); } /** * 绘制练习格(逐笔画) */ 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: 'rgb(170,170,170)', // 浅灰色填充(#aaaaaa),参考练字贴颜色 strokeStyle: 'rgb(170,170,170)', // 浅灰色描边 lineWidth: 1.2, // Fill + Stroke 模式,线条宽度调小一点(逻辑像素) }); } // drawDivider 已在基类中实现,此方法保留以保持兼容 drawDivider() { super.drawDivider(); } } export default WordDrawService;