feat: 完成四种字母描红开发

This commit is contained in:
R524809
2026-04-17 17:03:36 +08:00
parent 15ad5cce75
commit 9febf7da4f
16 changed files with 479 additions and 480 deletions
@@ -3,121 +3,128 @@ import type {
LetterTracingData,
TracingRow,
} from '../generators/letter-tracing-generator';
import { drawFourLineGrid } from './drawTools';
import { fontFamilyOf } from '../data/fontProfiles';
import {
calcLetterFontSizes,
drawFourLineGrid,
drawLetterInFourLineGrid,
FOUR_LINE_GRID_H,
type LetterFontSizes,
} from './drawTools';
const COLOR_BLACK = '#1a1a1a';
const COLOR_LIGHT_RED = '#FF8E8E';
const LETTER_FACE = 'Helvetica, Arial, "Segoe UI", Roboto, sans-serif';
/** 左右大写/小写两栏之间的间距(相对行宽 + 下限,避免过窄屏过小) */
const COLUMN_GUTTER_RATIO = 0.02;
const COLUMN_GUTTER_MIN = 14;
type CasePairingData = Extract<LetterTracingData, { mode: 'case-pairing' }>;
/**
* 单栏内字母排版区域占栏宽比例(<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.leftVisualRows, data.rightVisualRows);
this.drawContent(data.leftRows, data.rightRows);
this.drawPrintFooter();
}
private drawContent(
leftVisualRows: TracingRow[][],
rightVisualRows: TracingRow[][],
) {
private drawContent(leftRows: TracingRow[], rightRows: 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 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 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;
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,
);
}
};
drawSide(leftVisualRows, M, colW);
drawSide(rightVisualRows, M + colW + gapCol, colW);
if (i < rightRows.length) {
this.drawLettersInHalf(
rightRows[i],
rightX,
y,
halfContentW,
INTRA_COLUMN_LETTER_BAND_RATIO,
);
}
}
}
private drawTracingRow(
/**
* 在四线三格的半侧区域内绘制字母。
* 第一个字母黑色,后续字母浅红色。
*/
private drawLettersInHalf(
row: TracingRow,
startX: number,
x: number,
y: number,
rowH: number,
totalWidth: number,
columnW: number,
letterBandRatio: number,
) {
const gap = 4;
const n = row.cells.length;
const cellW = n > 0 ? (totalWidth - gap * (n - 1)) / n : totalWidth;
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 cx = startX + i * (cellW + gap);
drawFourLineGrid(this.ctx, cx, y, cellW, rowH);
const cell = row.cells[i];
this.drawCharInCell(
const color = i === 0 ? COLOR_BLACK : COLOR_LIGHT_RED;
const cx = x + inset + slotW * i + slotW / 2;
drawLetterInFourLineGrid(
this.ctx,
cell.char,
cx,
y,
cellW,
rowH,
cell.opacity,
cell.isGuide,
cell.pairChar,
FOUR_LINE_GRID_H,
this.letterSizes,
{ fontFamily: fontFamilyOf(this.letterSizes._profile), color },
);
}
}
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();
}
}
@@ -1,127 +0,0 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type {
LetterTracingData,
TracingRow,
} from '../generators/letter-tracing-generator';
import { fontFamilyOf } from '../data/fontProfiles';
import {
calcLetterFontSizes,
drawFourLineGrid,
drawLetterInFourLineGrid,
FOUR_LINE_GRID_H,
type LetterFontSizes,
} from './drawTools';
const COLOR_BLACK = '#1a1a1a';
const COLOR_LIGHT_RED = 'rgba(252, 46, 0, 0.3)';
/** 左右大写/小写两栏之间的间距(相对行宽 + 下限,避免过窄屏过小) */
const COLUMN_GUTTER_RATIO = 0.02;
const COLUMN_GUTTER_MIN = 14;
/**
* 单栏内字母排版区域占栏宽比例(<1 则同栏字母更紧凑、字间距更小)
*/
const INTRA_COLUMN_LETTER_BAND_RATIO = 1;
type ColumnCompareData = Extract<LetterTracingData, { mode: 'column-compare' }>;
/**
* 分栏对照绘制:
* 四线三格横跨整页宽度,字母分左右两侧书写——
* 左栏大写、右栏小写(各 repetitions 个),中间留间距;同栏内字母略收紧。
* 每侧第一个字母黑色,后续浅红色。
*/
export default class ColumnCompareDraw extends BaseDrawService {
private letterSizes: LetterFontSizes =
calcLetterFontSizes(FOUR_LINE_GRID_H);
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 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 },
);
}
}
}
@@ -97,8 +97,7 @@ export function drawLetterInFourLineGrid(
bold?: boolean;
},
) {
const fontFamily =
options?.fontFamily ?? fontFamilyOf(sizes._profile);
const fontFamily = options?.fontFamily ?? fontFamilyOf(sizes._profile);
const color = options?.color ?? '#1a1a1a';
const bold = options?.bold ?? false;
@@ -1,9 +1,9 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type { LetterTracingData } from '../generators/letter-tracing-generator';
import PictureTracingDraw from './pictureTracingDraw';
import singleLetterDraw from './singleLetterDraw';
import CasePairingDraw from './casePairingDraw';
import ColumnCompareDraw from './columnCompareDraw';
import OverviewDraw from './overviewDraw';
import TwoColumnDraw from './twoColumnDraw';
import UpperLowerDraw from './upperLowerDraw';
/**
* 字母描红绘制分发层。
@@ -12,10 +12,10 @@ import OverviewDraw from './overviewDraw';
export default class LetterTracingDraw extends BaseDrawService {
async draw(data: LetterTracingData) {
const DrawClass = {
'picture-tracing': PictureTracingDraw,
'case-pairing': CasePairingDraw,
'column-compare': ColumnCompareDraw,
overview: OverviewDraw,
'letter-tracing-single': singleLetterDraw,
'letter-tracing-upper-lower': UpperLowerDraw,
'letter-tracing-case-pairing': CasePairingDraw,
'letter-tracing-two-column': TwoColumnDraw,
}[data.mode];
const delegate = new DrawClass(this.canvas, this.ctx, this.options);
@@ -19,23 +19,26 @@ const ACCENT_RED = 'rgba(252, 46, 0, 1)';
const TITLE_TEXT_FONT = '14px sans-serif';
const TITLE_ICON_GAP = 4;
type PictureData = Extract<LetterTracingData, { mode: 'picture-tracing' }>;
type SingleLetterData = Extract<
LetterTracingData,
{ mode: 'letter-tracing-single' }
>;
export default class PictureTracingDraw extends BaseDrawService {
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: PictureData) {
async draw(data: SingleLetterData) {
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent(data);
this.drawPrintFooter();
}
private async drawContent(data: PictureData) {
private async drawContent(data: SingleLetterData) {
const { teaching, highlightGrid, traceImgPath } = data;
const ctx = this.ctx;
@@ -79,7 +82,7 @@ export default class PictureTracingDraw extends BaseDrawService {
);
}
this.drawSentence(
this.drawSingleLetterSentence(
teaching.sentence,
teaching.upper,
TOP_X,
@@ -228,7 +231,7 @@ export default class PictureTracingDraw extends BaseDrawService {
ctx.restore();
}
private drawSentence(
private drawSingleLetterSentence(
sentence: string,
letter: string,
x: number,
@@ -0,0 +1,124 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type {
LetterTracingData,
TracingRow,
} from '../generators/letter-tracing-generator';
import { fontFamilyOf } from '../data/fontProfiles';
import {
calcLetterFontSizes,
drawFourLineGrid,
drawLetterInFourLineGrid,
FOUR_LINE_GRID_H,
type LetterFontSizes,
} from './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();
}
}
}
@@ -14,7 +14,10 @@ import {
type LetterFontSizes,
} from './drawTools';
type OverviewData = Extract<LetterTracingData, { mode: 'overview' }>;
type UpperLowerData = Extract<
LetterTracingData,
{ mode: 'letter-tracing-upper-lower' }
>;
/** 页面左右边距(margin,px),用于排版时留白 */
const M = 24;
@@ -40,24 +43,24 @@ const TITLE_FACE = fontFamilyOf(TITLE_PROFILE);
* + 4 线7+7+6+6
* 7 6 6
*/
export default class OverviewDraw extends BaseDrawService {
export default class UpperLowerDraw extends BaseDrawService {
private letterSizes: LetterFontSizes = calcLetterFontSizes(
FOUR_LINE_GRID_H,
DASHED_PROFILE,
);
async draw(data: OverviewData) {
async draw(data: UpperLowerData) {
this.prepareDraw();
await Promise.allSettled([
loadLetterFont(DASHED_PROFILE),
loadLetterFont(TITLE_PROFILE),
]);
await this.drawHeaderAndDivider();
this.drawOverviewBody(data.upperRows, data.lowerRows);
this.drawUpperLowerBody(data.upperRows, data.lowerRows);
await this.drawPrintFooter();
}
private drawOverviewBody(
private drawUpperLowerBody(
upperRows: string[][],
lowerRows: string[][],
): void {
@@ -3,17 +3,17 @@ import { ALPHABET_BY_LETTER, LETTERS_UPPER } from '../data/alphabet';
/** 字母描红的布局模式(与产品文档六类练习一致) */
export type LetterTracingMode =
/** 1 看图识字:教学区 + 描红行(前三行大写、后三行小写) */
| 'picture-tracing'
| 'letter-tracing-single'
/** 2 字母总览:Uppercase / Lowercase 分区,每区四行 7+7+6+6 */
| 'overview'
| 'letter-tracing-upper-lower'
/** 3 大小写配对:左栏 A–M、右栏 N–Z,每行最多 4 组 Aa */
| 'case-pairing'
| 'letter-tracing-case-pairing'
/** 4 分栏对照:半组字母,左栏大写、右栏小写,每行 4 个 */
| 'column-compare'
| 'letter-tracing-two-column'
/** 5 三字母精练:每页聚焦 3 个字母,各含大写行 + 小写行 */
| 'triple-focus'
| 'letter-tracing-triple'
/** 6 单字母逐行:每行一个字母,13 字母半表(A–M 或 N–Z) */
| 'single-line';
| 'letter-tracing-single-line';
/** 字母大小写模式(部分模式不使用) */
export type LetterCaseMode = 'upper' | 'lower' | 'both';
@@ -70,7 +70,7 @@ export interface TeachingBlock {
export type LetterTracingData =
| {
mode: 'picture-tracing';
mode: 'letter-tracing-single';
teaching: TeachingBlock;
highlightGrid: HighlightGrid;
/** Trace It 区域使用的字母笔顺图路径(同 teaching.letterImgPath */
@@ -79,17 +79,17 @@ export type LetterTracingData =
tracingLineCount: number;
}
| {
mode: 'case-pairing';
leftVisualRows: TracingRow[][];
rightVisualRows: TracingRow[][];
}
| {
mode: 'column-compare';
mode: 'letter-tracing-case-pairing';
leftRows: TracingRow[];
rightRows: TracingRow[];
}
| {
mode: 'overview';
mode: 'letter-tracing-two-column';
leftRows: TracingRow[];
rightRows: TracingRow[];
}
| {
mode: 'letter-tracing-upper-lower';
upperRows: string[][];
lowerRows: string[][];
};
@@ -212,7 +212,7 @@ export function generateLetterTracing(
): LetterTracingData {
const { mode, repetitions, fadePattern } = config;
if (mode === 'picture-tracing') {
if (mode === 'letter-tracing-single') {
const key = normalizeLetter(config.selectedLetter ?? 'A');
const meta = ALPHABET_BY_LETTER[key] ?? ALPHABET_BY_LETTER.A;
const upper = key;
@@ -237,7 +237,7 @@ export function generateLetterTracing(
const highlightGrid = buildHighlightGrid(upper, 3, 6);
return {
mode: 'picture-tracing',
mode: 'letter-tracing-single',
teaching,
highlightGrid,
traceImgPath: letterImgPath,
@@ -245,23 +245,7 @@ export function generateLetterTracing(
};
}
if (mode === 'case-pairing') {
const leftLetters = LETTERS_UPPER.slice(0, 13);
const rightLetters = LETTERS_UPPER.slice(13);
const pack = (letters: string[]) =>
chunk(letters, 4).map((group) =>
group.map((L) =>
rowForChar(L, repetitions, fadePattern, L.toLowerCase()),
),
);
return {
mode: 'case-pairing',
leftVisualRows: pack(leftLetters),
rightVisualRows: pack(rightLetters),
};
}
if (mode === 'column-compare') {
if (mode === 'letter-tracing-case-pairing') {
const half =
config.alphabetHalf === 'N-Z'
? LETTERS_UPPER.slice(13)
@@ -272,12 +256,26 @@ export function generateLetterTracing(
const rightRows = half.map((L) =>
rowForChar(L.toLowerCase(), repetitions, fadePattern),
);
return { mode: 'column-compare', leftRows, rightRows };
return { mode: 'letter-tracing-case-pairing', leftRows, rightRows };
}
// overview
if (mode === 'letter-tracing-two-column') {
const leftLetters = LETTERS_UPPER.slice(0, 13);
const rightLetters = LETTERS_UPPER.slice(13);
const toRows = (letters: string[]) =>
letters.map((L) =>
rowForChar(L, repetitions, fadePattern, L.toLowerCase()),
);
return {
mode: 'letter-tracing-two-column',
leftRows: toRows(leftLetters),
rightRows: toRows(rightLetters),
};
}
// letter-tracing-upper-lower
return {
mode: 'overview',
mode: 'letter-tracing-upper-lower',
upperRows: overviewRowsFrom([...LETTERS_UPPER]),
lowerRows: overviewRowsFrom(LETTERS_UPPER.map((L) => L.toLowerCase())),
};
@@ -291,7 +289,7 @@ export function mergeLetterTracingConfig(
return {
mode: (overrides.mode ??
base.mode ??
'picture-tracing') as LetterTracingMode,
'letter-tracing-single') as LetterTracingMode,
letterCase: (overrides.letterCase ??
base.letterCase ??
'upper') as LetterCaseMode,