feat: 开发英语启蒙的入口页

This commit is contained in:
R524809
2026-04-21 15:03:38 +08:00
parent 8c1d7e0657
commit b2f5b203f8
62 changed files with 392 additions and 259 deletions
@@ -0,0 +1,130 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type {
LetterTracingData,
TracingRow,
} from '../generators/letter-tracing-generator';
import { fontFamilyOf } from '../../shared/data/fontProfiles';
import {
calcLetterFontSizes,
drawFourLineGrid,
drawLetterInFourLineGrid,
FOUR_LINE_GRID_H,
type LetterFontSizes,
} from '../../shared/draw/drawTools';
const COLOR_BLACK = '#1a1a1a';
const COLOR_LIGHT_RED = '#FF8E8E';
/** 左右大写/小写两栏之间的间距(相对行宽 + 下限,避免过窄屏过小) */
const COLUMN_GUTTER_RATIO = 0.02;
const COLUMN_GUTTER_MIN = 14;
/**
* 单栏内字母排版区域占栏宽比例(<1 则同栏字母更紧凑、字间距更小)
*/
const INTRA_COLUMN_LETTER_BAND_RATIO = 1;
type CasePairingData = Extract<
LetterTracingData,
{ mode: 'letter-tracing-case-pairing' }
>;
/**
* 分栏对照绘制:
* 四线三格横跨整页宽度,字母分左右两侧书写——
* 左栏大写、右栏小写(各 repetitions 个),中间留间距;同栏内字母略收紧。
* 每侧第一个字母黑色,后续浅红色。
*/
export default class CasePairingDraw extends BaseDrawService {
private letterSizes: LetterFontSizes =
calcLetterFontSizes(FOUR_LINE_GRID_H);
async draw(data: CasePairingData) {
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawContent(data.leftRows, data.rightRows);
this.drawPrintFooter();
}
private drawContent(leftRows: TracingRow[], rightRows: TracingRow[]) {
const M = 24;
const lineW = this.canvasWidth - M * 2;
const maxR = Math.max(leftRows.length, rightRows.length);
const footerReserve = 56;
const availableH =
this.canvasHeight - this.currentY - footerReserve - 12;
const rowGap = Math.max(
4,
(availableH - maxR * FOUR_LINE_GRID_H) / (maxR + 1),
);
const y0 = this.currentY + rowGap;
const columnGutter = Math.max(
COLUMN_GUTTER_MIN,
Math.round(lineW * COLUMN_GUTTER_RATIO),
);
const halfContentW = (lineW - columnGutter) / 2;
const leftX = M;
const rightX = M + halfContentW + columnGutter;
for (let i = 0; i < maxR; i++) {
const y = y0 + i * (FOUR_LINE_GRID_H + rowGap);
drawFourLineGrid(this.ctx, M, y, lineW, FOUR_LINE_GRID_H);
if (i < leftRows.length) {
this.drawLettersInHalf(
leftRows[i],
leftX,
y,
halfContentW,
INTRA_COLUMN_LETTER_BAND_RATIO,
);
}
if (i < rightRows.length) {
this.drawLettersInHalf(
rightRows[i],
rightX,
y,
halfContentW,
INTRA_COLUMN_LETTER_BAND_RATIO,
);
}
}
}
/**
* 在四线三格的半侧区域内绘制字母。
* 第一个字母黑色,后续字母浅红色。
*/
private drawLettersInHalf(
row: TracingRow,
x: number,
y: number,
columnW: number,
letterBandRatio: number,
) {
const n = row.cells.length;
if (n === 0) return;
const bandW = columnW * letterBandRatio;
const inset = (columnW - bandW) / 2;
const slotW = bandW / n;
for (let i = 0; i < n; i++) {
const cell = row.cells[i];
const color = i === 0 ? COLOR_BLACK : COLOR_LIGHT_RED;
const cx = x + inset + slotW * i + slotW / 2;
drawLetterInFourLineGrid(
this.ctx,
cell.char,
cx,
y,
FOUR_LINE_GRID_H,
this.letterSizes,
{ fontFamily: fontFamilyOf(this.letterSizes._profile), color },
);
}
}
}
@@ -0,0 +1,28 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type { LetterTracingData } from '../generators/letter-tracing-generator';
import singleLetterDraw from './singleLetterDraw';
import CasePairingDraw from './casePairingDraw';
import TwoColumnDraw from './twoColumnDraw';
import UpperLowerDraw from './upperLowerDraw';
import SingleLineDraw from './singleLineDraw';
import TripleDraw from './tripleDraw';
/**
* 字母描红绘制分发层。
* 根据 data.mode 委托给对应的独立 DrawService 完成实际绘制。
*/
export default class LetterTracingDraw extends BaseDrawService {
async draw(data: LetterTracingData) {
const DrawClass = {
'letter-tracing-single': singleLetterDraw,
'letter-tracing-upper-lower': UpperLowerDraw,
'letter-tracing-case-pairing': CasePairingDraw,
'letter-tracing-two-column': TwoColumnDraw,
'letter-tracing-half': SingleLineDraw,
'letter-tracing-three': TripleDraw,
}[data.mode];
const delegate = new DrawClass(this.canvas, this.ctx, this.options);
await delegate.draw(data as any);
}
}
@@ -0,0 +1,393 @@
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);
}
}
}
@@ -0,0 +1,112 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type {
LetterTracingData,
SingleLineRow,
} from '../generators/letter-tracing-generator';
import {
fontFamilyOf,
PRINT_CLEARLY_DASHED,
} from '../../shared/data/fontProfiles';
import {
calcLetterFontSizes,
drawFourLineGrid,
drawLetterInFourLineGrid,
FOUR_LINE_GRID_H,
loadLetterFont,
type LetterFontSizes,
} from '../../shared/draw/drawTools';
const SLOTS_PER_ROW = 6;
const PAIR_GAP = 25;
type SingleLineData = Extract<
LetterTracingData,
{ mode: 'letter-tracing-half' }
>;
/**
* 单字母逐行绘制:
* 13 行四线三格横跨整页宽度,每行划分为 6 组位置。
* 前 4 组展示大小写字母(Aa),后 2 组空白供用户书写。
* 颜色递进:黑色 → 浅红 → 半透明浅红 → 虚线字体+半透明浅红。
*/
export default class SingleLineDraw extends BaseDrawService {
private letterSizes: LetterFontSizes =
calcLetterFontSizes(FOUR_LINE_GRID_H);
private dashedSizes: LetterFontSizes = calcLetterFontSizes(
FOUR_LINE_GRID_H,
PRINT_CLEARLY_DASHED,
);
async draw(data: SingleLineData) {
this.prepareDraw();
await Promise.all([
this.drawHeaderAndDivider(),
loadLetterFont(PRINT_CLEARLY_DASHED),
]);
this.drawContent(data.rows);
this.drawPrintFooter();
}
private drawContent(rows: SingleLineRow[]) {
const M = 24;
const lineW = this.canvasWidth - M * 2;
const rowCount = rows.length;
const footerReserve = 56;
const availableH =
this.canvasHeight - this.currentY - footerReserve - 12;
const rowGap = Math.max(
4,
(availableH - rowCount * FOUR_LINE_GRID_H) / (rowCount + 1),
);
const y0 = this.currentY + rowGap;
const slotW = lineW / SLOTS_PER_ROW;
for (let i = 0; i < rowCount; i++) {
const y = y0 + i * (FOUR_LINE_GRID_H + rowGap);
drawFourLineGrid(this.ctx, M, y, lineW, FOUR_LINE_GRID_H);
const row = rows[i];
for (let j = 0; j < row.cells.length; j++) {
const cell = row.cells[j];
if (!cell) continue;
const pairCx = M + j * slotW + slotW / 2;
const isDashed = cell.style === 'dashed';
const sizes = isDashed ? this.dashedSizes : this.letterSizes;
const fontFamily = fontFamilyOf(
isDashed ? PRINT_CLEARLY_DASHED : sizes._profile,
);
this.ctx.save();
this.ctx.globalAlpha = cell.alpha;
drawLetterInFourLineGrid(
this.ctx,
cell.upper,
pairCx - PAIR_GAP / 2,
y,
FOUR_LINE_GRID_H,
sizes,
{ fontFamily, color: cell.color },
);
drawLetterInFourLineGrid(
this.ctx,
cell.lower,
pairCx + PAIR_GAP / 2,
y,
FOUR_LINE_GRID_H,
sizes,
{ fontFamily, color: cell.color },
);
this.ctx.restore();
}
}
}
}
@@ -0,0 +1,125 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type {
LetterTracingData,
TripleSection,
SingleLineRow,
} from '../generators/letter-tracing-generator';
import {
fontFamilyOf,
PRINT_CLEARLY_DASHED,
} from '../../shared/data/fontProfiles';
import {
calcLetterFontSizes,
drawFourLineGrid,
drawLetterInFourLineGrid,
FOUR_LINE_GRID_H,
loadLetterFont,
type LetterFontSizes,
} from '../../shared/draw/drawTools';
// 每行可容纳的格子数(用于三字母精练模板,一行展示 6 个位置)
const SLOTS_PER_ROW = 6;
// 大小写字母配对间的间隔(px,字母间距)
const PAIR_GAP = 25;
// 同一字母区域内相邻两行四线三格之间的间距(px)
const TRIPLE_ROW_GAP = 10;
// 相邻两个字母区域之间的间距(px),大于 TRIPLE_ROW_GAP
const TRIPLE_SECTION_GAP = 20;
type TripleData = Extract<LetterTracingData, { mode: 'letter-tracing-three' }>;
/**
* 三字母精练绘制:
* 3 个字母区域,每区域 4 行四线三格。
* 前两行展示完整颜色递进(黑→浅红→半透明浅红→虚线),
* 后两行只展示黑色+浅红色两组,其余空白供用户书写。
* 区域之间间距大于行间距。
*/
export default class TripleDraw extends BaseDrawService {
private letterSizes: LetterFontSizes =
calcLetterFontSizes(FOUR_LINE_GRID_H);
private dashedSizes: LetterFontSizes = calcLetterFontSizes(
FOUR_LINE_GRID_H,
PRINT_CLEARLY_DASHED,
);
async draw(data: TripleData) {
this.prepareDraw();
await Promise.all([
this.drawHeaderAndDivider(),
loadLetterFont(PRINT_CLEARLY_DASHED),
]);
this.drawContent(data.sections);
this.drawPrintFooter();
}
private drawContent(sections: TripleSection[]) {
const M = 24;
const lineW = this.canvasWidth - M * 2;
const slotW = lineW / SLOTS_PER_ROW;
let y = this.currentY + 10;
for (let s = 0; s < sections.length; s++) {
const section = sections[s];
for (let r = 0; r < section.rows.length; r++) {
drawFourLineGrid(this.ctx, M, y, lineW, FOUR_LINE_GRID_H);
this.drawRow(section.rows[r], M, y, slotW);
y += FOUR_LINE_GRID_H;
if (r < section.rows.length - 1) {
y += TRIPLE_ROW_GAP;
}
}
if (s < sections.length - 1) {
y += TRIPLE_SECTION_GAP;
}
}
}
private drawRow(
row: SingleLineRow,
marginX: number,
y: number,
slotW: number,
) {
for (let j = 0; j < row.cells.length; j++) {
const cell = row.cells[j];
if (!cell) continue;
const pairCx = marginX + j * slotW + slotW / 2;
const isDashed = cell.style === 'dashed';
const sizes = isDashed ? this.dashedSizes : this.letterSizes;
const fontFamily = fontFamilyOf(
isDashed ? PRINT_CLEARLY_DASHED : sizes._profile,
);
this.ctx.save();
this.ctx.globalAlpha = cell.alpha;
drawLetterInFourLineGrid(
this.ctx,
cell.upper,
pairCx - PAIR_GAP / 2,
y,
FOUR_LINE_GRID_H,
sizes,
{ fontFamily, color: cell.color },
);
drawLetterInFourLineGrid(
this.ctx,
cell.lower,
pairCx + PAIR_GAP / 2,
y,
FOUR_LINE_GRID_H,
sizes,
{ fontFamily, color: cell.color },
);
this.ctx.restore();
}
}
}
@@ -0,0 +1,124 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type {
LetterTracingData,
TracingRow,
} from '../generators/letter-tracing-generator';
import { fontFamilyOf } from '../../shared/data/fontProfiles';
import {
calcLetterFontSizes,
drawFourLineGrid,
drawLetterInFourLineGrid,
FOUR_LINE_GRID_H,
type LetterFontSizes,
} from '../../shared/draw/drawTools';
const COLOR_BLACK = '#1a1a1a';
const COLOR_LIGHT_RED = '#FF8E8E';
const COLUMN_GUTTER = 18;
type TwoColumnData = Extract<
LetterTracingData,
{ mode: 'letter-tracing-two-column' }
>;
/**
* 两列练习绘制:
* 左栏 A–M(Aa Bb … Mm)、右栏 NZNn Oo … Zz),每栏 13 行。
* 每行一个完整四线三格,格内绘制大小写配对字母。
* 第一行黑色示范,后续行浅红色渐淡。两栏间距 18px。
*/
export default class TwoColumnDraw extends BaseDrawService {
private letterSizes: LetterFontSizes =
calcLetterFontSizes(FOUR_LINE_GRID_H);
async draw(data: TwoColumnData) {
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawContent(data.leftRows, data.rightRows);
this.drawPrintFooter();
}
private drawContent(leftRows: TracingRow[], rightRows: TracingRow[]) {
const M = 24;
const lineW = this.canvasWidth - M * 2;
const colW = (lineW - COLUMN_GUTTER) / 2;
const maxR = Math.max(leftRows.length, rightRows.length);
const footerReserve = 56;
const availableH =
this.canvasHeight - this.currentY - footerReserve - 12;
const rowGap = Math.max(
4,
(availableH - maxR * FOUR_LINE_GRID_H) / (maxR + 1),
);
const y0 = this.currentY + rowGap;
const leftX = M;
const rightX = M + colW + COLUMN_GUTTER;
for (let i = 0; i < maxR; i++) {
const y = y0 + i * (FOUR_LINE_GRID_H + rowGap);
if (i < leftRows.length) {
drawFourLineGrid(this.ctx, leftX, y, colW, FOUR_LINE_GRID_H);
this.drawPairRow(leftRows[i], leftX, y, colW);
}
if (i < rightRows.length) {
drawFourLineGrid(this.ctx, rightX, y, colW, FOUR_LINE_GRID_H);
this.drawPairRow(rightRows[i], rightX, y, colW);
}
}
}
/**
* 在一个四线三格内绘制大小写配对(如 A a)。
* rowIndex=0 黑色示范,>0 浅红色。
* cells 里每个 cell 的 char 是大写、pairChar 是小写,
* 按 repetitions 排列(第一个实色 → 后续渐淡)。
*/
private drawPairRow(row: TracingRow, x: number, y: number, colW: number) {
const fontFamily = fontFamilyOf(this.letterSizes._profile);
const cells = row.cells;
const n = cells.length;
if (n === 0) return;
const pairCount = n;
const slotW = colW / pairCount;
const pairGap = 25;
for (let i = 0; i < pairCount; i++) {
const cell = cells[i];
const color = i === 0 ? COLOR_BLACK : COLOR_LIGHT_RED;
const alpha = i >= pairCount - 2 ? 0.5 : 1;
const pairCx = x + i * slotW + slotW / 2;
this.ctx.save();
this.ctx.globalAlpha = alpha;
drawLetterInFourLineGrid(
this.ctx,
cell.char,
pairCx - pairGap / 2,
y,
FOUR_LINE_GRID_H,
this.letterSizes,
{ fontFamily, color },
);
if (cell.pairChar) {
drawLetterInFourLineGrid(
this.ctx,
cell.pairChar,
pairCx + pairGap / 2,
y,
FOUR_LINE_GRID_H,
this.letterSizes,
{ fontFamily, color },
);
}
this.ctx.restore();
}
}
}
@@ -0,0 +1,136 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type { LetterTracingData } from '../generators/letter-tracing-generator';
import {
PRINT_CLEARLY_BOLD,
PRINT_CLEARLY_DASHED,
fontFamilyOf,
} from '../../shared/data/fontProfiles';
import {
calcLetterFontSizes,
drawFourLineGrid,
drawLetterInFourLineGrid,
FOUR_LINE_GRID_H,
loadLetterFont,
type LetterFontSizes,
} from '../../shared/draw/drawTools';
type UpperLowerData = Extract<
LetterTracingData,
{ mode: 'letter-tracing-upper-lower' }
>;
/** 页面左右边距(margin,px),用于排版时留白 */
const M = 24;
/** 上下两大区(Uppercase / lowercase)之间的间距 */
const SECTION_GAP = 25;
/** 标题字号 */
const TITLE_SIZE = 28;
/** 标题与第一行四线三格的间距 */
const TITLE_GAP = 25;
/** 同一区域内相邻四线三格行之间的间距 */
const ROW_GAP = 24;
const TITLE_COLOR = '#000';
/** 浅红色描红(不透明 hex,避免打印时半透明发灰) */
const TRACE_LIGHT_RED = '#FF8E8E';
const DASHED_PROFILE = PRINT_CLEARLY_DASHED;
const TITLE_PROFILE = PRINT_CLEARLY_BOLD;
const LETTER_FACE = fontFamilyOf(DASHED_PROFILE);
const TITLE_FACE = fontFamilyOf(TITLE_PROFILE);
/**
* 字母总览:上区大写、下区小写;每区标题 + 4 行四线三格(7+7+6+6)。
* 列宽按 7 列均分;后两行 6 字左对齐,与前两行逐列对齐(占前 6 列)。
*/
export default class UpperLowerDraw extends BaseDrawService {
private letterSizes: LetterFontSizes = calcLetterFontSizes(
FOUR_LINE_GRID_H,
DASHED_PROFILE,
);
async draw(data: UpperLowerData) {
this.prepareDraw();
await Promise.allSettled([
loadLetterFont(DASHED_PROFILE),
loadLetterFont(TITLE_PROFILE),
]);
await this.drawHeaderAndDivider();
this.drawUpperLowerBody(data.upperRows, data.lowerRows);
await this.drawPrintFooter();
}
private drawUpperLowerBody(
upperRows: string[][],
lowerRows: string[][],
): void {
const lineW = this.canvasWidth - M * 2;
const slotW = lineW / 7;
let y = this.currentY + SECTION_GAP - 7;
y = this.drawLetterBlock(
'Uppercase Letters',
upperRows,
y,
lineW,
slotW,
);
y += SECTION_GAP + 7;
this.drawLetterBlock('lowercase letters', lowerRows, y, lineW, slotW);
}
/**
* 绘制一个分区:居中标题 + 4 行四线三格与字母。
* @returns 该分区最后一行四线三格底边 Y
*/
private drawLetterBlock(
title: string,
rows: string[][],
startY: number,
lineW: number,
slotW: number,
): number {
const ctx = this.ctx;
const centerX = M + lineW / 2;
ctx.save();
ctx.font = `bold ${TITLE_SIZE}px ${TITLE_FACE}`;
ctx.fillStyle = TITLE_COLOR;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(title, centerX, startY);
ctx.restore();
let y = startY + TITLE_SIZE + TITLE_GAP;
for (let r = 0; r < rows.length; r++) {
const chars = rows[r];
drawFourLineGrid(ctx, M, y, lineW, FOUR_LINE_GRID_H);
/** 与 7 字行共用列宽 slotW,后两行 6 字左对齐(占前 6 列),与上行逐列对齐 */
const offsetX = M;
for (let i = 0; i < chars.length; i++) {
const cx = offsetX + slotW * (i + 0.5);
drawLetterInFourLineGrid(
ctx,
chars[i],
cx,
y,
FOUR_LINE_GRID_H,
this.letterSizes,
{
fontFamily: LETTER_FACE,
color: TRACE_LIGHT_RED,
},
);
}
y += FOUR_LINE_GRID_H;
if (r < rows.length - 1) {
y += ROW_GAP;
}
}
return y;
}
}