feat: 修改字母描红逻辑,添加字母手写字体
This commit is contained in:
@@ -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:43,top 分别 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→Baseline(2 格高),小写占满 Midline→Baseline(1 格高)
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user