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

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+5 -5
View File
@@ -24,16 +24,16 @@
"miniprogram": { "miniprogram": {
"list": [ "list": [
{ {
"name": "supportPages/debug/debug", "name": "englishPages/letterTracing/letterTracing",
"pathName": "supportPages/debug/debug", "pathName": "englishPages/letterTracing/letterTracing",
"query": "", "query": "",
"scene": null, "scene": null,
"launchMode": "default" "launchMode": "default"
}, },
{ {
"name": "englishPages/letterTracing/letterTracing", "name": "supportPages/debug/debug",
"pathName": "englishPages/letterTracing/letterTracing", "pathName": "supportPages/debug/debug",
"query": "id=letter-tracing-daily-checkin", "query": "",
"launchMode": "default", "launchMode": "default",
"scene": null "scene": null
}, },