feat: 分栏对照开发,字体缩放、定位调整
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
type LetterTracingMode,
|
||||
} from '../shared/generators/letter-tracing-generator';
|
||||
import { LETTERS_UPPER } from '../shared/data/alphabet';
|
||||
import { loadLetterFont } from '../shared/draw/drawTools';
|
||||
|
||||
/** 各 worksheetId 对应的页面标题和描述 */
|
||||
const PAGE_META: Record<string, { title: string; desc: string }> = {
|
||||
@@ -80,13 +81,34 @@ createPage(
|
||||
modeOptions: [...MODE_OPTIONS],
|
||||
} as unknown as PageData,
|
||||
|
||||
/** 页面加载:从路由参数获取 worksheetId 并应用 */
|
||||
onLoad(options: { id?: string }) {
|
||||
/** 页面加载:从路由参数获取 worksheetId、letter、case 并应用,同时预加载字体 */
|
||||
onLoad(options: {
|
||||
id?: string;
|
||||
letter?: string;
|
||||
case?: 'upper' | 'lower';
|
||||
}) {
|
||||
loadLetterFont().catch(() => {});
|
||||
const worksheetId =
|
||||
options.id && PAGE_META[options.id]
|
||||
? options.id
|
||||
: 'letter-tracing-single';
|
||||
this.applyWorksheet(worksheetId);
|
||||
|
||||
const updates: Partial<PageData> = {};
|
||||
if (options.letter) {
|
||||
const normalized = normalizeLetter(options.letter);
|
||||
if (normalized) updates.selectedLetter = normalized;
|
||||
}
|
||||
if (options.case === 'lower') {
|
||||
updates.letterCaseLower = true;
|
||||
} else if (options.case === 'upper') {
|
||||
updates.letterCaseLower = false;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
this.setData(
|
||||
updates as Partial<PageData> & WechatMiniprogram.IAnyObject,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/** 用户点击模式选择器时切换 worksheet */
|
||||
@@ -140,10 +162,11 @@ createPage(
|
||||
});
|
||||
},
|
||||
|
||||
/** 执行 Canvas 绘制:生成数据 → 调用 drawService 渲染 */
|
||||
/** 执行 Canvas 绘制:等待字体就绪 → 生成数据 → 调用 drawService 渲染 */
|
||||
async drawCanvas() {
|
||||
if (!this.ctx || !this.drawService) return;
|
||||
try {
|
||||
await loadLetterFont();
|
||||
const config = this.buildRuntimeConfig();
|
||||
const data = generateLetterTracing(config);
|
||||
console.log('drawCanvas data', data);
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 字体渲染配置(Font Profile)
|
||||
*
|
||||
* 不同手写字体的字形设计差异很大,同一 fontSize 下各字母的视觉高度、
|
||||
* 基线位置各不相同。为每种字体维护一份 profile,按字母分类配置
|
||||
* 缩放因子和 Y 轴偏移,保证字母在四线三格中视觉准确。
|
||||
*
|
||||
* 设计思路(参考 Excalidraw 的 hardcoded font metrics):
|
||||
* - 缩放和偏移均为相对值(基于 gridH),换字体只需新增/调整 profile
|
||||
* - 按字母分类给出默认值,可通过 letterOverrides 对单个字母做精细微调
|
||||
*/
|
||||
|
||||
/** 字母分类 */
|
||||
export type LetterCategory =
|
||||
| 'uppercase'
|
||||
| 'tallLower'
|
||||
| 'descender'
|
||||
| 'lowercase';
|
||||
|
||||
/** 单个分类的渲染参数 */
|
||||
export interface CategoryMetrics {
|
||||
/** fontSize = gridH * scale */
|
||||
scale: number;
|
||||
/** 基线 Y 偏移修正(相对于 gridH,正值=下移) */
|
||||
baselineOffset: number;
|
||||
}
|
||||
|
||||
/** 单个字母的渲染微调(覆盖所属分类的默认值) */
|
||||
export interface LetterOverride {
|
||||
scale?: number;
|
||||
baselineOffset?: number;
|
||||
}
|
||||
|
||||
/** 一套完整的字体渲染配置 */
|
||||
export interface FontProfile {
|
||||
/** 字体 URL */
|
||||
url: string;
|
||||
/** 各分类的默认渲染参数 */
|
||||
categories: Record<LetterCategory, CategoryMetrics>;
|
||||
/** 按字母精细微调(优先级高于分类默认值) */
|
||||
letterOverrides?: Record<string, LetterOverride>;
|
||||
}
|
||||
|
||||
// ── 字母分类集合 ──
|
||||
const TALL_LOWERCASE = new Set(['b', 'd', 'h', 'k', 'l']);
|
||||
const DESCENDERS = new Set(['g', 'p', 'q', 'y']);
|
||||
|
||||
export { TALL_LOWERCASE, DESCENDERS };
|
||||
|
||||
/** 判断字母所属分类 */
|
||||
export function getLetterCategory(letter: string): LetterCategory {
|
||||
if (letter >= 'A' && letter <= 'Z') return 'uppercase';
|
||||
if (TALL_LOWERCASE.has(letter)) return 'tallLower';
|
||||
if (DESCENDERS.has(letter)) return 'descender';
|
||||
return 'lowercase';
|
||||
}
|
||||
|
||||
/** 获取某个字母的最终渲染参数(分类默认 + 单字母微调合并) */
|
||||
export function getLetterMetrics(
|
||||
letter: string,
|
||||
profile: FontProfile,
|
||||
): CategoryMetrics {
|
||||
const cat = getLetterCategory(letter);
|
||||
const base = profile.categories[cat];
|
||||
const override = profile.letterOverrides?.[letter];
|
||||
if (!override) return base;
|
||||
return {
|
||||
scale: override.scale ?? base.scale,
|
||||
baselineOffset: override.baselineOffset ?? base.baselineOffset,
|
||||
};
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// 字体配置表
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
/**
|
||||
* 四线三格字母分类目标占位(所有字体通用):
|
||||
* 顶线 ─── 第一格 ─── 中线 ─── 第二格 ─── 基线 ─── 第三格 ─── 底线
|
||||
*
|
||||
* uppercase / tallLower : 第一格 + 第二格(顶线 → 基线)
|
||||
* descender (g,p,q,y) : 第二格 + 第三格(中线 → 底线)
|
||||
* lowercase : 第二格(中线 → 基线)
|
||||
*
|
||||
* f / t / j 等需跨格占位的字母不在分类里单独建模,统一走 lowercase,
|
||||
* 在 letterOverrides 里按字体微调(与 i 相同方式)。
|
||||
*/
|
||||
|
||||
/** ZhiyongWrite 手写字体 */
|
||||
export const ZHIYONG_WRITE: FontProfile = {
|
||||
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 },
|
||||
},
|
||||
};
|
||||
|
||||
/** Handlee Regular — Google 手写风格 */
|
||||
export const HANDLEE_REGULAR: FontProfile = {
|
||||
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 },
|
||||
},
|
||||
};
|
||||
|
||||
/** Print Clearly Regular */
|
||||
export const PRINT_CLEARLY_REGULAR: FontProfile = {
|
||||
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 },
|
||||
},
|
||||
letterOverrides: {
|
||||
f: { scale: 1.35, baselineOffset: 0 },
|
||||
t: { scale: 0.85, baselineOffset: 0 },
|
||||
j: { scale: 1.15, baselineOffset: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
/** Print Clearly Bold */
|
||||
export const PRINT_CLEARLY_BOLD: FontProfile = {
|
||||
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 },
|
||||
},
|
||||
letterOverrides: {
|
||||
f: { scale: 1.35, baselineOffset: 0 },
|
||||
t: { scale: 0.85, baselineOffset: 0 },
|
||||
j: { scale: 1.15, baselineOffset: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
/** Print Clearly Dashed — 虚线描红字体 */
|
||||
export const PRINT_CLEARLY_DASHED: FontProfile = {
|
||||
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 },
|
||||
},
|
||||
letterOverrides: {
|
||||
f: { scale: 1.35, baselineOffset: 0 },
|
||||
t: { scale: 0.85, baselineOffset: 0 },
|
||||
j: { scale: 1.15, baselineOffset: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
/** Num 数字字体 */
|
||||
export const NUM_FONT: FontProfile = {
|
||||
url: 'https://cdn.joeyone.cn/doodle/fonts/num.woff2',
|
||||
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 },
|
||||
},
|
||||
letterOverrides: {
|
||||
f: { scale: 1.35, baselineOffset: 0 },
|
||||
t: { scale: 0.85, baselineOffset: 0 },
|
||||
j: { scale: 1.15, baselineOffset: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
/** 当前激活的字体配置(切换字体只需修改这里) */
|
||||
// export const ACTIVE_FONT_PROFILE = ZHIYONG_WRITE;
|
||||
export const ACTIVE_FONT_PROFILE = HANDLEE_REGULAR;
|
||||
@@ -3,17 +3,34 @@ import type {
|
||||
LetterTracingData,
|
||||
TracingRow,
|
||||
} from '../generators/letter-tracing-generator';
|
||||
import { drawFourLineGrid } from './drawTools';
|
||||
import {
|
||||
calcLetterFontSizes,
|
||||
drawFourLineGrid,
|
||||
drawLetterInFourLineGrid,
|
||||
FOUR_LINE_GRID_H,
|
||||
TOY_FONT_LETTER,
|
||||
type LetterFontSizes,
|
||||
} from './drawTools';
|
||||
|
||||
const LETTER_FACE =
|
||||
'Helvetica, Arial, "Segoe UI", Roboto, sans-serif';
|
||||
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' }
|
||||
>;
|
||||
|
||||
/**
|
||||
* 分栏对照绘制:
|
||||
* 四线三格横跨整页宽度,字母分左右两侧书写——
|
||||
* 左半写大写(repetitions 个),右半写小写(repetitions 个),
|
||||
* 每侧第一个字母黑色,后续浅红色。
|
||||
*/
|
||||
export default class ColumnCompareDraw extends BaseDrawService {
|
||||
private letterSizes: LetterFontSizes =
|
||||
calcLetterFontSizes(FOUR_LINE_GRID_H);
|
||||
|
||||
async draw(data: ColumnCompareData) {
|
||||
this.prepareDraw();
|
||||
await this.drawHeaderAndDivider();
|
||||
@@ -23,83 +40,62 @@ export default class ColumnCompareDraw extends BaseDrawService {
|
||||
|
||||
private drawContent(leftRows: TracingRow[], rightRows: TracingRow[]) {
|
||||
const M = 24;
|
||||
const y0 = this.currentY + 6;
|
||||
const bottom = this.canvasHeight - M;
|
||||
const lineW = this.canvasWidth - M * 2;
|
||||
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);
|
||||
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 halfW = lineW / 2;
|
||||
|
||||
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], M, y, halfW);
|
||||
}
|
||||
if (i < rightRows.length) {
|
||||
this.drawLettersInHalf(rightRows[i], M + halfW, y, halfW);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawTracingRow(
|
||||
/**
|
||||
* 在四线三格的半侧区域内绘制字母。
|
||||
* 第一个字母黑色,后续字母浅红色。
|
||||
*/
|
||||
private drawLettersInHalf(
|
||||
row: TracingRow,
|
||||
startX: number,
|
||||
x: number,
|
||||
y: number,
|
||||
rowH: number,
|
||||
totalWidth: number,
|
||||
halfW: number,
|
||||
) {
|
||||
const gap = 4;
|
||||
const n = row.cells.length;
|
||||
const cellW = n > 0 ? (totalWidth - gap * (n - 1)) / n : totalWidth;
|
||||
if (n === 0) return;
|
||||
|
||||
const slotW = halfW / 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 + 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: LETTER_FACE, 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,118 @@
|
||||
* 绘制工具集合(后续可继续扩展其他通用绘制方法)
|
||||
*/
|
||||
|
||||
import {
|
||||
ACTIVE_FONT_PROFILE,
|
||||
getLetterMetrics,
|
||||
type FontProfile,
|
||||
} from '../data/fontProfiles';
|
||||
|
||||
// ── 四线三格统一高度(所有页面共用) ──
|
||||
export const FOUR_LINE_GRID_H = 43;
|
||||
|
||||
// ── 英语字母字体集合 ──
|
||||
export const TOY_FONT_LETTER = 'ToyLetterFont';
|
||||
|
||||
let _fontLoaded = false;
|
||||
export function loadLetterFont(): Promise<void> {
|
||||
if (_fontLoaded) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.loadFontFace({
|
||||
family: TOY_FONT_LETTER,
|
||||
source: `url("${ACTIVE_FONT_PROFILE.url}")`,
|
||||
scopes: ['native'],
|
||||
success: () => {
|
||||
_fontLoaded = true;
|
||||
resolve();
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('loadFontFace failed', err);
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── 向后兼容:LetterFontSizes 类型保留,供外部已有调用使用 ──
|
||||
|
||||
export type LetterFontSizes = {
|
||||
/** 字体配置(新机制) */
|
||||
_profile: FontProfile;
|
||||
/** gridH(用于运行时计算) */
|
||||
_gridH: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据四线三格总高度,构建 LetterFontSizes。
|
||||
* 内部持有 FontProfile 引用,按字母查表计算。
|
||||
*/
|
||||
export function calcLetterFontSizes(gridH: number): LetterFontSizes {
|
||||
return {
|
||||
_profile: ACTIVE_FONT_PROFILE,
|
||||
_gridH: gridH,
|
||||
};
|
||||
}
|
||||
|
||||
/** 根据字母和字体配置,计算对应 fontSize */
|
||||
export function getLetterFontSize(
|
||||
letter: string,
|
||||
sizes: LetterFontSizes,
|
||||
): number {
|
||||
const metrics = getLetterMetrics(letter, sizes._profile);
|
||||
return sizes._gridH * metrics.scale;
|
||||
}
|
||||
|
||||
/** 根据字母和字体配置,计算基线 Y 偏移修正(px) */
|
||||
function getLetterBaselineOffset(
|
||||
letter: string,
|
||||
sizes: LetterFontSizes,
|
||||
): number {
|
||||
const metrics = getLetterMetrics(letter, sizes._profile);
|
||||
return sizes._gridH * metrics.baselineOffset;
|
||||
}
|
||||
|
||||
// ── 向后兼容:导出 DESCENDERS 集合 ──
|
||||
export { DESCENDERS } from '../data/fontProfiles';
|
||||
|
||||
/**
|
||||
* 在四线三格中绘制单个字母(基线固定在 y + 2h/3 + baselineOffset)。
|
||||
*
|
||||
* @param cx 字母水平中心
|
||||
* @param gridY 四线三格顶线 Y
|
||||
* @param gridH 四线三格总高度
|
||||
* @param sizes 预计算的字号(由 calcLetterFontSizes 生成)
|
||||
*/
|
||||
export function drawLetterInFourLineGrid(
|
||||
ctx: RenderingContext,
|
||||
letter: string,
|
||||
cx: number,
|
||||
gridY: number,
|
||||
gridH: number,
|
||||
sizes: LetterFontSizes,
|
||||
options?: {
|
||||
fontFamily?: string;
|
||||
color?: string;
|
||||
bold?: boolean;
|
||||
},
|
||||
) {
|
||||
const fontFamily =
|
||||
options?.fontFamily ?? `${TOY_FONT_LETTER}, Roboto, sans-serif`;
|
||||
const color = options?.color ?? '#1a1a1a';
|
||||
const bold = options?.bold ?? false;
|
||||
|
||||
const baselineY =
|
||||
gridY + (gridH * 2) / 3 + getLetterBaselineOffset(letter, sizes);
|
||||
const fontSize = getLetterFontSize(letter, sizes);
|
||||
|
||||
ctx.save();
|
||||
ctx.font = `${bold ? 'bold ' : ''}${fontSize}px ${fontFamily}`;
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
ctx.fillText(letter, cx, baselineY);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export type FourLineGridStyle = {
|
||||
/** 顶线/底线颜色 */
|
||||
ink?: string;
|
||||
@@ -62,4 +174,3 @@ export function drawFourLineGrid(
|
||||
ctx.lineTo(x + w, yBot);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,20 +4,30 @@ import type {
|
||||
LetterTracingData,
|
||||
HighlightGrid,
|
||||
} from '../generators/letter-tracing-generator';
|
||||
import { drawFourLineGrid } from './drawTools';
|
||||
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 = 'Roboto, sans-serif';
|
||||
const TITLE_EMOJI_FONT =
|
||||
"16px 'Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',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);
|
||||
|
||||
async draw(data: PictureData) {
|
||||
this.prepareDraw();
|
||||
await this.drawHeaderAndDivider();
|
||||
@@ -141,7 +151,6 @@ export default class PictureTracingDraw extends BaseDrawService {
|
||||
// 每行: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),左对齐,形成倒置直角三角形
|
||||
@@ -150,19 +159,17 @@ export default class PictureTracingDraw extends BaseDrawService {
|
||||
|
||||
for (let r = 0; r < lineTops.length; r++) {
|
||||
const y = lineTops[r];
|
||||
drawFourLineGrid(ctx, lineX, y, lineW, lineH);
|
||||
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; // 1 → 0.3
|
||||
const alpha = n <= 1 ? 1 : 1 - (i / (n - 1)) * 0.7;
|
||||
this.drawUpperLowerPairInFourLines(
|
||||
teaching.upper,
|
||||
teaching.lower,
|
||||
lineX + i * slotW,
|
||||
y,
|
||||
slotW,
|
||||
lineH,
|
||||
alpha,
|
||||
);
|
||||
}
|
||||
@@ -170,17 +177,7 @@ export default class PictureTracingDraw extends BaseDrawService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 在四线三格中绘制 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,间距远小于组间距
|
||||
* 在四线三格中绘制 Upper+Lower 对(共享基线,间距远小于组间距)
|
||||
*/
|
||||
private drawUpperLowerPairInFourLines(
|
||||
upper: string,
|
||||
@@ -188,40 +185,45 @@ export default class PictureTracingDraw extends BaseDrawService {
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
alpha: number,
|
||||
) {
|
||||
const ctx = this.ctx;
|
||||
const sizes = this.letterSizes;
|
||||
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;
|
||||
|
||||
const upperFs = getLetterFontSize(upper, sizes);
|
||||
ctx.font = `${upperFs}px ${LETTER_FACE}`;
|
||||
const upperW = ctx.measureText(upper).width;
|
||||
|
||||
const lowerFs = getLetterFontSize(lower, sizes);
|
||||
ctx.font = `${lowerFs}px ${LETTER_FACE}`;
|
||||
const lowerW = ctx.measureText(lower).width;
|
||||
|
||||
const totalPairW = upperW + pairGap + lowerW;
|
||||
const pairStartX = x + (w - totalPairW) / 2;
|
||||
const color = 'rgba(26, 26, 26, 1)';
|
||||
|
||||
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);
|
||||
drawLetterInFourLineGrid(
|
||||
ctx,
|
||||
upper,
|
||||
pairStartX + upperW / 2,
|
||||
y,
|
||||
FOUR_LINE_GRID_H,
|
||||
sizes,
|
||||
{ fontFamily: LETTER_FACE, color },
|
||||
);
|
||||
drawLetterInFourLineGrid(
|
||||
ctx,
|
||||
lower,
|
||||
pairStartX + upperW + pairGap + lowerW / 2,
|
||||
y,
|
||||
FOUR_LINE_GRID_H,
|
||||
sizes,
|
||||
{ fontFamily: LETTER_FACE, color },
|
||||
);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -257,7 +259,8 @@ export default class PictureTracingDraw extends BaseDrawService {
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
const hlIcon = '\u{1F50D}';
|
||||
ctx.font = TITLE_EMOJI_FONT;
|
||||
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;
|
||||
|
||||
@@ -312,7 +315,8 @@ export default class PictureTracingDraw extends BaseDrawService {
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
const traceIcon = '\u{1F58A}\u{FE0F}';
|
||||
ctx.font = TITLE_EMOJI_FONT;
|
||||
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;
|
||||
|
||||
|
||||
@@ -255,16 +255,11 @@ export function generateLetterTracing(
|
||||
config.alphabetHalf === 'N-Z'
|
||||
? LETTERS_UPPER.slice(13)
|
||||
: LETTERS_UPPER.slice(0, 13);
|
||||
const rowsOf4 = chunk(half, 4);
|
||||
const leftRows = rowsOf4.map((g) =>
|
||||
rowForManyChars(g, repetitions, fadePattern),
|
||||
const leftRows = half.map((L) =>
|
||||
rowForChar(L, repetitions, fadePattern),
|
||||
);
|
||||
const rightRows = rowsOf4.map((g) =>
|
||||
rowForManyChars(
|
||||
g.map((L) => L.toLowerCase()),
|
||||
repetitions,
|
||||
fadePattern,
|
||||
),
|
||||
const rightRows = half.map((L) =>
|
||||
rowForChar(L.toLowerCase(), repetitions, fadePattern),
|
||||
);
|
||||
return { mode: 'column-compare', leftRows, rightRows };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user