Files
doodle-mini/miniprogram/englishPages/letterTracing/draw/singleLetterDraw.ts
T
2026-04-21 15:03:38 +08:00

394 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BaseDrawService } from '../../../core/draw/baseDraw';
import { getImage } from '../../../utils/index';
import type {
LetterTracingData,
HighlightGrid,
} from '../generators/letter-tracing-generator';
import { fontFamilyOf } from '../../shared/data/fontProfiles';
import {
calcLetterFontSizes,
drawFourLineGrid,
drawLetterInFourLineGrid,
FOUR_LINE_GRID_H,
getLetterFontSize,
type LetterFontSizes,
} from '../../shared/draw/drawTools';
const LINE_INK_ALPHA = 'rgba(50, 46, 37, 1)';
const ACCENT_RED = 'rgba(252, 46, 0, 1)';
const TITLE_TEXT_FONT = '14px sans-serif';
const TITLE_ICON_GAP = 4;
type SingleLetterData = Extract<
LetterTracingData,
{ mode: 'letter-tracing-single' }
>;
export default class SingleLetterDraw extends BaseDrawService {
private letterSizes: LetterFontSizes =
calcLetterFontSizes(FOUR_LINE_GRID_H);
private get letterFace(): string {
return fontFamilyOf(this.letterSizes._profile);
}
async draw(data: SingleLetterData) {
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent(data);
this.drawPrintFooter();
}
private async drawContent(data: SingleLetterData) {
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 ${this.letterFace}`;
ctx.fillStyle = '#111';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
`${teaching.upper} ${teaching.lower}`,
letterImgX + letterImgW / 2,
letterImgY + letterImgH / 2,
);
}
this.drawSingleLetterSentence(
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 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, FOUR_LINE_GRID_H);
const n = groupsPerLine[r] ?? 0;
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,
);
}
}
}
/**
* 在四线三格中绘制 Upper+Lower 对(共享基线,间距远小于组间距)
*/
private drawUpperLowerPairInFourLines(
upper: string,
lower: string,
x: number,
y: number,
w: number,
alpha: number,
) {
const ctx = this.ctx;
const sizes = this.letterSizes;
ctx.save();
ctx.globalAlpha = Math.max(0, Math.min(1, alpha));
const pairGap = 3;
const upperFs = getLetterFontSize(upper, sizes);
ctx.font = `${upperFs}px ${this.letterFace}`;
const upperW = ctx.measureText(upper).width;
const lowerFs = getLetterFontSize(lower, sizes);
ctx.font = `${lowerFs}px ${this.letterFace}`;
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,
upper,
pairStartX + upperW / 2,
y,
FOUR_LINE_GRID_H,
sizes,
{ fontFamily: this.letterFace, color },
);
drawLetterInFourLineGrid(
ctx,
lower,
pairStartX + upperW + pairGap + lowerW / 2,
y,
FOUR_LINE_GRID_H,
sizes,
{ fontFamily: this.letterFace, color },
);
ctx.restore();
}
private drawSingleLetterSentence(
sentence: string,
letter: string,
x: number,
y: number,
) {
const ctx = this.ctx;
ctx.font = `36px ${this.letterFace}`;
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 =
"16px 'Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif";
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 ${this.letterFace}`;
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 =
"16px 'Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif";
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 ${this.letterFace}`;
ctx.fillStyle = '#222';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${upper} ${lower}`, imgX + imgW / 2, imgY + imgH / 2);
}
}
}