331 lines
10 KiB
TypeScript
331 lines
10 KiB
TypeScript
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
|
import type {
|
|
LetterTracingData,
|
|
TracingRow,
|
|
} from '../generators/letter-tracing-generator';
|
|
|
|
const M = 24;
|
|
/** 四线三格线色:偏黑,实线与虚线统一色相,仅以线型区分 */
|
|
const LINE_INK = '#2a2a2a';
|
|
|
|
/**
|
|
* 字母描红绘制
|
|
* 四线三格:顶线、上中线、下中线为格内三线间距;最上最下为实线,中间两虚线(产品文档)
|
|
*/
|
|
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();
|
|
|
|
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);
|
|
}
|
|
}
|