feat: 英语字母描红修改

This commit is contained in:
R524809
2026-04-14 17:34:05 +08:00
parent e74fd3709c
commit ec5bc44741
11 changed files with 546 additions and 342 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -2,14 +2,15 @@ import { BaseDrawService } from '../../../core/draw/baseDraw';
import type {
LetterTracingData,
TracingRow,
} from '../../../core/generators/letter-tracing-generator';
} from '../generators/letter-tracing-generator';
const M = 24;
const BLUE = '#4A90E2';
const ORANGE = '#FF8C42';
/** 四线三格线色:偏黑,实线与虚线统一色相,仅以线型区分 */
const LINE_INK = '#2a2a2a';
/**
* 字母描红绘制tracing-writing + letter-tracing 的客户端实现)
* 字母描红绘制
* 四线三格:顶线、上中线、下中线为格内三线间距;最上最下为实线,中间两虚线(产品文档)
*/
export default class LetterTracingDraw extends BaseDrawService {
constructor(
@@ -25,53 +26,65 @@ export default class LetterTracingDraw extends BaseDrawService {
await this.drawHeaderAndDivider();
switch (data.mode) {
case 'single-letter':
this.drawSingleLetter(data);
case 'picture-tracing':
this.drawPictureTracing(data);
this.drawPrintFooter();
break;
case 'full-alphabet':
this.drawFullAlphabet(data.rows);
case 'case-pairing':
this.drawCasePairing(data.leftVisualRows, data.rightVisualRows);
this.drawPrintFooter();
break;
case 'upper-lower-split':
this.drawUpperLowerSplit(data.upperGlyphs, data.lowerGlyphs);
case 'column-compare':
this.drawColumnCompare(data.leftRows, data.rightRows);
this.drawPrintFooter();
break;
case 'two-column':
this.drawTwoColumn(data.leftRows, data.rightRows);
case 'overview':
this.drawOverview(data.upperRows, data.lowerRows);
this.drawPrintFooter();
break;
}
}
/** 四线三格:顶线、中虚线、基线、底线 */
/**
* 四线三格:y 顶 → y+h 底,中间两条虚线
*/
private drawFourLines(x: number, y: number, w: number, h: number) {
const ctx = this.ctx;
const yTop = y;
const yMid = y + h * 0.28;
const yBase = y + h * 0.62;
const yMid1 = y + h * 0.28;
const yMid2 = y + h * 0.58;
const yBot = y + h;
ctx.strokeStyle = BLUE;
ctx.lineWidth = 1;
ctx.setLineDash([]);
const strokeSolid = () => {
ctx.strokeStyle = LINE_INK;
ctx.lineWidth = 1;
ctx.setLineDash([]);
};
const strokeDash = () => {
ctx.strokeStyle = LINE_INK;
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
};
strokeSolid();
ctx.beginPath();
ctx.moveTo(x, yTop);
ctx.lineTo(x + w, yTop);
ctx.stroke();
ctx.strokeStyle = ORANGE;
ctx.setLineDash([6, 6]);
strokeDash();
ctx.beginPath();
ctx.moveTo(x, yMid);
ctx.lineTo(x + w, yMid);
ctx.moveTo(x, yMid1);
ctx.lineTo(x + w, yMid1);
ctx.stroke();
ctx.strokeStyle = BLUE;
ctx.beginPath();
ctx.moveTo(x, yMid2);
ctx.lineTo(x + w, yMid2);
ctx.stroke();
strokeSolid();
ctx.setLineDash([]);
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(x, yBase);
ctx.lineTo(x + w, yBase);
ctx.stroke();
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, yBot);
ctx.lineTo(x + w, yBot);
@@ -86,18 +99,30 @@ export default class LetterTracingDraw extends BaseDrawService {
h: number,
opacity: number,
bold: boolean,
pairChar?: string,
) {
const ctx = this.ctx;
if (opacity <= 0) return;
const fontSize = Math.min(h * 0.72, w * 0.85);
ctx.save();
ctx.globalAlpha = opacity;
ctx.fillStyle = bold ? '#111' : '#666';
ctx.font = `${bold ? 'bold ' : ''}${fontSize}px sans-serif`;
ctx.fillStyle = bold ? '#1a1a1a' : '#4a4a4a';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
const baselineY = y + h * 0.62;
ctx.fillText(ch, x + w / 2, baselineY);
const baselineY = y + h * 0.72;
if (pairChar) {
const fs = Math.min(h * 0.5, w * 0.22);
ctx.font = `${bold ? 'bold ' : ''}${fs}px sans-serif`;
const gap = fs * 0.2;
const totalW = fs * 2 + gap;
const x0 = x + w / 2 - totalW / 2;
ctx.fillText(ch, x0 + fs * 0.5, baselineY);
ctx.fillText(pairChar, x0 + fs * 1.5 + gap, baselineY);
} else {
const fontSize = Math.min(h * 0.72, w * 0.85);
ctx.font = `${bold ? 'bold ' : ''}${fontSize}px sans-serif`;
ctx.fillText(ch, x + w / 2, baselineY);
}
ctx.restore();
}
@@ -110,7 +135,7 @@ export default class LetterTracingDraw extends BaseDrawService {
) {
const gap = 4;
const n = row.cells.length;
const cellW = (totalWidth - gap * (n - 1)) / n;
const cellW = n > 0 ? (totalWidth - gap * (n - 1)) / n : totalWidth;
for (let i = 0; i < n; i++) {
const cx = startX + i * (cellW + gap);
@@ -124,140 +149,182 @@ export default class LetterTracingDraw extends BaseDrawService {
rowH,
cell.opacity,
cell.isGuide,
cell.pairChar,
);
}
}
private drawSingleLetter(
data: Extract<LetterTracingData, { mode: 'single-letter' }>,
/**
* 看图描红:上左超大字母+例句,上右「配图」大 emoji;下 6 行四线三格
*/
private drawPictureTracing(
data: Extract<LetterTracingData, { mode: 'picture-tracing' }>,
) {
const ctx = this.ctx;
const { teaching, rows } = data;
const cw = this.canvasWidth;
let y = this.currentY + 8;
const teachH = Math.min(220, (this.canvasHeight - y) * 0.42);
const teachH = Math.min(240, (this.canvasHeight - y) * 0.38);
const innerW = cw - M * 2;
const splitX = M + innerW * 0.52;
// 教学区背景
ctx.fillStyle = '#f8fafc';
ctx.fillRect(M, y, cw - M * 2, teachH);
ctx.fillRect(M, y, innerW, teachH);
// 左侧 emoji
ctx.font = `${Math.min(88, teachH * 0.45)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(teaching.emoji, M + (cw - M * 2) * 0.22, y + teachH / 2);
// 右侧文字
const tx = M + (cw - M * 2) * 0.42;
// 左侧:大小写 + 例句(短句在字母下方)
const leftPad = M + 20;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillStyle = '#111';
ctx.font = 'bold 44px sans-serif';
ctx.fillText(`${teaching.upper} ${teaching.lower}`, tx, y + 16);
ctx.font = '22px sans-serif';
const bigFs = Math.min(56, teachH * 0.26);
ctx.font = `bold ${bigFs}px sans-serif`;
ctx.fillText(
`${teaching.upper} ${teaching.lower}`,
leftPad,
y + teachH * 0.12,
);
ctx.font = `${Math.min(22, teachH * 0.1)}px sans-serif`;
ctx.fillStyle = '#333';
ctx.fillText(teaching.sentence, tx, y + 72);
ctx.fillStyle = '#666';
ctx.font = '18px sans-serif';
ctx.fillText(teaching.word, tx, y + 104);
ctx.fillText(teaching.sentence, leftPad, y + teachH * 0.48);
// 右侧:配图区(以 emoji 作为插图占位)
ctx.font = `${Math.min(120, teachH * 0.55)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
teaching.emoji,
splitX + (cw - M - splitX) / 2,
y + teachH / 2,
);
y += teachH + 12;
this.drawDashedDivider(y, M, '#ccc');
y += 14;
const rest = this.canvasHeight - y - M;
const rowH = Math.max(36, (rest - 8) / rows.length);
const rowWidth = cw - M * 2;
const rowH = Math.max(34, (rest - 10) / rows.length);
const rowWidth = innerW;
for (let r = 0; r < rows.length; r++) {
this.drawTracingRow(
rows[r],
M,
y + r * (rowH + 10),
y + r * (rowH + 8),
rowH,
rowWidth,
);
}
}
private drawFullAlphabet(rows: TracingRow[]) {
/**
* 大小写配对:左右两栏,每行最多 4 组子行并排
*/
private drawCasePairing(
leftVisualRows: TracingRow[][],
rightVisualRows: TracingRow[][],
) {
const y0 = this.currentY + 6;
const bottom = this.canvasHeight - M;
const totalH = bottom - y0;
const rowH = Math.max(14, totalH / rows.length - 2);
const rowWidth = this.canvasWidth - M * 2;
const gapCol = 18;
const colW = (this.canvasWidth - M * 2 - gapCol) / 2;
const maxVisual = Math.max(
leftVisualRows.length,
rightVisualRows.length,
);
const rowSlotH = Math.max(40, (bottom - y0) / maxVisual - 6);
for (let i = 0; i < rows.length; i++) {
const y = y0 + i * (rowH + 2);
this.drawTracingRow(rows[i], M, y, rowH, rowWidth);
}
}
private drawUpperLowerSplit(upper: string[], lower: string[]) {
const ctx = this.ctx;
const cw = this.canvasWidth;
let y = this.currentY + 6;
const innerW = cw - M * 2;
const drawBlockTitle = (t: string) => {
ctx.fillStyle = '#111';
ctx.font = 'bold 18px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(t, M, y);
y += 26;
};
const drawGlyphGrid = (chars: string[], cols: number) => {
const rows = Math.ceil(chars.length / cols);
const cellW = (innerW - (cols - 1) * 4) / cols;
const cellH = 32;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const idx = r * cols + c;
if (idx >= chars.length) break;
const cx = M + c * (cellW + 4);
this.drawFourLines(cx, y, cellW, cellH);
this.drawCharInCell(
chars[idx],
cx,
const drawSide = (
blocks: TracingRow[][],
startX: number,
width: number,
) => {
let y = y0;
for (const visualRow of blocks) {
const nSeg = visualRow.length;
const segGap = 6;
const segW = (width - (nSeg - 1) * segGap) / nSeg;
for (let s = 0; s < nSeg; s++) {
this.drawTracingRow(
visualRow[s],
startX + s * (segW + segGap),
y,
cellW,
cellH,
0.85,
true,
rowSlotH,
segW,
);
}
y += cellH + 6;
y += rowSlotH + 8;
}
};
drawBlockTitle('大写字母 Uppercase');
drawGlyphGrid(upper, 13);
y += 8;
this.drawDashedDivider(y, M, '#bbb');
y += 14;
drawBlockTitle('小写字母 Lowercase');
drawGlyphGrid(lower, 13);
drawSide(leftVisualRows, M, colW);
drawSide(rightVisualRows, M + colW + gapCol, colW);
}
private drawTwoColumn(leftRows: TracingRow[], rightRows: TracingRow[]) {
/** 分栏对照:左大写、右小写,逐行对齐 */
private drawColumnCompare(leftRows: TracingRow[], rightRows: TracingRow[]) {
const y0 = this.currentY + 6;
const bottom = this.canvasHeight - M;
const totalH = bottom - y0;
const maxRows = Math.max(leftRows.length, rightRows.length);
const rowH = Math.max(16, totalH / maxRows - 3);
const maxR = Math.max(leftRows.length, rightRows.length);
const rowH = Math.max(36, (bottom - y0) / maxR - 6);
const gapCol = 16;
const colW = (this.canvasWidth - M * 2 - gapCol) / 2;
for (let i = 0; i < leftRows.length; i++) {
const y = y0 + i * (rowH + 3);
const y = y0 + i * (rowH + 6);
this.drawTracingRow(leftRows[i], M, y, rowH, colW);
}
const xR = M + colW + gapCol;
for (let i = 0; i < rightRows.length; i++) {
const y = y0 + i * (rowH + 3);
const y = y0 + i * (rowH + 6);
this.drawTracingRow(rightRows[i], xR, y, rowH, colW);
}
}
/**
* 字母总览:Uppercase Letters / lowercase Letters,各行 9+9+8,四线三格
*/
private drawOverview(upperRows: string[][], lowerRows: string[][]) {
const ctx = this.ctx;
let y = this.currentY + 6;
const innerW = this.canvasWidth - M * 2;
const title = (t: string) => {
ctx.fillStyle = '#111';
ctx.font = 'bold 17px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(t, M, y);
y += 24;
};
const drawBand = (rows: string[][]) => {
for (const line of rows) {
const n = line.length;
const gap = 4;
const cellW = (innerW - (n - 1) * gap) / n;
const cellH = 30;
for (let i = 0; i < n; i++) {
const cx = M + i * (cellW + gap);
this.drawFourLines(cx, y, cellW, cellH);
this.drawCharInCell(
line[i],
cx,
y,
cellW,
cellH,
0.9,
true,
);
}
y += cellH + 8;
}
};
title('Uppercase Letters');
drawBand(upperRows);
y += 6;
this.drawDashedDivider(y, M, '#bbb');
y += 12;
title('lowercase Letters');
drawBand(lowerRows);
}
}
@@ -0,0 +1,260 @@
import { ALPHABET_BY_LETTER, LETTERS_UPPER } from '../../../core/data/alphabet';
/** 字母描红的布局模式(与产品文档四类练习一致) */
export type LetterTracingMode =
/** 4 看图描红:教学区 + 6 行描红(前三行大写、后三行小写) */
| 'picture-tracing'
/** 1 大小写配对:左栏 A–M、右栏 N–Z,每行最多 4 组 Aa */
| 'case-pairing'
/** 2 分栏对照:半组字母,左栏大写、右栏小写,每行 4 个 */
| 'column-compare'
/** 3 字母总览:Uppercase / Lowercase 分区,每区 9+9+8 */
| 'overview';
/** 字母大小写模式(部分模式不使用) */
export type LetterCaseMode = 'upper' | 'lower' | 'both';
/** A–M / N–Z 半表(分栏对照) */
export type AlphabetHalf = 'A-M' | 'N-Z';
/** 字母描红生成器的配置项 */
export interface LetterTracingGeneratorConfig {
mode: LetterTracingMode;
letterCase: LetterCaseMode;
selectedLetter?: string;
repetitions: number;
fadePattern: 'gradient' | 'first-only';
/** 分栏对照:练习 A–M 或 N–Z */
alphabetHalf?: AlphabetHalf;
}
/** 单个描红格子的数据 */
export interface TracingCell {
char: string;
opacity: number;
isGuide: boolean;
/** 与 char 组成大小写一对(如 A + a),用于大小写配对格内展示 */
pairChar?: string;
}
/** 一行描红数据,包含展示字符和若干格子 */
export interface TracingRow {
displayChar: string;
cells: TracingCell[];
}
/** 看图描红教学区 */
export interface TeachingBlock {
upper: string;
lower: string;
word: string;
sentence: string;
emoji: string;
}
export type LetterTracingData =
| {
mode: 'picture-tracing';
teaching: TeachingBlock;
rows: TracingRow[];
}
| {
mode: 'case-pairing';
leftVisualRows: TracingRow[][];
rightVisualRows: TracingRow[][];
}
| {
mode: 'column-compare';
leftRows: TracingRow[];
rightRows: TracingRow[];
}
| {
mode: 'overview';
upperRows: string[][];
lowerRows: string[][];
};
/** 将用户输入的字母标准化为大写单字符,默认 'A' */
export function normalizeLetter(c: string): string {
const u = c.trim().toUpperCase();
return u.length ? u[0] : 'A';
}
function buildCells(
char: string,
count: number,
fadePattern: 'gradient' | 'first-only',
pairChar?: string,
): TracingCell[] {
const n = Math.max(1, Math.min(12, count));
const cells: TracingCell[] = [];
for (let i = 0; i < n; i++) {
let opacity: number;
if (fadePattern === 'first-only') {
opacity = i === 0 ? 1 : 0;
} else if (n <= 1) {
opacity = 1;
} else {
opacity = Math.max(0.12, 1 - i / (n - 1));
}
cells.push({
char,
opacity,
isGuide: i === 0,
...(pairChar !== undefined ? { pairChar } : {}),
});
}
return cells;
}
function rowForChar(
ch: string,
repetitions: number,
fadePattern: 'gradient' | 'first-only',
pairChar?: string,
): TracingRow {
return {
displayChar: ch,
cells: buildCells(ch, repetitions, fadePattern, pairChar),
};
}
/** 一行内多个字母,各自占一段描红格(用于分栏对照等) */
function rowForManyChars(
chars: string[],
repetitions: number,
fadePattern: 'gradient' | 'first-only',
): TracingRow {
const cells: TracingCell[] = [];
for (const ch of chars) {
cells.push(...buildCells(ch, repetitions, fadePattern));
}
return {
displayChar: chars.join(''),
cells,
};
}
function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < arr.length; i += size) {
out.push(arr.slice(i, i + size));
}
return out;
}
/** 总览区 26 字母拆成 9 + 9 + 8 三行 */
function overviewRowsFrom(letters: string[]): string[][] {
return [letters.slice(0, 9), letters.slice(9, 18), letters.slice(18, 26)];
}
/**
* 根据配置生成字母描红数据
* - picture-tracing:教学区 + 6 行描红
* - case-pairing:左 AM / 右 N–Z,每行最多 4 组大小写配对
* - column-compare:半表,左大写右小写,每行 4 个
* - overviewUppercase / Lowercase Letters9+9+8
*/
export function generateLetterTracing(
config: LetterTracingGeneratorConfig,
): LetterTracingData {
const { mode, repetitions, fadePattern } = config;
if (mode === 'picture-tracing') {
const key = normalizeLetter(config.selectedLetter ?? 'A');
const meta = ALPHABET_BY_LETTER[key] ?? ALPHABET_BY_LETTER.A;
const upper = key;
const lower = key.toLowerCase();
const teaching: TeachingBlock = {
upper,
lower,
word: meta.word,
sentence: `${upper} is for ${meta.word}`,
emoji: meta.emoji,
};
const rows: TracingRow[] = [
rowForChar(upper, repetitions, fadePattern),
rowForChar(upper, repetitions, fadePattern),
rowForChar(upper, repetitions, fadePattern),
rowForChar(lower, repetitions, fadePattern),
rowForChar(lower, repetitions, fadePattern),
rowForChar(lower, repetitions, fadePattern),
];
return { mode: 'picture-tracing', teaching, rows };
}
if (mode === 'case-pairing') {
const leftLetters = LETTERS_UPPER.slice(0, 13);
const rightLetters = LETTERS_UPPER.slice(13);
const pack = (letters: string[]) =>
chunk(letters, 4).map((group) =>
group.map((L) =>
rowForChar(
L,
repetitions,
fadePattern,
L.toLowerCase(),
),
),
);
return {
mode: 'case-pairing',
leftVisualRows: pack(leftLetters),
rightVisualRows: pack(rightLetters),
};
}
if (mode === 'column-compare') {
const half =
config.alphabetHalf === 'N-Z'
? LETTERS_UPPER.slice(13)
: LETTERS_UPPER.slice(0, 13);
const rowsOf4 = chunk(half, 4);
const leftRows = rowsOf4.map((g) =>
rowForManyChars(g, repetitions, fadePattern),
);
const rightRows = rowsOf4.map((g) =>
rowForManyChars(
g.map((L) => L.toLowerCase()),
repetitions,
fadePattern,
),
);
return { mode: 'column-compare', leftRows, rightRows };
}
// overview
return {
mode: 'overview',
upperRows: overviewRowsFrom([...LETTERS_UPPER]),
lowerRows: overviewRowsFrom(
LETTERS_UPPER.map((L) => L.toLowerCase()),
),
};
}
/** 与 WorksheetDefinition.generatorConfig 合并后的运行时配置 */
export function mergeLetterTracingConfig(
base: Partial<LetterTracingGeneratorConfig>,
overrides: Partial<LetterTracingGeneratorConfig>,
): LetterTracingGeneratorConfig {
return {
mode: (overrides.mode ??
base.mode ??
'picture-tracing') as LetterTracingMode,
letterCase: (overrides.letterCase ??
base.letterCase ??
'upper') as LetterCaseMode,
selectedLetter: overrides.selectedLetter ?? base.selectedLetter,
repetitions: Math.max(
1,
(overrides.repetitions ?? base.repetitions ?? 5) as number,
),
fadePattern: (overrides.fadePattern ??
base.fadePattern ??
'gradient') as 'gradient' | 'first-only',
alphabetHalf: (overrides.alphabetHalf ??
base.alphabetHalf ??
'A-M') as AlphabetHalf,
};
}