feat: 修改字母描红逻辑,添加字母手写字体

This commit is contained in:
R524809
2026-04-15 17:29:59 +08:00
parent ec5bc44741
commit 4299c4e082
62 changed files with 4135 additions and 894 deletions
@@ -9,7 +9,7 @@ import {
type LetterTracingGeneratorConfig,
type LetterTracingMode,
} from '../shared/generators/letter-tracing-generator';
import { LETTERS_UPPER } from '../../core/data/alphabet';
import { LETTERS_UPPER } from '../shared/data/alphabet';
/** 各 worksheetId 对应的页面标题和描述 */
const PAGE_META: Record<string, { title: string; desc: string }> = {
@@ -99,7 +99,7 @@ createPage(
});
},
/** 页面就绪:初始化 Canvas 和绘制服务,完成后触发首次绘制 */
/** 页面就绪:初始化 Canvas → 首次绘制(标题行前缀使用系统 emoji,无需 loadFontFace */
onReady() {
this.initCanvas({
createDrawService: (
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

@@ -0,0 +1,39 @@
/**
* 26 字母与示例词(首版用 emoji 占位配图,后续可换 imageUrl)
*/
export interface AlphabetEntry {
word: string;
/** 与单词相关的 emoji,Canvas 上直接绘制,无需图片资源 */
emoji: string;
}
export const ALPHABET_BY_LETTER: Record<string, AlphabetEntry> = {
A: { word: 'Apple', emoji: '🍎' },
B: { word: 'Ball', emoji: '⚽' },
C: { word: 'Cat', emoji: '🐱' },
D: { word: 'Dog', emoji: '🐶' },
E: { word: 'Egg', emoji: '🥚' },
F: { word: 'Fish', emoji: '🐟' },
G: { word: 'Girl', emoji: '👧' },
H: { word: 'House', emoji: '🏠' },
I: { word: 'Ice', emoji: '🧊' },
J: { word: 'Juice', emoji: '🧃' },
K: { word: 'Kite', emoji: '🪁' },
L: { word: 'Lion', emoji: '🦁' },
M: { word: 'Moon', emoji: '🌙' },
N: { word: 'Nest', emoji: '🪺' },
O: { word: 'Orange', emoji: '🍊' },
P: { word: 'Pig', emoji: '🐷' },
Q: { word: 'Queen', emoji: '👑' },
R: { word: 'Rain', emoji: '🌧️' },
S: { word: 'Sun', emoji: '☀️' },
T: { word: 'Tree', emoji: '🌳' },
U: { word: 'Umbrella', emoji: '☂️' },
V: { word: 'Van', emoji: '🚐' },
W: { word: 'Water', emoji: '💧' },
X: { word: 'X-ray', emoji: '🦴' },
Y: { word: 'Yacht', emoji: '⛵' },
Z: { word: 'Zebra', emoji: '🦓' },
};
export const LETTERS_UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
@@ -0,0 +1,124 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type {
LetterTracingData,
TracingRow,
} from '../generators/letter-tracing-generator';
import { drawFourLineGrid } from './drawTools';
const LETTER_FACE =
'Helvetica, Arial, "Segoe UI", Roboto, sans-serif';
type CasePairingData = Extract<LetterTracingData, { mode: 'case-pairing' }>;
export default class CasePairingDraw extends BaseDrawService {
async draw(data: CasePairingData) {
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawContent(data.leftVisualRows, data.rightVisualRows);
this.drawPrintFooter();
}
private drawContent(
leftVisualRows: TracingRow[][],
rightVisualRows: TracingRow[][],
) {
const M = 24;
const y0 = this.currentY + 6;
const bottom = this.canvasHeight - M;
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);
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,
rowSlotH,
segW,
);
}
y += rowSlotH + 8;
}
};
drawSide(leftVisualRows, M, colW);
drawSide(rightVisualRows, M + colW + gapCol, colW);
}
private drawTracingRow(
row: TracingRow,
startX: number,
y: number,
rowH: number,
totalWidth: number,
) {
const gap = 4;
const n = row.cells.length;
const cellW = n > 0 ? (totalWidth - gap * (n - 1)) / n : totalWidth;
for (let i = 0; i < n; i++) {
const cx = startX + i * (cellW + gap);
drawFourLineGrid(this.ctx, cx, y, cellW, rowH);
const cell = row.cells[i];
this.drawCharInCell(
cell.char,
cx,
y,
cellW,
rowH,
cell.opacity,
cell.isGuide,
cell.pairChar,
);
}
}
private drawCharInCell(
ch: string,
x: number,
y: number,
w: number,
h: number,
opacity: number,
bold: boolean,
pairChar?: string,
) {
const ctx = this.ctx;
if (opacity <= 0) return;
ctx.save();
ctx.globalAlpha = opacity;
ctx.fillStyle = bold ? '#1a1a1a' : '#4a4a4a';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
const baselineY = y + h * 0.72;
if (pairChar) {
const fs = Math.min(h * 0.5, w * 0.22);
ctx.font = `${bold ? 'bold ' : ''}${fs}px ${LETTER_FACE}`;
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 ${LETTER_FACE}`;
ctx.fillText(ch, x + w / 2, baselineY);
}
ctx.restore();
}
}
@@ -0,0 +1,105 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type {
LetterTracingData,
TracingRow,
} from '../generators/letter-tracing-generator';
import { drawFourLineGrid } from './drawTools';
const LETTER_FACE =
'Helvetica, Arial, "Segoe UI", Roboto, sans-serif';
type ColumnCompareData = Extract<
LetterTracingData,
{ mode: 'column-compare' }
>;
export default class ColumnCompareDraw extends BaseDrawService {
async draw(data: ColumnCompareData) {
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawContent(data.leftRows, data.rightRows);
this.drawPrintFooter();
}
private drawContent(leftRows: TracingRow[], rightRows: TracingRow[]) {
const M = 24;
const y0 = this.currentY + 6;
const bottom = this.canvasHeight - M;
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 + 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 + 6);
this.drawTracingRow(rightRows[i], xR, y, rowH, colW);
}
}
private drawTracingRow(
row: TracingRow,
startX: number,
y: number,
rowH: number,
totalWidth: number,
) {
const gap = 4;
const n = row.cells.length;
const cellW = n > 0 ? (totalWidth - gap * (n - 1)) / n : totalWidth;
for (let i = 0; i < n; i++) {
const cx = startX + i * (cellW + gap);
drawFourLineGrid(this.ctx, cx, y, cellW, rowH);
const cell = row.cells[i];
this.drawCharInCell(
cell.char,
cx,
y,
cellW,
rowH,
cell.opacity,
cell.isGuide,
cell.pairChar,
);
}
}
private drawCharInCell(
ch: string,
x: number,
y: number,
w: number,
h: number,
opacity: number,
bold: boolean,
pairChar?: string,
) {
const ctx = this.ctx;
if (opacity <= 0) return;
ctx.save();
ctx.globalAlpha = opacity;
ctx.fillStyle = bold ? '#1a1a1a' : '#4a4a4a';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
const baselineY = y + h * 0.72;
if (pairChar) {
const fs = Math.min(h * 0.5, w * 0.22);
ctx.font = `${bold ? 'bold ' : ''}${fs}px ${LETTER_FACE}`;
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 ${LETTER_FACE}`;
ctx.fillText(ch, x + w / 2, baselineY);
}
ctx.restore();
}
}
@@ -0,0 +1,65 @@
/**
* 绘制工具集合(后续可继续扩展其他通用绘制方法)
*/
export type FourLineGridStyle = {
/** 顶线/底线颜色 */
ink?: string;
/** 中间两条虚线颜色 */
middleInk?: string;
/** 线宽 */
lineWidth?: number;
/** 虚线样式 */
dash?: number[];
};
/**
* 四线三格(通用)
* - 顶/底:实线
* - 中间两条:虚线
*/
export function drawFourLineGrid(
ctx: RenderingContext,
x: number,
y: number,
w: number,
h: number,
style?: FourLineGridStyle,
) {
const ink = style?.ink ?? '#322E25';
const middleInk = style?.middleInk ?? 'rgba(50, 46, 37, 0.3)';
const lineWidth = style?.lineWidth ?? 1;
const dash = style?.dash ?? [4, 4];
const yTop = y;
const y1 = y + h / 3;
const y2 = y + (h * 2) / 3;
const yBot = y + h;
ctx.strokeStyle = ink;
ctx.lineWidth = lineWidth;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(x, yTop);
ctx.lineTo(x + w, yTop);
ctx.stroke();
ctx.strokeStyle = middleInk;
ctx.setLineDash(dash);
ctx.beginPath();
ctx.moveTo(x, y1);
ctx.lineTo(x + w, y1);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x, y2);
ctx.lineTo(x + w, y2);
ctx.stroke();
ctx.strokeStyle = ink;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(x, yBot);
ctx.lineTo(x + w, yBot);
ctx.stroke();
}
@@ -1,330 +1,24 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type {
LetterTracingData,
TracingRow,
} from '../generators/letter-tracing-generator';
const M = 24;
/** 四线三格线色:偏黑,实线与虚线统一色相,仅以线型区分 */
const LINE_INK = '#2a2a2a';
import type { LetterTracingData } from '../generators/letter-tracing-generator';
import PictureTracingDraw from './pictureTracingDraw';
import CasePairingDraw from './casePairingDraw';
import ColumnCompareDraw from './columnCompareDraw';
import OverviewDraw from './overviewDraw';
/**
* 字母描红绘制
* 四线三格:顶线、上中线、下中线为格内三线间距;最上最下为实线,中间两虚线(产品文档)
* 字母描红绘制分发层。
* 根据 data.mode 委托给对应的独立 DrawService 完成实际绘制。
*/
export default class LetterTracingDraw extends BaseDrawService {
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, unknown>,
) {
super(canvas, ctx, options);
}
async draw(data: LetterTracingData) {
this.prepareDraw();
await this.drawHeaderAndDivider();
const DrawClass = {
'picture-tracing': PictureTracingDraw,
'case-pairing': CasePairingDraw,
'column-compare': ColumnCompareDraw,
overview: OverviewDraw,
}[data.mode];
switch (data.mode) {
case 'picture-tracing':
this.drawPictureTracing(data);
this.drawPrintFooter();
break;
case 'case-pairing':
this.drawCasePairing(data.leftVisualRows, data.rightVisualRows);
this.drawPrintFooter();
break;
case 'column-compare':
this.drawColumnCompare(data.leftRows, data.rightRows);
this.drawPrintFooter();
break;
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 yMid1 = y + h * 0.28;
const yMid2 = y + h * 0.58;
const yBot = y + h;
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();
strokeDash();
ctx.beginPath();
ctx.moveTo(x, yMid1);
ctx.lineTo(x + w, yMid1);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x, yMid2);
ctx.lineTo(x + w, yMid2);
ctx.stroke();
strokeSolid();
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(x, yBot);
ctx.lineTo(x + w, yBot);
ctx.stroke();
}
private drawCharInCell(
ch: string,
x: number,
y: number,
w: number,
h: number,
opacity: number,
bold: boolean,
pairChar?: string,
) {
const ctx = this.ctx;
if (opacity <= 0) return;
ctx.save();
ctx.globalAlpha = opacity;
ctx.fillStyle = bold ? '#1a1a1a' : '#4a4a4a';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
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();
}
private drawTracingRow(
row: TracingRow,
startX: number,
y: number,
rowH: number,
totalWidth: number,
) {
const gap = 4;
const n = row.cells.length;
const cellW = n > 0 ? (totalWidth - gap * (n - 1)) / n : totalWidth;
for (let i = 0; i < n; i++) {
const cx = startX + i * (cellW + gap);
this.drawFourLines(cx, y, cellW, rowH);
const cell = row.cells[i];
this.drawCharInCell(
cell.char,
cx,
y,
cellW,
rowH,
cell.opacity,
cell.isGuide,
cell.pairChar,
);
}
}
/**
* 看图描红:上左超大字母+例句,上右「配图」大 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(240, (this.canvasHeight - y) * 0.38);
const innerW = cw - M * 2;
const splitX = M + innerW * 0.52;
ctx.fillStyle = '#f8fafc';
ctx.fillRect(M, y, innerW, teachH);
// 左侧:大小写 + 例句(短句在字母下方)
const leftPad = M + 20;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillStyle = '#111';
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, 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(34, (rest - 10) / rows.length);
const rowWidth = innerW;
for (let r = 0; r < rows.length; r++) {
this.drawTracingRow(
rows[r],
M,
y + r * (rowH + 8),
rowH,
rowWidth,
);
}
}
/**
* 大小写配对:左右两栏,每行最多 4 组子行并排
*/
private drawCasePairing(
leftVisualRows: TracingRow[][],
rightVisualRows: TracingRow[][],
) {
const y0 = this.currentY + 6;
const bottom = this.canvasHeight - M;
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);
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,
rowSlotH,
segW,
);
}
y += rowSlotH + 8;
}
};
drawSide(leftVisualRows, M, colW);
drawSide(rightVisualRows, M + colW + gapCol, colW);
}
/** 分栏对照:左大写、右小写,逐行对齐 */
private drawColumnCompare(leftRows: TracingRow[], rightRows: TracingRow[]) {
const y0 = this.currentY + 6;
const bottom = this.canvasHeight - M;
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 + 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 + 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);
const delegate = new DrawClass(this.canvas, this.ctx, this.options);
await delegate.draw(data as any);
}
}
@@ -0,0 +1,87 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type { LetterTracingData } from '../generators/letter-tracing-generator';
import { drawFourLineGrid } from './drawTools';
const LETTER_FACE =
'Helvetica, Arial, "Segoe UI", Roboto, sans-serif';
type OverviewData = Extract<LetterTracingData, { mode: 'overview' }>;
export default class OverviewDraw extends BaseDrawService {
async draw(data: OverviewData) {
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawContent(data.upperRows, data.lowerRows);
this.drawPrintFooter();
}
private drawContent(upperRows: string[][], lowerRows: string[][]) {
const ctx = this.ctx;
const M = 24;
let y = this.currentY + 6;
const innerW = this.canvasWidth - M * 2;
const drawTitle = (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);
drawFourLineGrid(ctx, cx, y, cellW, cellH);
this.drawCharInCell(
line[i],
cx,
y,
cellW,
cellH,
0.9,
true,
);
}
y += cellH + 8;
}
};
drawTitle('Uppercase Letters');
drawBand(upperRows);
y += 6;
this.drawDashedDivider(y, M, '#bbb');
y += 12;
drawTitle('lowercase Letters');
drawBand(lowerRows);
}
private drawCharInCell(
ch: string,
x: number,
y: number,
w: number,
h: number,
opacity: number,
bold: boolean,
) {
const ctx = this.ctx;
if (opacity <= 0) return;
ctx.save();
ctx.globalAlpha = opacity;
ctx.fillStyle = bold ? '#1a1a1a' : '#4a4a4a';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
const baselineY = y + h * 0.72;
const fontSize = Math.min(h * 0.72, w * 0.85);
ctx.font = `${bold ? 'bold ' : ''}${fontSize}px ${LETTER_FACE}`;
ctx.fillText(ch, x + w / 2, baselineY);
ctx.restore();
}
}
@@ -0,0 +1,386 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import { getImage } from '../../../utils/index';
import type {
LetterTracingData,
HighlightGrid,
} from '../generators/letter-tracing-generator';
import { drawFourLineGrid } from './drawTools';
const LINE_INK_ALPHA = 'rgba(50, 46, 37, 1)';
const ACCENT_RED = 'rgba(252, 46, 0, 1)';
//'Helvetica, Arial, "Segoe UI", Roboto, sans-serif';
const LETTER_FACE = 'Roboto, sans-serif';
const TITLE_EMOJI_FONT =
"16px 'Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif";
const TITLE_TEXT_FONT = '14px sans-serif';
const TITLE_ICON_GAP = 4;
type PictureData = Extract<LetterTracingData, { mode: 'picture-tracing' }>;
export default class PictureTracingDraw extends BaseDrawService {
async draw(data: PictureData) {
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent(data);
this.drawPrintFooter();
}
private async drawContent(data: PictureData) {
const { teaching, highlightGrid, traceImgPath } = data;
const ctx = this.ctx;
// ── 上区:left:78 top:120 w:451 h:180 ──
const TOP_X = 78;
const TOP_Y = 120;
const letterImgX = TOP_X + 15;
const letterImgY = TOP_Y + 9;
const letterImgW = 180;
const letterImgH = 103.5;
try {
const letterImg = (await getImage(
this.canvas,
teaching.letterImgPath,
)) as WechatMiniprogram.Image;
const aspect = (letterImg.width || 1) / (letterImg.height || 1);
let dw = letterImgW;
let dh = dw / aspect;
if (dh > letterImgH) {
dh = letterImgH;
dw = dh * aspect;
}
ctx.drawImage(
letterImg,
letterImgX + (letterImgW - dw) / 2,
letterImgY + (letterImgH - dh) / 2,
dw,
dh,
);
} catch {
ctx.font = `bold 60px ${LETTER_FACE}`;
ctx.fillStyle = '#111';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
`${teaching.upper} ${teaching.lower}`,
letterImgX + letterImgW / 2,
letterImgY + letterImgH / 2,
);
}
this.drawSentence(
teaching.sentence,
teaching.upper,
TOP_X,
TOP_Y + 9 + 120,
);
// 右侧配图
const appleX = TOP_X + 271;
const appleY = TOP_Y;
const appleW = 180;
const appleH = 180;
try {
const wordImg = (await getImage(
this.canvas,
teaching.wordImgPath,
)) as WechatMiniprogram.Image;
const aspect = (wordImg.width || 1) / (wordImg.height || 1);
let dw = appleW;
let dh = dw / aspect;
if (dh > appleH) {
dh = appleH;
dw = dh * aspect;
}
ctx.drawImage(
wordImg,
appleX + (appleW - dw) / 2,
appleY + (appleH - dh) / 2,
dw,
dh,
);
} catch {
ctx.font = `${Math.min(80, appleH * 0.5)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
teaching.emoji,
appleX + appleW / 2,
appleY + appleH / 2,
);
}
// ── 中区 ──
const HL_X = 26,
HL_Y = 320,
HL_W = 272,
HL_H = 181;
const TR_X = 298,
TR_Y = 320,
TR_W = 272,
TR_H = 181;
ctx.strokeStyle = LINE_INK_ALPHA;
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.strokeRect(HL_X, HL_Y, HL_W, HL_H);
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,
);
// ── 下区:4 行四线三格 + 字母临摹(倒置直角三角形)──
// 每行:left:24 w:547 h:43top 分别 524.5 / 590.5 / 656.5 / 722.5
const lineX = 24;
const lineW = 547;
const lineH = 43;
const lineTops = [524.5, 590.5, 656.5, 722.5];
// 8 → 6 → 3 → 1 组(每组为 Upper+Lower),左对齐,形成倒置直角三角形
const groupsPerLine = [8, 6, 3, 1];
const slotW = lineW / groupsPerLine[0]; // 以第一行的 8 组为基准
for (let r = 0; r < lineTops.length; r++) {
const y = lineTops[r];
drawFourLineGrid(ctx, lineX, y, lineW, lineH);
const n = groupsPerLine[r] ?? 0;
for (let i = 0; i < n; i++) {
// 第一组不透明,后面逐次更透明
const alpha = n <= 1 ? 1 : 1 - (i / (n - 1)) * 0.7; // 1 → 0.3
this.drawUpperLowerPairInFourLines(
teaching.upper,
teaching.lower,
lineX + i * slotW,
y,
slotW,
lineH,
alpha,
);
}
}
}
/**
* 在四线三格中绘制 Upper+Lower(遵循书写基线规范)
*
* 四线三格的 4 条线(从上到下):
* Top = y ── 顶线
* Midline = y + h/3 ── 中线(虚线)
* Baseline = y + 2h/3 ── 基线(虚线)
* Bottom = y + h ── 底线
*
* 大写字母:高度占满 Top → Baseline(即第一格 + 第二格),字号 ≈ h * 2/3
* 小写字母:高度占满 Midline → Baseline(即第二格),字号 ≈ h * 1/3
* 两个字母共享 Baseline,间距远小于组间距
*/
private drawUpperLowerPairInFourLines(
upper: string,
lower: string,
x: number,
y: number,
w: number,
h: number,
alpha: number,
) {
const ctx = this.ctx;
ctx.save();
ctx.globalAlpha = Math.max(0, Math.min(1, alpha));
ctx.fillStyle = 'rgba(26, 26, 26, 1)';
ctx.textBaseline = 'alphabetic';
const baselineY = y + (h * 2) / 3;
// 大写占满 Top→Baseline2 格高),小写占满 Midline→Baseline1 格高)
const upperFs = h * (2 / 3);
const lowerFs = h * (1 / 3);
// 字母间距:固定 3px,远小于组间距(slotW ≈ 68)
const pairGap = 3;
ctx.font = `${upperFs}px ${LETTER_FACE}`;
const upperW = ctx.measureText(upper).width;
ctx.font = `${lowerFs}px ${LETTER_FACE}`;
const lowerW = ctx.measureText(lower).width;
const totalPairW = upperW + pairGap + lowerW;
const pairStartX = x + (w - totalPairW) / 2;
ctx.font = `${upperFs}px ${LETTER_FACE}`;
ctx.textAlign = 'left';
ctx.fillText(upper, pairStartX, baselineY);
ctx.font = `${lowerFs}px ${LETTER_FACE}`;
ctx.textAlign = 'left';
ctx.fillText(lower, pairStartX + upperW + pairGap, baselineY);
ctx.restore();
}
private drawSentence(
sentence: string,
letter: string,
x: number,
y: number,
) {
const ctx = this.ctx;
ctx.font = `36px ${LETTER_FACE}`;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
let cx = x;
for (let i = 0; i < sentence.length; i++) {
const ch = sentence[i];
ctx.fillStyle =
ch.toUpperCase() === letter ? ACCENT_RED : 'rgba(0, 0, 0, 1)';
ctx.fillText(ch, cx, y);
cx += ctx.measureText(ch).width;
}
}
private drawHighlightSection(grid: HighlightGrid, sx: number, sy: number) {
const ctx = this.ctx;
const titleX = sx + 16;
const titleY = sy + 12;
ctx.fillStyle = '#000';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const hlIcon = '\u{1F50D}';
ctx.font = TITLE_EMOJI_FONT;
ctx.fillText(hlIcon, titleX, titleY + 1);
const hlIconW = ctx.measureText(hlIcon).width;
ctx.font = TITLE_TEXT_FONT;
ctx.fillText(
'Highlight it !',
titleX + hlIconW + TITLE_ICON_GAP,
titleY,
);
const gridX = sx + 16;
const gridY = sy + 45;
const cellSize = 40;
const cols = grid.rows[0]?.length ?? 6;
const rows = grid.rows.length;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const cx = gridX + c * cellSize;
const cy = gridY + r * cellSize;
ctx.strokeStyle = 'rgba(0, 0, 0, 1)';
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.strokeRect(cx, cy, cellSize, cellSize);
ctx.font = `18px ${LETTER_FACE}`;
ctx.fillStyle = '#000';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
grid.rows[r][c],
cx + cellSize / 2,
cy + cellSize / 2 + 1,
);
}
}
}
private async drawTraceItSection(
traceImgPath: string,
upper: string,
lower: string,
sx: number,
sy: number,
) {
const ctx = this.ctx;
const titleX = sx + 25;
const titleY = sy + 12;
ctx.fillStyle = '#000';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const traceIcon = '\u{1F58A}\u{FE0F}';
ctx.font = TITLE_EMOJI_FONT;
ctx.fillText(traceIcon, titleX, titleY);
const traceIconW = ctx.measureText(traceIcon).width;
ctx.font = TITLE_TEXT_FONT;
ctx.fillText(
'Trace It !',
titleX + traceIconW + TITLE_ICON_GAP,
titleY,
);
// 四线三格(设计稿绝对坐标)
const lx = 316,
lw = 236;
const lines = [364.5, 404.5, 444.5, 484.5];
ctx.strokeStyle = LINE_INK_ALPHA;
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(lx, lines[0]);
ctx.lineTo(lx + lw, lines[0]);
ctx.stroke();
ctx.setLineDash([4, 4]);
for (const ly of [lines[1], lines[2]]) {
ctx.beginPath();
ctx.moveTo(lx, ly);
ctx.lineTo(lx + lw, ly);
ctx.stroke();
}
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(lx, lines[3]);
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 ${LETTER_FACE}`;
ctx.fillStyle = '#222';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${upper} ${lower}`, imgX + imgW / 2, imgY + imgH / 2);
}
}
}
@@ -1,4 +1,4 @@
import { ALPHABET_BY_LETTER, LETTERS_UPPER } from '../../../core/data/alphabet';
import { ALPHABET_BY_LETTER, LETTERS_UPPER } from '../data/alphabet';
/** 字母描红的布局模式(与产品文档四类练习一致) */
export type LetterTracingMode =
@@ -43,6 +43,12 @@ export interface TracingRow {
cells: TracingCell[];
}
/** Highlight It 格子数据(3×6 网格,混入目标字母) */
export interface HighlightGrid {
rows: string[][];
targetLetter: string;
}
/** 看图描红教学区 */
export interface TeachingBlock {
upper: string;
@@ -50,13 +56,21 @@ export interface TeachingBlock {
word: string;
sentence: string;
emoji: string;
/** 字母笔顺图路径(/englishPages/shared/assets/letter/X.png */
letterImgPath: string;
/** 配图路径(/englishPages/shared/assets/letterImgs/Word.png */
wordImgPath: string;
}
export type LetterTracingData =
| {
mode: 'picture-tracing';
teaching: TeachingBlock;
rows: TracingRow[];
highlightGrid: HighlightGrid;
/** Trace It 区域使用的字母笔顺图路径(同 teaching.letterImgPath */
traceImgPath: string;
/** 下部 4 行四线三格(无字符填充,纯空行) */
tracingLineCount: number;
}
| {
mode: 'case-pairing';
@@ -80,6 +94,33 @@ export function normalizeLetter(c: string): string {
return u.length ? u[0] : 'A';
}
/** 构建 Highlight It 的 3×6 随机字母网格,保证混入 2~5 个目标字母 */
function buildHighlightGrid(
target: string,
gridRows: number,
gridCols: number,
): HighlightGrid {
const total = gridRows * gridCols;
const targetCount = 2 + Math.floor(Math.random() * 4); // 2~5
const others = LETTERS_UPPER.filter((l) => l !== target);
const chars: string[] = [];
for (let i = 0; i < targetCount; i++) chars.push(target);
for (let i = targetCount; i < total; i++) {
chars.push(others[Math.floor(Math.random() * others.length)]);
}
for (let i = chars.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[chars[i], chars[j]] = [chars[j], chars[i]];
}
const rows: string[][] = [];
for (let r = 0; r < gridRows; r++) {
rows.push(chars.slice(r * gridCols, (r + 1) * gridCols));
}
return { rows, targetLetter: target };
}
function buildCells(
char: string,
count: number,
@@ -165,22 +206,32 @@ export function generateLetterTracing(
const meta = ALPHABET_BY_LETTER[key] ?? ALPHABET_BY_LETTER.A;
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 wordImgPath = `/englishPages/shared/assets/letterImgs/Apple.png`;
const teaching: TeachingBlock = {
upper,
lower,
word: meta.word,
sentence: `${upper} is for ${meta.word}`,
emoji: meta.emoji,
letterImgPath,
wordImgPath,
};
const highlightGrid = buildHighlightGrid(upper, 3, 6);
return {
mode: 'picture-tracing',
teaching,
highlightGrid,
traceImgPath: letterImgPath,
tracingLineCount: 8,
};
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') {
@@ -189,12 +240,7 @@ export function generateLetterTracing(
const pack = (letters: string[]) =>
chunk(letters, 4).map((group) =>
group.map((L) =>
rowForChar(
L,
repetitions,
fadePattern,
L.toLowerCase(),
),
rowForChar(L, repetitions, fadePattern, L.toLowerCase()),
),
);
return {
@@ -227,9 +273,7 @@ export function generateLetterTracing(
return {
mode: 'overview',
upperRows: overviewRowsFrom([...LETTERS_UPPER]),
lowerRows: overviewRowsFrom(
LETTERS_UPPER.map((L) => L.toLowerCase()),
),
lowerRows: overviewRowsFrom(LETTERS_UPPER.map((L) => L.toLowerCase())),
};
}