feat: 汉字每天打卡基本完成,细节还待优化

This commit is contained in:
R524809
2026-05-21 18:23:00 +08:00
parent e86e5f5aa7
commit a9bd118ec7
15 changed files with 1013 additions and 235 deletions
@@ -0,0 +1,353 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import {
GRID_COLORS,
GRID_DASH,
TRACING_COLORS,
} from '../../../core/data/tracingStyles';
import { TONEOZ_PINYIN, fontFamilyOf } from '../../../core/font/fontProfiles';
import { loadFontFace } from '../../../core/font/fontLoader';
import {
SECTION_PADDING_TOP,
SECTION_PADDING_BOTTOM,
HEADER_TITLE_GAP,
HEADER_META_GAP,
CONTENT_TOP_GAP,
FOOTER_PHRASE_GAP,
FOOTER_INDEX_GAP,
drawSectionHeader,
drawSectionFooter,
getRandomEncouragementPhrase,
} from '../../../core/draw/dailyPracticeDrawHelper';
import {
getScalingTransform,
drawSvgPath,
drawTianZiGrid,
} from '../../shared/drawUtils';
export interface DailyCheckinHanziItem {
character: string;
pinyin: string;
strokes: string[];
}
const PAGE_MARGIN = 14;
const SECTION_PADDING_X = 18;
const COLUMNS = 11;
const ROWS_PER_SECTION = 4;
const BLOCK_GAP = 0;
const PINYIN_ROW_RATIO = 0.52; // 拼音/笔画行高度占田字格高度比例
interface DrawSingleStrokeParams {
ctx: RenderingContext;
strokes: string[];
offsetX: number;
offsetY: number;
width: number;
height: number;
fromIndex: number;
toIndex: number;
fillStyle: string;
}
function drawStrokesRange({
ctx,
strokes,
offsetX,
offsetY,
width,
height,
fromIndex,
toIndex,
fillStyle,
}: DrawSingleStrokeParams) {
const padding = Math.min(width, height) * 0.1;
const transform = getScalingTransform(width, height, padding);
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.miterLimit = 10;
ctx.fillStyle = fillStyle;
for (let s = fromIndex; s <= toIndex && s < strokes.length; s++) {
ctx.save();
ctx.translate(
offsetX + transform.xOffset,
offsetY + height - transform.yOffset,
);
ctx.scale(transform.scale, -transform.scale);
ctx.beginPath();
drawSvgPath(ctx, strokes[s]);
ctx.fill();
ctx.restore();
}
ctx.restore();
}
export default class WordDailyCheckinDraw extends BaseDrawService {
async draw(items: DailyCheckinHanziItem[]) {
this.prepareDraw();
await loadFontFace(TONEOZ_PINYIN);
this.drawPageDivider();
const sectionRects = this.getSectionRects();
const itemsPerSection = ROWS_PER_SECTION;
for (let i = 0; i < sectionRects.length; i++) {
const rect = sectionRects[i];
const sectionItems = items.slice(
i * itemsPerSection,
(i + 1) * itemsPerSection,
);
if (sectionItems.length === 0) continue;
this.drawSection(rect, sectionItems, i + 1);
}
}
private drawPageDivider() {
const cy = this.canvasHeight / 2;
this.drawLine(PAGE_MARGIN, cy, this.canvasWidth - PAGE_MARGIN, cy, {
isDashed: true,
dashPattern: [5, 5],
color: '#D6D2CC',
lineWidth: 1,
});
}
private getSectionRects() {
const halfH = this.canvasHeight / 2;
const fullW = this.canvasWidth;
return [
{
x: PAGE_MARGIN,
y: PAGE_MARGIN,
w: fullW - PAGE_MARGIN * 2,
h: halfH - PAGE_MARGIN,
},
{
x: PAGE_MARGIN,
y: halfH,
w: fullW - PAGE_MARGIN * 2,
h: this.canvasHeight - PAGE_MARGIN - halfH,
},
];
}
private drawSection(
rect: { x: number; y: number; w: number; h: number },
items: DailyCheckinHanziItem[],
sectionIndex: number,
) {
const phrase = getRandomEncouragementPhrase();
drawSectionHeader(this.ctx, rect, '汉字每日打卡');
drawSectionFooter(this.ctx, rect, phrase, `- ${sectionIndex} -`);
this.drawSectionContent(rect, items);
}
private drawSectionContent(
rect: { x: number; y: number; w: number; h: number },
items: DailyCheckinHanziItem[],
) {
const contentTop =
rect.y +
SECTION_PADDING_TOP +
HEADER_TITLE_GAP +
HEADER_META_GAP +
CONTENT_TOP_GAP;
const contentBottom =
rect.y +
rect.h -
SECTION_PADDING_BOTTOM -
FOOTER_PHRASE_GAP -
FOOTER_INDEX_GAP;
const contentHeight = contentBottom - contentTop;
const contentLeft = rect.x + SECTION_PADDING_X;
const contentWidth = rect.w - SECTION_PADDING_X * 2;
// 单元尺寸:田字格为正方形,受宽高约束取较小值
const cellWFromWidth = contentWidth / COLUMNS;
// 每个 hanzi 块高度 = pinyinRow + tianzigeRow,取 pinyin = ratio * tianzige
const blockHeightFactor = 1 + PINYIN_ROW_RATIO;
const totalGap = (ROWS_PER_SECTION - 1) * BLOCK_GAP;
const cellWFromHeight =
(contentHeight - totalGap) / (blockHeightFactor * ROWS_PER_SECTION);
const cellW = Math.min(cellWFromWidth, cellWFromHeight);
const tianzigeH = cellW;
const pinyinH = cellW * PINYIN_ROW_RATIO;
const blockH = tianzigeH + pinyinH;
const totalH = ROWS_PER_SECTION * blockH + totalGap;
const startX = contentLeft + (contentWidth - cellW * COLUMNS) / 2;
const startY = contentTop + (contentHeight - totalH) / 2;
for (let i = 0; i < items.length && i < ROWS_PER_SECTION; i++) {
const blockY = startY + i * (blockH + BLOCK_GAP);
this.drawHanziBlock(items[i], startX, blockY, cellW, pinyinH);
}
}
private drawHanziBlock(
item: DailyCheckinHanziItem,
startX: number,
startY: number,
cellW: number,
pinyinH: number,
) {
const tianzigeH = cellW;
const totalW = cellW * COLUMNS;
// 拼音 + 笔画行(顶部)
this.drawPinyinStrokeRow(item, startX, startY, totalW, pinyinH, cellW);
// 田字格练习行(底部)
const tianzigeTop = startY + pinyinH;
this.drawTianzigeRow(item, startX, tianzigeTop, cellW, tianzigeH);
}
private drawPinyinStrokeRow(
item: DailyCheckinHanziItem,
startX: number,
startY: number,
totalW: number,
rowH: number,
cellW: number,
) {
const ctx = this.ctx;
const y1 = startY + rowH / 3;
const y2 = startY + (rowH * 2) / 3;
const yBot = startY + rowH;
ctx.save();
// 外边框
ctx.strokeStyle = GRID_COLORS.border;
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.strokeRect(startX, startY, totalW, rowH);
// 中间两条虚线(四线三格)—— 仅第一格
ctx.strokeStyle = GRID_COLORS.middleLine;
ctx.setLineDash([...GRID_DASH]);
ctx.beginPath();
ctx.moveTo(startX, y1);
ctx.lineTo(startX + cellW, y1);
ctx.moveTo(startX, y2);
ctx.lineTo(startX + cellW, y2);
ctx.stroke();
ctx.beginPath();
ctx.setLineDash([]);
const vx = startX + cellW;
ctx.moveTo(vx, startY);
ctx.lineTo(vx, yBot);
ctx.stroke();
ctx.restore();
// 列分隔线
// ctx.strokeStyle = GRID_COLORS.border;
// ctx.setLineDash([]);
// for (let c = 1; c < COLUMNS; c++) {
// const vx = startX + c * cellW;
// ctx.beginPath();
// ctx.moveTo(vx, startY);
// ctx.lineTo(vx, yBot);
// ctx.stroke();
// }
// 第一格:绘制拼音
if (item.pinyin) {
const cx = startX + cellW / 2;
const fontSize = Math.round(rowH * 0.65);
ctx.save();
ctx.font = `${fontSize}px ${fontFamilyOf(TONEOZ_PINYIN)}`;
ctx.fillStyle = TRACING_COLORS.strong;
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.fillText(item.pinyin, cx, y2);
ctx.restore();
}
// 笔画格:第 2 格起,按笔画顺序展示当前进度
const strokeCount = item.strokes.length;
for (let k = 1; k <= strokeCount && k < COLUMNS; k++) {
const cellX = startX + cellW / 2 + k * 15;
const currentIdx = k - 1;
// 已绘制的历史笔画:浅灰
if (currentIdx > 0) {
drawStrokesRange({
ctx,
strokes: item.strokes,
offsetX: cellX,
offsetY: startY,
width: cellW,
height: rowH,
fromIndex: 0,
toIndex: currentIdx - 1,
fillStyle: TRACING_COLORS.guide,
});
}
// 当前笔画:红色高亮
drawStrokesRange({
ctx,
strokes: item.strokes,
offsetX: cellX,
offsetY: startY,
width: cellW,
height: rowH,
fromIndex: currentIdx,
toIndex: currentIdx,
fillStyle: '#D94B4B',
});
}
}
private drawTianzigeRow(
item: DailyCheckinHanziItem,
startX: number,
startY: number,
cellW: number,
cellH: number,
) {
const ctx = this.ctx;
const TRACING_INDICES = new Set<number>([5, 8]); // 第 6 个和第 9 个(0-based: 5, 8
for (let c = 0; c < COLUMNS; c++) {
const cellX = startX + c * cellW;
const cx = cellX + cellW / 2;
const cy = startY + cellH / 2;
drawTianZiGrid({ ctx, cx, cy, size: cellW });
if (c === 0) {
drawStrokesRange({
ctx,
strokes: item.strokes,
offsetX: cellX,
offsetY: startY,
width: cellW,
height: cellH,
fromIndex: 0,
toIndex: item.strokes.length - 1,
fillStyle: TRACING_COLORS.strong,
});
} else if (TRACING_INDICES.has(c)) {
drawStrokesRange({
ctx,
strokes: item.strokes,
offsetX: cellX,
offsetY: startY,
width: cellW,
height: cellH,
fromIndex: 0,
toIndex: item.strokes.length - 1,
fillStyle: TRACING_COLORS.guide,
});
}
}
}
}
@@ -9,123 +9,13 @@
* - symbol:符号
*/
import { BaseDrawService } from '../../../core/draw/baseDraw';
import { GRID_COLORS, TRACING_COLORS } from '../../../core/data/tracingStyles';
import { TRACING_COLORS } from '../../../core/data/tracingStyles';
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 路径命令
// 支持 MmoveTo)、L(lineTo)、Q(二次贝塞尔曲线)、ZclosePath)
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}`);
}
}
}
import {
getScalingTransform,
drawSvgPath,
drawTianZiGrid,
} from '../../shared/drawUtils';
interface DrawStrokesParams {
ctx: RenderingContext;
@@ -187,7 +77,7 @@ function drawStrokes({
// 绘制路径
ctx.beginPath();
drawSvgPath({ ctx, pathD: strokes[s] });
drawSvgPath(ctx, strokes[s]);
// 使用 fill 绘制(cnchar 只使用 fill
ctx.fillStyle = fillStyle;
@@ -197,48 +87,6 @@ function drawStrokes({
}
}
interface DrawTianZiGridParams {
ctx: RenderingContext;
x: number;
y: number;
size: number;
lineColor?: string;
boldColor?: string;
}
function drawTianZiGrid({
ctx,
x,
y,
size,
lineColor = GRID_COLORS.middleLine,
boldColor = GRID_COLORS.border,
}: DrawTianZiGridParams) {
ctx.strokeStyle = boldColor;
ctx.lineWidth = 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 = GRID_COLORS.diagonal;
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,
@@ -421,7 +269,7 @@ class WordDrawService extends BaseDrawService {
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 });
drawTianZiGrid({ ctx, cx: x, cy: y, size: cellSize });
}
}
}