feat: 增加字母笔顺图片
This commit is contained in:
+4
-4
@@ -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:43,top 分别 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: '左 A–M、右 N–Z 配对描红',
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user