Files
doodle-mini/miniprogram/englishPages/shared/draw/pictureTracingDraw.ts
T

387 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 { 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);
}
}
}