391 lines
12 KiB
TypeScript
391 lines
12 KiB
TypeScript
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
||
import { getImage } from '../../../utils/index';
|
||
import type {
|
||
LetterTracingData,
|
||
HighlightGrid,
|
||
} from '../generators/letter-tracing-generator';
|
||
import {
|
||
calcLetterFontSizes,
|
||
drawFourLineGrid,
|
||
drawLetterInFourLineGrid,
|
||
FOUR_LINE_GRID_H,
|
||
getLetterFontSize,
|
||
TOY_FONT_LETTER,
|
||
type LetterFontSizes,
|
||
} 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 = `${TOY_FONT_LETTER}, 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 {
|
||
private letterSizes: LetterFontSizes = calcLetterFontSizes(FOUR_LINE_GRID_H);
|
||
|
||
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:43,top 分别 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 ${LETTER_FACE}`;
|
||
const upperW = ctx.measureText(upper).width;
|
||
|
||
const lowerFs = getLetterFontSize(lower, sizes);
|
||
ctx.font = `${lowerFs}px ${LETTER_FACE}`;
|
||
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: LETTER_FACE, color },
|
||
);
|
||
drawLetterInFourLineGrid(
|
||
ctx,
|
||
lower,
|
||
pairStartX + upperW + pairGap + lowerW / 2,
|
||
y,
|
||
FOUR_LINE_GRID_H,
|
||
sizes,
|
||
{ fontFamily: LETTER_FACE, color },
|
||
);
|
||
|
||
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 =
|
||
"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 ${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 =
|
||
"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 ${LETTER_FACE}`;
|
||
ctx.fillStyle = '#222';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(`${upper} ${lower}`, imgX + imgW / 2, imgY + imgH / 2);
|
||
}
|
||
}
|
||
}
|