feat: 完成三类字母描红开发

This commit is contained in:
R524809
2026-04-17 15:59:55 +08:00
parent 6c98e96506
commit 15ad5cce75
13 changed files with 565 additions and 233 deletions
@@ -33,6 +33,8 @@ export interface LetterOverride {
/** 一套完整的字体渲染配置 */
export interface FontProfile {
/** 唯一标识,同时用作 wx.loadFontFace 的 family 和按名称查找的 key */
name: string;
/** 字体 URL */
url: string;
/** 各分类的默认渲染参数 */
@@ -41,6 +43,11 @@ export interface FontProfile {
letterOverrides?: Record<string, LetterOverride>;
}
/** 构建 CSS font-family 串(含回退字体) */
export function fontFamilyOf(profile: FontProfile): string {
return `${profile.name}, Roboto, sans-serif`;
}
// ── 字母分类集合 ──
const TALL_LOWERCASE = new Set(['b', 'd', 'h', 'k', 'l']);
const DESCENDERS = new Set(['g', 'p', 'q', 'y']);
@@ -88,88 +95,121 @@ export function getLetterMetrics(
/** ZhiyongWrite 手写字体 */
export const ZHIYONG_WRITE: FontProfile = {
name: 'ZhiyongWrite',
url: 'https://cdn.joeyone.cn/doodle/fonts/ZhiyongWrite.ttf',
// categories: {
// uppercase: { scale: 0.93, baselineOffset: 0 },
// tallLower: { scale: 0.95, baselineOffset: 0 },
// descender: { scale: 1.12, baselineOffset: 0.18 },
// lowercase: { scale: 0.83, baselineOffset: 0.03 },
// },
// letterOverrides: {
// i: { scale: 0.78, baselineOffset: 0.02 },
// f: { scale: 1.37, baselineOffset: 0.34 },
// t: { scale: 0.85, baselineOffset: 0 },
// j: { scale: 1, baselineOffset: 0.15 },
// },
categories: {
uppercase: { scale: 0.93, baselineOffset: 0 },
tallLower: { scale: 0.95, baselineOffset: 0 },
descender: { scale: 1.12, baselineOffset: 0.18 },
lowercase: { scale: 0.83, baselineOffset: 0.03 },
uppercase: { scale: 0.95, baselineOffset: 0 },
tallLower: { scale: 0.85, baselineOffset: 0 },
descender: { scale: 1, baselineOffset: 0.12 },
lowercase: { scale: 0.75, baselineOffset: 0 },
},
letterOverrides: {
i: { scale: 0.78, baselineOffset: 0.02 },
f: { scale: 1.37, baselineOffset: 0.34 },
i: { scale: 0.8, baselineOffset: 0 },
f: { scale: 1, baselineOffset: 0.05 },
t: { scale: 0.85, baselineOffset: 0 },
j: { scale: 1, baselineOffset: 0.15 },
j: { scale: 0.85, baselineOffset: 0.1 },
},
};
/** Handlee Regular — Google 手写风格 */
export const HANDLEE_REGULAR: FontProfile = {
name: 'HandleeRegular',
url: 'https://cdn.joeyone.cn/doodle/fonts/Handlee-Regular.ttf',
// categories: {
// uppercase: { scale: 0.9, baselineOffset: 0 },
// tallLower: { scale: 0.73, baselineOffset: 0.03 },
// descender: { scale: 0.8, baselineOffset: 0.05 },
// lowercase: { scale: 0.73, baselineOffset: 0.02 },
// },
// letterOverrides: {
// i: { scale: 0.78, baselineOffset: 0.02 },
// f: { scale: 0.82, baselineOffset: 0 },
// t: { scale: 0.77, baselineOffset: 0.04 },
// j: { scale: 0.85, baselineOffset: 0.08 },
// },
categories: {
uppercase: { scale: 0.9, baselineOffset: 0 },
tallLower: { scale: 0.73, baselineOffset: 0.03 },
descender: { scale: 0.8, baselineOffset: 0.05 },
lowercase: { scale: 0.73, baselineOffset: 0.02 },
tallLower: { scale: 0.68, baselineOffset: 0 },
descender: { scale: 0.68, baselineOffset: 0 },
lowercase: { scale: 0.68, baselineOffset: 0 },
},
letterOverrides: {
i: { scale: 0.78, baselineOffset: 0.02 },
f: { scale: 0.82, baselineOffset: 0 },
t: { scale: 0.77, baselineOffset: 0.04 },
j: { scale: 0.85, baselineOffset: 0.08 },
i: { scale: 0.68, baselineOffset: 0 },
f: { scale: 0.68, baselineOffset: 0 },
t: { scale: 0.68, baselineOffset: 0 },
j: { scale: 0.68, baselineOffset: 0 },
},
};
/** Print Clearly Regular */
export const PRINT_CLEARLY_REGULAR: FontProfile = {
name: 'PrintClearlyRegular',
url: 'https://cdn.joeyone.cn/doodle/fonts/print_clearly_regular_tt.ttf',
categories: {
uppercase: { scale: 0.95, baselineOffset: 0 },
tallLower: { scale: 0.95, baselineOffset: 0 },
descender: { scale: 1.05, baselineOffset: 0 },
lowercase: { scale: 0.85, baselineOffset: 0 },
uppercase: { scale: 1, baselineOffset: 0 },
tallLower: { scale: 1, baselineOffset: 0 },
descender: { scale: 1, baselineOffset: 0 },
lowercase: { scale: 1, baselineOffset: 0 },
},
letterOverrides: {
f: { scale: 1.35, baselineOffset: 0 },
t: { scale: 0.85, baselineOffset: 0 },
j: { scale: 1.15, baselineOffset: 0 },
i: { scale: 1, baselineOffset: 0 },
f: { scale: 1, baselineOffset: 0 },
t: { scale: 1, baselineOffset: 0 },
j: { scale: 1, baselineOffset: 0 },
},
};
/** Print Clearly Bold */
export const PRINT_CLEARLY_BOLD: FontProfile = {
name: 'PrintClearlyBold',
url: 'https://cdn.joeyone.cn/doodle/fonts/print_clearly_bold_tt.ttf',
categories: {
uppercase: { scale: 0.95, baselineOffset: 0 },
tallLower: { scale: 0.95, baselineOffset: 0 },
descender: { scale: 1.05, baselineOffset: 0 },
lowercase: { scale: 0.85, baselineOffset: 0 },
uppercase: { scale: 1, baselineOffset: 0 },
tallLower: { scale: 1, baselineOffset: 0 },
descender: { scale: 1, baselineOffset: 0 },
lowercase: { scale: 1, baselineOffset: 0 },
},
letterOverrides: {
f: { scale: 1.35, baselineOffset: 0 },
t: { scale: 0.85, baselineOffset: 0 },
j: { scale: 1.15, baselineOffset: 0 },
i: { scale: 1, baselineOffset: 0 },
f: { scale: 1, baselineOffset: 0 },
t: { scale: 1, baselineOffset: 0 },
j: { scale: 1, baselineOffset: 0 },
},
};
/** Print Clearly Dashed — 虚线描红字体 */
export const PRINT_CLEARLY_DASHED: FontProfile = {
name: 'PrintClearlyDashed',
url: 'https://cdn.joeyone.cn/doodle/fonts/print_clearly_dashed_tt.ttf',
categories: {
uppercase: { scale: 0.95, baselineOffset: 0 },
tallLower: { scale: 0.95, baselineOffset: 0 },
descender: { scale: 1.05, baselineOffset: 0 },
lowercase: { scale: 0.85, baselineOffset: 0 },
uppercase: { scale: 1, baselineOffset: 0 },
tallLower: { scale: 1, baselineOffset: 0 },
descender: { scale: 1, baselineOffset: 0 },
lowercase: { scale: 1, baselineOffset: 0 },
},
letterOverrides: {
f: { scale: 1.35, baselineOffset: 0 },
t: { scale: 0.85, baselineOffset: 0 },
j: { scale: 1.15, baselineOffset: 0 },
i: { scale: 1, baselineOffset: 0 },
f: { scale: 1, baselineOffset: 0 },
t: { scale: 1, baselineOffset: 0 },
j: { scale: 1, baselineOffset: 0 },
},
};
/** Num 数字字体 */
export const NUM_FONT: FontProfile = {
name: 'NumFont',
url: 'https://cdn.joeyone.cn/doodle/fonts/num.woff2',
categories: {
uppercase: { scale: 0.95, baselineOffset: 0 },
@@ -184,6 +224,25 @@ export const NUM_FONT: FontProfile = {
},
};
/** 当前激活的字体配置(切换字体只需修改这里) */
// export const ACTIVE_FONT_PROFILE = ZHIYONG_WRITE;
export const ACTIVE_FONT_PROFILE = HANDLEE_REGULAR;
// ── 字体注册表:按 name 索引所有可用字体 ──
const FONT_PROFILES: ReadonlyArray<FontProfile> = [
ZHIYONG_WRITE,
HANDLEE_REGULAR,
PRINT_CLEARLY_REGULAR,
PRINT_CLEARLY_BOLD,
PRINT_CLEARLY_DASHED,
NUM_FONT,
];
const _profileByName = new Map<string, FontProfile>(
FONT_PROFILES.map((p) => [p.name, p]),
);
/** 默认字母字体 */
export const DEFAULT_LETTER_PROFILE = PRINT_CLEARLY_REGULAR;
/** 按名称查找字体配置,找不到返回默认 */
export function getFontProfile(name: string): FontProfile {
return _profileByName.get(name) ?? DEFAULT_LETTER_PROFILE;
}
@@ -5,8 +5,7 @@ import type {
} from '../generators/letter-tracing-generator';
import { drawFourLineGrid } from './drawTools';
const LETTER_FACE =
'Helvetica, Arial, "Segoe UI", Roboto, sans-serif';
const LETTER_FACE = 'Helvetica, Arial, "Segoe UI", Roboto, sans-serif';
type CasePairingData = Extract<LetterTracingData, { mode: 'case-pairing' }>;
@@ -3,28 +3,32 @@ import type {
LetterTracingData,
TracingRow,
} from '../generators/letter-tracing-generator';
import { fontFamilyOf } from '../data/fontProfiles';
import {
calcLetterFontSizes,
drawFourLineGrid,
drawLetterInFourLineGrid,
FOUR_LINE_GRID_H,
TOY_FONT_LETTER,
type LetterFontSizes,
} from './drawTools';
const LETTER_FACE = `${TOY_FONT_LETTER}, Roboto, sans-serif`;
const COLOR_BLACK = '#1a1a1a';
const COLOR_LIGHT_RED = 'rgba(252, 46, 0, 0.3)';
type ColumnCompareData = Extract<
LetterTracingData,
{ mode: 'column-compare' }
>;
/** 左右大写/小写两栏之间的间距(相对行宽 + 下限,避免过窄屏过小) */
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 个),右半写小写(repetitions 个),
* 左栏大写、右栏小写(repetitions 个),中间留间距;同栏内字母略收紧。
* 每侧第一个字母黑色,后续浅红色。
*/
export default class ColumnCompareDraw extends BaseDrawService {
@@ -52,7 +56,14 @@ export default class ColumnCompareDraw extends BaseDrawService {
);
const y0 = this.currentY + rowGap;
const halfW = lineW / 2;
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);
@@ -60,10 +71,22 @@ export default class ColumnCompareDraw extends BaseDrawService {
drawFourLineGrid(this.ctx, M, y, lineW, FOUR_LINE_GRID_H);
if (i < leftRows.length) {
this.drawLettersInHalf(leftRows[i], M, y, halfW);
this.drawLettersInHalf(
leftRows[i],
leftX,
y,
halfContentW,
INTRA_COLUMN_LETTER_BAND_RATIO,
);
}
if (i < rightRows.length) {
this.drawLettersInHalf(rightRows[i], M + halfW, y, halfW);
this.drawLettersInHalf(
rightRows[i],
rightX,
y,
halfContentW,
INTRA_COLUMN_LETTER_BAND_RATIO,
);
}
}
}
@@ -76,16 +99,19 @@ export default class ColumnCompareDraw extends BaseDrawService {
row: TracingRow,
x: number,
y: number,
halfW: number,
columnW: number,
letterBandRatio: number,
) {
const n = row.cells.length;
if (n === 0) return;
const slotW = halfW / n;
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 + slotW * i + slotW / 2;
const cx = x + inset + slotW * i + slotW / 2;
drawLetterInFourLineGrid(
this.ctx,
@@ -94,7 +120,7 @@ export default class ColumnCompareDraw extends BaseDrawService {
y,
FOUR_LINE_GRID_H,
this.letterSizes,
{ fontFamily: LETTER_FACE, color },
{ fontFamily: fontFamilyOf(this.letterSizes._profile), color },
);
}
}
@@ -1,9 +1,10 @@
/**
* 绘制工具集合(后续可继续扩展其他通用绘制方法)
* 四线三格 & 字母绘制工具集
*/
import {
ACTIVE_FONT_PROFILE,
DEFAULT_LETTER_PROFILE,
fontFamilyOf,
getLetterMetrics,
type FontProfile,
} from '../data/fontProfiles';
@@ -11,47 +12,47 @@ import {
// ── 四线三格统一高度(所有页面共用) ──
export const FOUR_LINE_GRID_H = 43;
// ── 英语字母字体集合 ──
export const TOY_FONT_LETTER = 'ToyLetterFont';
// ── 字体加载(支持多字体,按 URL 去重) ──
let _fontLoaded = false;
export function loadLetterFont(): Promise<void> {
if (_fontLoaded) return Promise.resolve();
const _loadedUrls = new Set<string>();
/**
* 加载字体到 Canvas native 渲染管线。
* 同一 URL 只加载一次;不同 FontProfile 可安全并发调用。
*/
export function loadLetterFont(
profile: FontProfile = DEFAULT_LETTER_PROFILE,
): Promise<void> {
if (_loadedUrls.has(profile.url)) return Promise.resolve();
return new Promise((resolve, reject) => {
wx.loadFontFace({
family: TOY_FONT_LETTER,
source: `url("${ACTIVE_FONT_PROFILE.url}")`,
family: profile.name,
source: `url("${profile.url}")`,
scopes: ['native'],
success: () => {
_fontLoaded = true;
_loadedUrls.add(profile.url);
resolve();
},
fail: (err) => {
console.error('loadFontFace failed', err);
console.error(`loadLetterFont [${profile.name}] failed`, err);
reject(err);
},
});
});
}
// ── 向后兼容:LetterFontSizes 类型保留,供外部已有调用使用 ──
// ── LetterFontSizes:持有 FontProfile 引用,运行时按字母查表 ──
export type LetterFontSizes = {
/** 字体配置(新机制) */
_profile: FontProfile;
/** gridH(用于运行时计算) */
_gridH: number;
};
/**
* 根据四线三格总高度,构建 LetterFontSizes。
* 内部持有 FontProfile 引用,按字母查表计算。
*/
export function calcLetterFontSizes(gridH: number): LetterFontSizes {
return {
_profile: ACTIVE_FONT_PROFILE,
_gridH: gridH,
};
export function calcLetterFontSizes(
gridH: number,
profile: FontProfile = DEFAULT_LETTER_PROFILE,
): LetterFontSizes {
return { _profile: profile, _gridH: gridH };
}
/** 根据字母和字体配置,计算对应 fontSize */
@@ -97,7 +98,7 @@ export function drawLetterInFourLineGrid(
},
) {
const fontFamily =
options?.fontFamily ?? `${TOY_FONT_LETTER}, Roboto, sans-serif`;
options?.fontFamily ?? fontFamilyOf(sizes._profile);
const color = options?.color ?? '#1a1a1a';
const bold = options?.bold ?? false;
@@ -1,87 +1,133 @@
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';
import {
PRINT_CLEARLY_BOLD,
PRINT_CLEARLY_DASHED,
fontFamilyOf,
} from '../data/fontProfiles';
import {
calcLetterFontSizes,
drawFourLineGrid,
drawLetterInFourLineGrid,
FOUR_LINE_GRID_H,
loadLetterFont,
type LetterFontSizes,
} from './drawTools';
type OverviewData = Extract<LetterTracingData, { mode: 'overview' }>;
/** 页面左右边距(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 OverviewDraw extends BaseDrawService {
private letterSizes: LetterFontSizes = calcLetterFontSizes(
FOUR_LINE_GRID_H,
DASHED_PROFILE,
);
async draw(data: OverviewData) {
this.prepareDraw();
await Promise.allSettled([
loadLetterFont(DASHED_PROFILE),
loadLetterFont(TITLE_PROFILE),
]);
await this.drawHeaderAndDivider();
this.drawContent(data.upperRows, data.lowerRows);
this.drawPrintFooter();
this.drawOverviewBody(data.upperRows, data.lowerRows);
await 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;
private drawOverviewBody(
upperRows: string[][],
lowerRows: string[][],
): void {
const lineW = this.canvasWidth - M * 2;
const slotW = lineW / 7;
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;
};
let y = this.currentY + SECTION_GAP - 7;
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);
y = this.drawLetterBlock(
'Uppercase Letters',
upperRows,
y,
lineW,
slotW,
);
y += SECTION_GAP + 7;
this.drawLetterBlock('lowercase letters', lowerRows, y, lineW, slotW);
}
private drawCharInCell(
ch: string,
x: number,
y: number,
w: number,
h: number,
opacity: number,
bold: boolean,
) {
/**
* 绘制一个分区:居中标题 + 4 行四线三格与字母。
* @returns 该分区最后一行四线三格底边 Y
*/
private drawLetterBlock(
title: string,
rows: string[][],
startY: number,
lineW: number,
slotW: number,
): number {
const ctx = this.ctx;
if (opacity <= 0) return;
const centerX = M + lineW / 2;
ctx.save();
ctx.globalAlpha = opacity;
ctx.fillStyle = bold ? '#1a1a1a' : '#4a4a4a';
ctx.font = `bold ${TITLE_SIZE}px ${TITLE_FACE}`;
ctx.fillStyle = TITLE_COLOR;
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.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;
}
}
@@ -4,29 +4,29 @@ import type {
LetterTracingData,
HighlightGrid,
} from '../generators/letter-tracing-generator';
import { fontFamilyOf } from '../data/fontProfiles';
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);
private letterSizes: LetterFontSizes =
calcLetterFontSizes(FOUR_LINE_GRID_H);
private get letterFace(): string {
return fontFamilyOf(this.letterSizes._profile);
}
async draw(data: PictureData) {
this.prepareDraw();
@@ -68,7 +68,7 @@ export default class PictureTracingDraw extends BaseDrawService {
dh,
);
} catch {
ctx.font = `bold 60px ${LETTER_FACE}`;
ctx.font = `bold 60px ${this.letterFace}`;
ctx.fillStyle = '#111';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
@@ -195,11 +195,11 @@ export default class PictureTracingDraw extends BaseDrawService {
const pairGap = 3;
const upperFs = getLetterFontSize(upper, sizes);
ctx.font = `${upperFs}px ${LETTER_FACE}`;
ctx.font = `${upperFs}px ${this.letterFace}`;
const upperW = ctx.measureText(upper).width;
const lowerFs = getLetterFontSize(lower, sizes);
ctx.font = `${lowerFs}px ${LETTER_FACE}`;
ctx.font = `${lowerFs}px ${this.letterFace}`;
const lowerW = ctx.measureText(lower).width;
const totalPairW = upperW + pairGap + lowerW;
@@ -213,7 +213,7 @@ export default class PictureTracingDraw extends BaseDrawService {
y,
FOUR_LINE_GRID_H,
sizes,
{ fontFamily: LETTER_FACE, color },
{ fontFamily: this.letterFace, color },
);
drawLetterInFourLineGrid(
ctx,
@@ -222,7 +222,7 @@ export default class PictureTracingDraw extends BaseDrawService {
y,
FOUR_LINE_GRID_H,
sizes,
{ fontFamily: LETTER_FACE, color },
{ fontFamily: this.letterFace, color },
);
ctx.restore();
@@ -235,7 +235,7 @@ export default class PictureTracingDraw extends BaseDrawService {
y: number,
) {
const ctx = this.ctx;
ctx.font = `36px ${LETTER_FACE}`;
ctx.font = `36px ${this.letterFace}`;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
@@ -286,7 +286,7 @@ export default class PictureTracingDraw extends BaseDrawService {
ctx.setLineDash([]);
ctx.strokeRect(cx, cy, cellSize, cellSize);
ctx.font = `18px ${LETTER_FACE}`;
ctx.font = `18px ${this.letterFace}`;
ctx.fillStyle = '#000';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
@@ -380,7 +380,7 @@ export default class PictureTracingDraw extends BaseDrawService {
);
} catch {
const fs = Math.min(imgH * 0.6, imgW * 0.18);
ctx.font = `bold ${fs}px ${LETTER_FACE}`;
ctx.font = `bold ${fs}px ${this.letterFace}`;
ctx.fillStyle = '#222';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
@@ -1,15 +1,19 @@
import { ALPHABET_BY_LETTER, LETTERS_UPPER } from '../data/alphabet';
/** 字母描红的布局模式(与产品文档类练习一致) */
/** 字母描红的布局模式(与产品文档类练习一致) */
export type LetterTracingMode =
/** 4 看图描红:教学区 + 6 行描红(前三行大写、后三行小写) */
/** 1 看图识字:教学区 + 描红(前三行大写、后三行小写) */
| 'picture-tracing'
/** 1 大小写配对:左栏 A–M、右栏 N–Z,每行最多 4 组 Aa */
/** 2 字母总览:Uppercase / Lowercase 分区,每区四行 7+7+6+6 */
| 'overview'
/** 3 大小写配对:左栏 A–M、右栏 N–Z,每行最多 4 组 Aa */
| 'case-pairing'
/** 2 分栏对照:半组字母,左栏大写、右栏小写,每行 4 个 */
/** 4 分栏对照:半组字母,左栏大写、右栏小写,每行 4 个 */
| 'column-compare'
/** 3 字母总览:Uppercase / Lowercase 分区,每区 9+9+8 */
| 'overview';
/** 5 三字母精练:每页聚焦 3 个字母,各含大写行 + 小写行 */
| 'triple-focus'
/** 6 单字母逐行:每行一个字母,13 字母半表(A–M 或 N–Z) */
| 'single-line';
/** 字母大小写模式(部分模式不使用) */
export type LetterCaseMode = 'upper' | 'lower' | 'both';
@@ -24,8 +28,10 @@ export interface LetterTracingGeneratorConfig {
selectedLetter?: string;
repetitions: number;
fadePattern: 'gradient' | 'first-only';
/** 分栏对照:练习 A–M 或 N–Z */
/** 分栏对照 / 单字母逐行:练习 AM 或 NZ */
alphabetHalf?: AlphabetHalf;
/** 三字母精练:当前页聚焦的 3 个字母(如 ['A','B','C'] */
tripleLetters?: [string, string, string];
}
/** 单个描红格子的数据 */
@@ -184,9 +190,14 @@ function chunk<T>(arr: T[], size: number): T[][] {
return out;
}
/** 总览区 26 字母拆成 9 + 9 + 8 三行 */
/** 总览区 26 字母拆成 7 + 7 + 6 + 6 四行(前两行 7 个、后两行 6 个,与四线三格排版一致) */
function overviewRowsFrom(letters: string[]): string[][] {
return [letters.slice(0, 9), letters.slice(9, 18), letters.slice(18, 26)];
return [
letters.slice(0, 7),
letters.slice(7, 14),
letters.slice(14, 20),
letters.slice(20, 26),
];
}
/**
@@ -194,7 +205,7 @@ function overviewRowsFrom(letters: string[]): string[][] {
* - picture-tracing:教学区 + 6 行描红
* - case-pairing:左 AM / 右 N–Z,每行最多 4 组大小写配对
* - column-compare:半表,左大写右小写,每行 4 个
* - overviewUppercase / Lowercase Letters9+9+8
* - overviewUppercase / Lowercase Letters每区四行 7+7+6+6
*/
export function generateLetterTracing(
config: LetterTracingGeneratorConfig,
@@ -295,5 +306,7 @@ export function mergeLetterTracingConfig(
alphabetHalf: (overrides.alphabetHalf ??
base.alphabetHalf ??
'A-M') as AlphabetHalf,
tripleLetters: overrides.tripleLetters ??
base.tripleLetters ?? ['A', 'B', 'C'],
};
}