feat: 增加字母笔顺图片

This commit is contained in:
R524809
2026-04-28 20:09:29 +08:00
parent bb69d5c49d
commit 63fa08ff1a
38 changed files with 458 additions and 163 deletions
@@ -16,7 +16,7 @@ import {
type LetterFontSizes,
} from '../../shared/draw/drawTools';
const SLOTS_PER_ROW = 6;
const SLOTS_PER_ROW = 7;
const PAIR_GAP = 25;
type SingleLineData = Extract<
@@ -26,11 +26,11 @@ type SingleLineData = Extract<
/**
*
* 13 线 6
* 4 Aa 2
* 13 线 7
* 4 Aa
* 线+
*/
export default class SingleLineDraw extends BaseDrawService {
export default class HalfLetterDraw extends BaseDrawService {
private letterSizes: LetterFontSizes =
calcLetterFontSizes(FOUR_LINE_GRID_H);
@@ -4,7 +4,7 @@ import singleLetterDraw from './singleLetterDraw';
import CasePairingDraw from './casePairingDraw';
import TwoColumnDraw from './twoColumnDraw';
import UpperLowerDraw from './upperLowerDraw';
import SingleLineDraw from './singleLineDraw';
import HalfLetterDraw from './halfLetterDraw';
import TripleDraw from './tripleDraw';
import DailyCheckinDraw from './dailyCheckinDraw';
@@ -19,7 +19,7 @@ export default class LetterTracingDraw extends BaseDrawService {
'letter-tracing-upper-lower': UpperLowerDraw,
'letter-tracing-case-pairing': CasePairingDraw,
'letter-tracing-two-column': TwoColumnDraw,
'letter-tracing-half': SingleLineDraw,
'letter-tracing-half': HalfLetterDraw,
'letter-tracing-three': TripleDraw,
'letter-tracing-daily-checkin': DailyCheckinDraw,
}[data.mode];
@@ -4,13 +4,18 @@ import type {
LetterTracingData,
HighlightGrid,
} from '../generators/letter-tracing-generator';
import { fontFamilyOf } from '../../shared/data/fontProfiles';
import {
fontFamilyOf,
PRINT_CLEARLY_BOLD,
PRINT_CLEARLY_DASHED,
} from '../../shared/data/fontProfiles';
import {
calcLetterFontSizes,
drawFourLineGrid,
drawLetterInFourLineGrid,
FOUR_LINE_GRID_H,
getLetterFontSize,
loadLetterFont,
type LetterFontSizes,
} from '../../shared/draw/drawTools';
@@ -27,29 +32,37 @@ type SingleLetterData = Extract<
export default class SingleLetterDraw extends BaseDrawService {
private letterSizes: LetterFontSizes =
calcLetterFontSizes(FOUR_LINE_GRID_H);
private dashedSizes: LetterFontSizes = calcLetterFontSizes(
FOUR_LINE_GRID_H,
PRINT_CLEARLY_DASHED,
);
private get letterFace(): string {
return fontFamilyOf(this.letterSizes._profile);
}
async draw(data: SingleLetterData) {
this.prepareDraw();
await this.drawHeaderAndDivider();
await Promise.all([
this.drawHeaderAndDivider(),
loadLetterFont(PRINT_CLEARLY_BOLD),
loadLetterFont(PRINT_CLEARLY_DASHED),
]);
await this.drawContent(data);
this.drawPrintFooter();
}
private async drawContent(data: SingleLetterData) {
const { teaching, highlightGrid, traceImgPath } = data;
const { teaching, highlightGrid } = data;
const ctx = this.ctx;
// ── 上区:left:78 top:120 w:451 h:180 ──
const TOP_X = 78;
const TOP_Y = 120;
const TOP_Y = 110;
const letterImgX = TOP_X + 15;
const letterImgY = TOP_Y + 9;
const letterImgW = 180;
const letterImgH = 103.5;
const letterImgH = 130;
try {
const letterImg = (await getImage(
@@ -85,8 +98,8 @@ export default class SingleLetterDraw extends BaseDrawService {
this.drawSingleLetterSentence(
teaching.sentence,
teaching.upper,
TOP_X,
TOP_Y + 9 + 120,
letterImgX + letterImgW / 2,
TOP_Y + 16 + 120,
);
// 右侧配图
@@ -142,13 +155,7 @@ export default class SingleLetterDraw extends BaseDrawService {
ctx.strokeRect(TR_X, TR_Y, TR_W, TR_H);
this.drawHighlightSection(highlightGrid, HL_X, HL_Y);
await this.drawTraceItSection(
traceImgPath,
teaching.upper,
teaching.lower,
TR_X,
TR_Y,
);
this.drawTraceItSection(teaching.upper, teaching.lower, TR_X, TR_Y);
// ── 下区:4 行四线三格 + 字母临摹(倒置直角三角形)──
// 每行:left:24 w:547 h:43top 分别 524.5 / 590.5 / 656.5 / 722.5
@@ -159,21 +166,27 @@ export default class SingleLetterDraw extends BaseDrawService {
// 8 → 6 → 3 → 1 组(每组为 Upper+Lower),左对齐,形成倒置直角三角形
const groupsPerLine = [8, 6, 3, 1];
const slotW = lineW / groupsPerLine[0]; // 以第一行的 8 组为基准
const lineStyles = [
{ color: 'rgba(26, 26, 26, 1)', alpha: 1, dashed: false },
{ color: '#FF8E8E', alpha: 1, dashed: false },
{ color: '#FF8E8E', alpha: 0.5, dashed: false },
{ color: '#FF8E8E', alpha: 0.5, dashed: true },
] as const;
for (let r = 0; r < lineTops.length; r++) {
const y = lineTops[r];
drawFourLineGrid(ctx, lineX, y, lineW, FOUR_LINE_GRID_H);
const n = groupsPerLine[r] ?? 0;
const style = lineStyles[r] ?? lineStyles[lineStyles.length - 1];
for (let i = 0; i < n; i++) {
const alpha = n <= 1 ? 1 : 1 - (i / (n - 1)) * 0.7;
this.drawUpperLowerPairInFourLines(
teaching.upper,
teaching.lower,
lineX + i * slotW,
y,
slotW,
alpha,
style,
);
}
}
@@ -188,26 +201,32 @@ export default class SingleLetterDraw extends BaseDrawService {
x: number,
y: number,
w: number,
alpha: number,
style: {
color: string;
alpha: number;
dashed: boolean;
},
) {
const ctx = this.ctx;
const sizes = this.letterSizes;
const sizes = style.dashed ? this.dashedSizes : this.letterSizes;
const fontFamily = fontFamilyOf(
style.dashed ? PRINT_CLEARLY_DASHED : sizes._profile,
);
ctx.save();
ctx.globalAlpha = Math.max(0, Math.min(1, alpha));
ctx.globalAlpha = Math.max(0, Math.min(1, style.alpha));
const pairGap = 3;
const upperFs = getLetterFontSize(upper, sizes);
ctx.font = `${upperFs}px ${this.letterFace}`;
ctx.font = `${upperFs}px ${fontFamily}`;
const upperW = ctx.measureText(upper).width;
const lowerFs = getLetterFontSize(lower, sizes);
ctx.font = `${lowerFs}px ${this.letterFace}`;
ctx.font = `${lowerFs}px ${fontFamily}`;
const lowerW = ctx.measureText(lower).width;
const totalPairW = upperW + pairGap + lowerW;
const pairStartX = x + (w - totalPairW) / 2;
const color = 'rgba(26, 26, 26, 1)';
drawLetterInFourLineGrid(
ctx,
@@ -216,7 +235,7 @@ export default class SingleLetterDraw extends BaseDrawService {
y,
FOUR_LINE_GRID_H,
sizes,
{ fontFamily: this.letterFace, color },
{ fontFamily, color: style.color },
);
drawLetterInFourLineGrid(
ctx,
@@ -225,7 +244,7 @@ export default class SingleLetterDraw extends BaseDrawService {
y,
FOUR_LINE_GRID_H,
sizes,
{ fontFamily: this.letterFace, color },
{ fontFamily, color: style.color },
);
ctx.restore();
@@ -234,15 +253,20 @@ export default class SingleLetterDraw extends BaseDrawService {
private drawSingleLetterSentence(
sentence: string,
letter: string,
x: number,
centerX: number,
y: number,
) {
const ctx = this.ctx;
ctx.font = `36px ${this.letterFace}`;
ctx.font = `bold 40px ${this.letterFace}`;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
let cx = x;
let totalWidth = 0;
for (let i = 0; i < sentence.length; i++) {
totalWidth += ctx.measureText(sentence[i]).width;
}
let cx = centerX - totalWidth / 2;
for (let i = 0; i < sentence.length; i++) {
const ch = sentence[i];
ctx.fillStyle =
@@ -302,8 +326,7 @@ export default class SingleLetterDraw extends BaseDrawService {
}
}
private async drawTraceItSection(
traceImgPath: string,
private drawTraceItSection(
upper: string,
lower: string,
sx: number,
@@ -357,37 +380,39 @@ export default class SingleLetterDraw extends BaseDrawService {
ctx.lineTo(lx + lw, lines[3]);
ctx.stroke();
// 笔顺图
const imgX = 337,
imgY = 365,
imgW = 159,
imgH = 80;
try {
const img = (await getImage(
this.canvas,
traceImgPath,
)) as WechatMiniprogram.Image;
const aspect = (img.width || 1) / (img.height || 1);
let dw = imgW;
let dh = dw / aspect;
if (dh > imgH) {
dh = imgH;
dw = dh * aspect;
}
ctx.drawImage(
img,
imgX + (imgW - dw) / 2,
imgY + (imgH - dh) / 2,
dw,
dh,
);
} catch {
const fs = Math.min(imgH * 0.6, imgW * 0.18);
ctx.font = `bold ${fs}px ${this.letterFace}`;
ctx.fillStyle = '#222';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${upper} ${lower}`, imgX + imgW / 2, imgY + imgH / 2);
}
const gridH = lines[3] - lines[0];
const boldSizes = calcLetterFontSizes(gridH, PRINT_CLEARLY_BOLD);
const boldFace = fontFamilyOf(PRINT_CLEARLY_BOLD);
const pairGap = 20;
const upperFs = getLetterFontSize(upper, boldSizes);
ctx.font = `${upperFs}px ${boldFace}`;
const upperW = ctx.measureText(upper).width;
const lowerFs = getLetterFontSize(lower, boldSizes);
ctx.font = `${lowerFs}px ${boldFace}`;
const lowerW = ctx.measureText(lower).width;
const totalW = upperW + pairGap + lowerW;
const startX = lx + (lw - totalW) / 2;
drawLetterInFourLineGrid(
ctx,
upper,
startX + upperW / 2,
lines[0],
gridH,
boldSizes,
{ fontFamily: boldFace, color: '#222' },
);
drawLetterInFourLineGrid(
ctx,
lower,
startX + upperW + pairGap + lowerW / 2,
lines[0],
gridH,
boldSizes,
{ fontFamily: boldFace, color: '#222' },
);
}
}
@@ -17,8 +17,8 @@ import {
type LetterFontSizes,
} from '../../shared/draw/drawTools';
// 每行可容纳的格子数(用于三字母精练模板,一行展示 6 个位置)
const SLOTS_PER_ROW = 6;
// 每行按 7 个位置预留空间,当前字母绘制占用其中前几组
const SLOTS_PER_ROW = 7;
// 大小写字母配对间的间隔(px,字母间距)
const PAIR_GAP = 25;
// 同一字母区域内相邻两行四线三格之间的间距(px)
@@ -317,9 +317,7 @@ export function generateLetterTracing(
const upper = key;
const lower = key.toLowerCase();
const hasLetterAsset = key === 'A' || key === 'B';
const letterFile = hasLetterAsset ? key : 'A';
const letterImgPath = `/englishPages/shared/assets/letter/${letterFile}.png`;
const letterImgPath = `/englishPages/shared/assets/letter/${key}.png`;
const wordImgPath = `/englishPages/shared/assets/letterImgs/Apple.png`;
@@ -361,9 +359,15 @@ export function generateLetterTracing(
if (mode === 'letter-tracing-two-column') {
const leftLetters = LETTERS_UPPER.slice(0, 13);
const rightLetters = LETTERS_UPPER.slice(13);
const twoColumnRepetitions = 4;
const toRows = (letters: string[]) =>
letters.map((L) =>
rowForChar(L, repetitions, fadePattern, L.toLowerCase()),
rowForChar(
L,
twoColumnRepetitions,
fadePattern,
L.toLowerCase(),
),
);
return {
mode: 'letter-tracing-two-column',
@@ -450,11 +454,7 @@ export function mergeLetterTracingConfig(
'A-M') as AlphabetHalf,
tripleLetters: overrides.tripleLetters ??
base.tripleLetters ?? ['A', 'B', 'C'],
dailyLetters: overrides.dailyLetters ?? base.dailyLetters ?? [
'A',
'B',
'C',
'D',
],
dailyLetters: overrides.dailyLetters ??
base.dailyLetters ?? ['A', 'B', 'C', 'D'],
};
}
@@ -19,7 +19,7 @@ const MODES = [
title: '默认字帖',
subtitle: '配图、例句与描红',
difficulty: 1,
tags: ['字母描红', '英语启蒙', '看图描红'],
tags: ['字母', '描红', '看图描红'],
sortOrder: 42,
},
{
@@ -28,7 +28,7 @@ const MODES = [
title: '基础描红',
subtitle: 'Uppercase / Lowercase 总览',
difficulty: 1,
tags: ['字母描红', '英语启蒙', '字母总览'],
tags: ['字母', '描红', '字母总览'],
sortOrder: 44,
},
{
@@ -37,7 +37,7 @@ const MODES = [
title: '大小写对照',
subtitle: '半组字母左大写右小写',
difficulty: 1,
tags: ['字母描红', '英语启蒙', '大小写练习'],
tags: ['字母', '描红', '大小写练习'],
sortOrder: 45,
},
{
@@ -46,7 +46,7 @@ const MODES = [
title: '两列描红',
subtitle: '左 AM、右 NZ 配对描红',
difficulty: 1,
tags: ['字母描红', '英语启蒙', '两列练习'],
tags: ['字母', '描红', '两列练习'],
sortOrder: 43,
},
{
@@ -55,7 +55,7 @@ const MODES = [
title: '13字母半表',
subtitle: '每行一个字母,13 字母半表',
difficulty: 2,
tags: ['字母描红', '英语启蒙', '单字母逐行'],
tags: ['字母', '描红', '半表练习'],
sortOrder: 47,
},
{
@@ -64,25 +64,26 @@ const MODES = [
title: '三字母精练',
subtitle: '每页聚焦 3 个字母深度书写',
difficulty: 2,
tags: ['字母描红', '英语启蒙', '三字母精练'],
tags: ['字母', '描红', '三字母精练'],
sortOrder: 46,
},
{
id: 'letter-tracing-daily-checkin',
icon: 'draw-o',
icon: 'task-o',
title: '每日打卡',
subtitle: '四宫格每日字母打卡练习',
difficulty: 2,
tags: ['字母描红', '英语启蒙', '每日打卡'],
tags: ['字母', '描红', '每日打卡'],
sortOrder: 48,
},
] as const satisfies ReadonlyArray<ModeDefinition>;
type Mode = (typeof MODES)[number];
const MODE_BY_ID = Object.fromEntries(
MODES.map((m) => [m.id, m]),
) as Record<string, Mode>;
const MODE_BY_ID = Object.fromEntries(MODES.map((m) => [m.id, m])) as Record<
string,
Mode
>;
/** 页面渲染用:模式选择器列表 */
export const LETTER_TRACING_MODE_OPTIONS = MODES;