feat: 分栏对照开发,字体缩放、定位调整

This commit is contained in:
R524809
2026-04-16 18:04:57 +08:00
parent 4299c4e082
commit fdb2165dc7
8 changed files with 451 additions and 119 deletions
@@ -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:43top 分别 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→Baseline2 格高),小写占满 Midline→Baseline1 格高)
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;