feat: 拼音字母选择

This commit is contained in:
R524809
2026-05-19 18:24:43 +08:00
parent 39b8e01c23
commit b70e54d6b0
40 changed files with 1197 additions and 759 deletions
+100
View File
@@ -0,0 +1,100 @@
/**
* 汉语拼音分类数据
* 23 个声母 + 24 个韵母 + 16 个整体认读音节
*/
export interface PinyinCategory {
key: string;
label: string;
count: number;
items: string[];
}
export interface PinyinSubCategory {
key: string;
label: string;
count: number;
items: string[];
}
export interface PinyinSection {
key: string;
title: string;
count: number;
items: string[];
subCategories?: PinyinSubCategory[];
}
// ─── 声母 (23个) ───
export const SHENGMU: string[] = [
'b', 'p', 'm', 'f',
'd', 't', 'n', 'l',
'g', 'k', 'h',
'j', 'q', 'x',
'zh', 'ch', 'sh', 'r',
'z', 'c', 's',
'y', 'w',
];
export const QIAOSHEYIN: string[] = ['zh', 'ch', 'sh', 'r'];
export const PINGSHEYIN: string[] = ['z', 'c', 's'];
// ─── 韵母 (24个) ───
export const DAN_YUNMU: string[] = ['a', 'o', 'e', 'i', 'u', 'ü'];
export const FU_YUNMU: string[] = ['ai', 'ei', 'ui', 'ao', 'ou', 'iu', 'ie', 'üe'];
export const TESHU_YUNMU: string[] = ['er'];
export const QIANBI_YUNMU: string[] = ['an', 'en', 'in', 'un', 'ün'];
export const HOUBI_YUNMU: string[] = ['ang', 'eng', 'ing', 'ong'];
export const YUNMU: string[] = [
...DAN_YUNMU,
...FU_YUNMU,
...TESHU_YUNMU,
...QIANBI_YUNMU,
...HOUBI_YUNMU,
];
// ─── 整体认读音节 (16个) ───
export const ZHENGTI_RENDU: string[] = [
'zhi', 'chi', 'shi', 'ri',
'zi', 'ci', 'si', 'yi',
'wu', 'yu', 'ye', 'yue',
'yuan', 'yin', 'yun', 'ying',
];
// ─── 结构化分类数据(用于绘制) ───
export const PINYIN_SECTIONS: PinyinSection[] = [
{
key: 'shengmu',
title: '一、声母',
count: 23,
items: SHENGMU,
subCategories: [
{ key: 'qiaoshe', label: '翘舌音', count: 4, items: QIAOSHEYIN },
{ key: 'pingshe', label: '平舌音', count: 3, items: PINGSHEYIN },
],
},
{
key: 'yunmu',
title: '二、韵母',
count: 24,
items: YUNMU,
subCategories: [
{ key: 'dan', label: '单韵母', count: 6, items: DAN_YUNMU },
{ key: 'fu', label: '复韵母', count: 8, items: FU_YUNMU },
{ key: 'teshu', label: '特殊韵母', count: 1, items: TESHU_YUNMU },
{ key: 'qianbi', label: '前鼻韵母', count: 5, items: QIANBI_YUNMU },
{ key: 'houbi', label: '后鼻韵母', count: 4, items: HOUBI_YUNMU },
],
},
{
key: 'zhengti',
title: '三、整体认读音节',
count: 16,
items: ZHENGTI_RENDU,
},
];
+39
View File
@@ -0,0 +1,39 @@
/**
* 描红练习统一样式配置
*
* 适用范围:田字格(汉字)、四线三格(拼音/英文字母)的格子线条和描红字体颜色。
* 所有绘制服务应统一引用此配置,避免各处硬编码颜色值。
*
* 设计原则:
* - 格子线条使用护眼绿色系,打印清晰且长时间书写不疲劳
* - 描红字体使用中性灰色,不带色彩偏向,打印友好
*
* @see docs/tracing-color-guide.md
*/
// ─── 格子线条颜色(田字格 / 四线三格通用) ───
export const GRID_COLORS = {
/** 外框、实线、垂直分隔线 — 中等绿色,清晰可见 */
border: '#7fb069',
/** 中线、虚线(田字格十字线 / 四线三格中间两线)— 浅绿色,柔和护眼 */
middleLine: '#a8d5a8',
/** 对角线 — 极淡绿色,辅助参考线(田字格专用) */
diagonal: '#d4f0d4',
} as const;
/** 四线三格默认虚线样式 */
export const GRID_DASH = [3, 3] as const;
// ─── 描红/临摹字体颜色 ───
export const TRACING_COLORS = {
/** 参照字/预览字 — 深灰色,完整展示供对照 */
reference: '#555555',
/** 描红引导字 — 中性灰色,跟着描写 */
guide: '#d0d0d0',
/** 极浅引导字 — 浅灰色,水印效果 */
guideLight: '#e0e0e0',
/** 首笔/强调 — 近黑色,用于首个参照格 */
strong: '#1a1a1a',
} as const;
-531
View File
@@ -1,531 +0,0 @@
import { PAPER_SIZE } from '../../constants/colors';
import { drawBaseHeader, drawBaseMiniHeader } from './baseHeaderDraw';
/**
* 基础绘制服务
* 包含Paper设置和Header绘制功能,可被所有绘制服务复用
*
* 提供功能:
* - Canvas 初始化和配置
* - Paper 尺寸设置(支持 A4 等标准尺寸)
* - Header 绘制(支持完整 Header 和迷你 Header
* - 分割线绘制
* - 打印配置管理
*/
export class BaseDrawService {
headerType: PrintHeader = 'wechat';
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
options: Record<string, any>;
paperSize: PaperSize;
currentX: number;
currentY: number;
canvasWidth: number; // 逻辑像素宽度
canvasHeight: number; // 逻辑像素高度
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
options = options || {};
const { appName, appHint } = getApp().getPrintConfig();
this.canvas = canvas;
this.ctx = ctx;
this.paperSize = 'A4';
this.options = {
appName,
appHint,
title: '看数字,涂一涂',
subTitle: '找一找下面相同的数字,涂上颜色',
...options,
};
this.currentX = 0;
this.currentY = 0;
this.canvasWidth = 0;
this.canvasHeight = 0;
this.setPrintConfig();
}
setPrintConfig() {
const printConfig = getApp().getPrintConfig();
this.headerType = printConfig.header;
this.options.appName = printConfig.appName;
}
/**
* 设置Paper(逻辑像素,尺寸除以3)
*/
setPaper() {
const { ctx, canvas } = this;
const { pixelRatio: dpr } = wx.getWindowInfo();
let { width, height } = PAPER_SIZE[this.paperSize];
this.canvasWidth = width;
this.canvasHeight = height;
// 设置 canvas 为物理像素尺寸(用于高分辨率显示)
const physicalWidth = width * dpr;
const physicalHeight = height * dpr;
canvas.width = physicalWidth;
canvas.height = physicalHeight;
// 重置 transform 并 scale 到逻辑像素
ctx.setTransform(1, 0, 0, 1, 0, 0); // 重置 transform
ctx.scale(dpr, dpr); // scale 到逻辑像素,后续绘制都使用逻辑像素
this.clear();
ctx.fillStyle = '#fff';
// 使用逻辑像素尺寸填充
ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight);
}
/**
* 清除画布
*/
clear() {
const canvas = this.canvas;
this.ctx.clearRect(0, 0, canvas.width, canvas.height);
}
/**
* 绘制Header(逻辑像素,尺寸除以3)
*/
async drawHeader() {
this.currentX = 25;
this.currentY = 25;
await drawBaseHeader({
canvas: this.canvas,
ctx: this.ctx,
headerType: this.headerType,
options: {
appName: this.options.appName || '涂鸦丫小程序',
appHint: this.options.appHint || '识字|识图|练字|打印',
title: this.options.title || '看数字,涂一涂',
subTitle:
this.options.subTitle || '找一找下面相同的数字,涂上颜色',
},
onHeaderDrawn: (currentY) => {
this.currentY = currentY;
},
});
}
/**
* 绘制迷你Header(逻辑像素,尺寸除以3)
*/
drawMiniHeader() {
drawBaseMiniHeader({
ctx: this.ctx,
canvasWidth: this.canvasWidth,
options: {
appName: this.options.appName || '涂鸦丫小程序',
title: this.options.title || '看数字,涂一涂',
},
onHeaderDrawn: (currentY) => {
console.log('drawMiniHeader currentY', currentY);
this.currentY = currentY;
},
});
}
/**
* 绘制分割线(逻辑像素,尺寸除以3)
*/
drawDivider() {
const { ctx, canvasWidth } = this;
const dividerY = this.currentY;
ctx.strokeStyle = '#000';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(24, dividerY);
ctx.lineTo(canvasWidth - 24, dividerY);
ctx.stroke();
this.currentY = dividerY + 10; // 分割线下方10px间距
}
/**
* 绘制虚线分割线(逻辑像素,尺寸除以3)
* @param y 分割线的Y坐标
* @param margin 左右边距,默认为40
* @param color 线条颜色,默认为'#999'
* @param lineWidth 线条宽度,默认为1
*/
drawDashedDivider(
y: number,
margin: number = 40,
color: string = '#999',
lineWidth: number = 1,
) {
const { ctx, canvasWidth } = this;
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.setLineDash([4, 4]); // 虚线
ctx.beginPath();
ctx.moveTo(margin, y);
ctx.lineTo(canvasWidth - margin, y);
ctx.stroke();
ctx.setLineDash([]); // 重置为实线
}
/**
* 绘制线条(逻辑像素,尺寸除以3)
* @param x1 起点X坐标
* @param y1 起点Y坐标
* @param x2 终点X坐标
* @param y2 终点Y坐标
* @param options 可选参数
* @param options.isDashed 是否为虚线,默认为true(虚线)
* @param options.dashPattern 虚线模式,默认为[4, 4]
* @param options.color 线条颜色,默认为'#999'
* @param options.lineWidth 线条宽度,默认为1
*/
drawLine(
x1: number,
y1: number,
x2: number,
y2: number,
options?: {
isDashed?: boolean;
dashPattern?: number[];
color?: string;
lineWidth?: number;
},
): void {
const { ctx } = this;
const {
isDashed = true,
dashPattern = [4, 4],
color = '#999',
lineWidth = 1,
} = options || {};
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
// 设置虚线或实线
if (isDashed) {
ctx.setLineDash(dashPattern);
} else {
ctx.setLineDash([]);
}
// 绘制线条
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
// 重置为实线(避免影响后续绘制)
ctx.setLineDash([]);
}
/**
* 绘制网格线(逻辑像素,尺寸除以3)
* @param gridStartX 网格起始X坐标
* @param gridStartY 网格起始Y坐标
* @param cellWidth 每个格子的宽度
* @param cellHeight 每个格子的高度
* @param cols 列数
* @param rows 行数
*/
drawGridLines(
gridStartX: number,
gridStartY: number,
cellWidth: number,
cellHeight: number,
cols: number,
rows: number,
): void {
const { ctx } = this;
// 在函数内部计算网格总宽度和高度
const gridWidth = cellWidth * cols;
const gridHeight = cellHeight * rows;
ctx.strokeStyle = '#000';
ctx.lineWidth = 1;
ctx.setLineDash([]); // 实线
// 绘制垂直线
for (let i = 0; i <= cols; i++) {
const x = gridStartX + i * cellWidth;
ctx.beginPath();
ctx.moveTo(x, gridStartY);
ctx.lineTo(x, gridStartY + gridHeight);
ctx.stroke();
}
// 绘制水平线
for (let i = 0; i <= rows; i++) {
const y = gridStartY + i * cellHeight;
ctx.beginPath();
ctx.moveTo(gridStartX, y);
ctx.lineTo(gridStartX + gridWidth, y);
ctx.stroke();
}
}
/**
* 绘制圆角矩形框(逻辑像素,尺寸除以3)
* @param x 框的X坐标
* @param y 框的Y坐标
* @param width 框的宽度
* @param height 框的高度
* @param options 可选参数
* @param options.isDashed 是否为虚线,默认为false(实线)
* @param options.radius 圆角半径,默认为10
* @param options.color 线条颜色,默认为'#000'
* @param options.lineWidth 线条宽度,默认为1
*/
drawRoundedRect(
x: number,
y: number,
width: number,
height: number,
options?: {
isDashed?: boolean;
radius?: number;
color?: string;
lineWidth?: number;
},
): void {
const { ctx } = this;
const {
isDashed = false,
radius = 10,
color = '#000',
lineWidth = 1,
} = options || {};
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
// 设置虚线或实线
if (isDashed) {
ctx.setLineDash([4, 4]); // 虚线
} else {
ctx.setLineDash([]); // 实线
}
// 绘制圆角矩形
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(
x + width,
y + height,
x + width - radius,
y + height,
);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
ctx.stroke();
// 重置为实线(避免影响后续绘制)
ctx.setLineDash([]);
}
/**
* 绘制符号(使用路径绘制)
* @param x 符号中心X坐标
* @param y 符号中心Y坐标
* @param symbol 符号类型:'+', '-', '=', '×', '✓'
* @param size 符号大小
*/
drawSymbol(x: number, y: number, symbol: string, size: number): void {
const { ctx } = this;
ctx.save();
ctx.translate(x, y);
const lineWidth = size * 0.15; // 线条宽度
const halfSize = size / 2;
const strokeLength = halfSize * 0.7; // 线条长度
ctx.strokeStyle = '#000';
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
switch (symbol) {
case '+':
// 加号:横线和竖线
ctx.beginPath();
// 横线
ctx.moveTo(-strokeLength, 0);
ctx.lineTo(strokeLength, 0);
// 竖线
ctx.moveTo(0, -strokeLength);
ctx.lineTo(0, strokeLength);
ctx.stroke();
break;
case '-':
// 减号:横线
ctx.beginPath();
ctx.moveTo(-strokeLength, 0);
ctx.lineTo(strokeLength, 0);
ctx.stroke();
break;
case '=':
// 等号:两条横线
const equalsSpacing = size * 0.15; // 两条线之间的间距
ctx.beginPath();
// 上横线
ctx.moveTo(-strokeLength, -equalsSpacing);
ctx.lineTo(strokeLength, -equalsSpacing);
// 下横线
ctx.moveTo(-strokeLength, equalsSpacing);
ctx.lineTo(strokeLength, equalsSpacing);
ctx.stroke();
break;
case '×':
// 乘号:两条斜线
ctx.beginPath();
// 左上到右下
ctx.moveTo(-strokeLength * 0.7, -strokeLength * 0.7);
ctx.lineTo(strokeLength * 0.7, strokeLength * 0.7);
// 右上到左下
ctx.moveTo(strokeLength * 0.7, -strokeLength * 0.7);
ctx.lineTo(-strokeLength * 0.7, strokeLength * 0.7);
ctx.stroke();
break;
case '✓':
// 对号:勾,整体更大,右侧的线更长
const checkScale = 1.2; // 对号整体放大1.2倍
ctx.beginPath();
const checkStartX = -strokeLength * 0.5 * checkScale;
const checkStartY = -strokeLength * 0.2 * checkScale;
const checkMidX = -strokeLength * 0.1 * checkScale;
const checkMidY = strokeLength * 0.3 * checkScale;
const checkEndX = strokeLength * 1.0 * checkScale; // 增加右侧长度
const checkEndY = -strokeLength * 0.4 * checkScale; // 稍微向上调整
ctx.moveTo(checkStartX, checkStartY);
ctx.lineTo(checkMidX, checkMidY);
ctx.lineTo(checkEndX, checkEndY);
ctx.stroke();
break;
default:
// 默认绘制加号
ctx.beginPath();
ctx.moveTo(-strokeLength, 0);
ctx.lineTo(strokeLength, 0);
ctx.moveTo(0, -strokeLength);
ctx.lineTo(0, strokeLength);
ctx.stroke();
}
ctx.restore();
}
/**
* 绘制圆点
* @param ctx 绘制上下文
* @param x 圆点中心X坐标
* @param y 圆点中心Y坐标
* @param radius 圆点半径
* @param fillColor 填充颜色,默认为 '#93D333'
* @param strokeColor 边线颜色,如果传入则绘制边线,宽度为1,默认不绘制
*/
drawDot(
ctx: RenderingContext,
x: number,
y: number,
radius: number,
fillColor?: string,
strokeColor?: string,
) {
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
// 绘制填充
ctx.fillStyle = fillColor ?? '#93D333';
ctx.fill();
// 绘制边线(如果提供了边线颜色)
if (strokeColor) {
ctx.strokeStyle = strokeColor;
ctx.lineWidth = 1;
ctx.stroke();
}
}
/**
* 准备绘制(公共初始化逻辑)
* 执行:setPrintConfig -> clear -> setPaper
* 子类可以在调用此方法前后执行自定义逻辑(如数据验证、异步资源加载等)
*/
prepareDraw() {
this.setPrintConfig();
this.clear();
this.setPaper();
}
/**
* 绘制 Header 和 Divider(公共绘制逻辑)
* 根据 headerType 自动选择绘制完整 Header 或迷你 Header,然后绘制分割线
*/
async drawHeaderAndDivider() {
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制内容区域分割线
this.drawDivider();
}
/**
* 绘制空白方框(边框1px,#999,无填充,不显示数字)
*/
drawBox(
ctx: RenderingContext,
x: number,
y: number,
width: number,
height: number,
color: string = '#999',
lineWidth: number = 1,
) {
// 绘制方框边框(1px,#999,无填充)
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.setLineDash([]);
ctx.strokeRect(x, y, width, height);
}
/**
* 绘制正方形方框(调用drawBox,减少参数)
* @param ctx 渲染上下文
* @param x 方框左上角x
* @param y 方框左上角y
* @param size 方框边长
* @param color 边框颜色(可选,默认为#999)
* @param lineWidth 线宽(可选,默认为1
*/
drawSquareBox(
ctx: RenderingContext,
x: number,
y: number,
size: number,
color: string = '#999',
lineWidth: number = 1,
) {
this.drawBox(ctx, x, y, size, size, color, lineWidth);
}
}
@@ -1,129 +0,0 @@
import { getMiniCodeImage, getImage } from '../../utils/index';
/**
* 绘制数学模块页眉的参数接口(尺寸除以3)
*/
interface drawBaseHeaderParams {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
headerType: PrintHeader;
options: {
appName: string;
appHint: string;
title: string;
subTitle: string;
};
onHeaderDrawn?: (currentY: number) => void;
}
/**
* 绘制数学模块完整页眉(尺寸除以3)
*/
export async function drawBaseHeader({
canvas,
ctx,
headerType,
options,
onHeaderDrawn,
}: drawBaseHeaderParams): Promise<void> {
const { appName, appHint, title, subTitle } = options;
let titleX = 108; // 约109.33
const titleY = 25; // 约26.67
const logoX = 24; // 约26.67
const logoY = 20; // 20
const logoWidth = 65; // 约66.67
const logoHeight = 65; // 约66.67
// 根据 headerType 绘制 Logo 或调整标题位置
switch (headerType) {
case 'LogoImage': {
const image = await getImage(
canvas,
'/assets/imgs/doodle-logo.png',
);
ctx.drawImage(image, logoX, logoY, logoWidth, logoHeight);
break;
}
case 'noLogoImage': {
titleX = 40;
break;
}
case 'minimal': {
titleX = 40;
break;
}
default: {
const image = await getMiniCodeImage(canvas);
ctx.drawImage(image, logoX, logoY, logoWidth, logoHeight);
break;
}
}
// 绘制应用名称(字体大小除以3
ctx.font = 'bold 22px "Microsoft Yahei"';
ctx.fillStyle = '#000';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(appName, titleX, titleY);
// 绘制应用提示(字体大小除以3
ctx.font = '16px "Microsoft Yahei"';
ctx.fillStyle = '#666';
ctx.fillText(appHint, titleX, 66);
// 绘制标题(字体大小除以3
ctx.font = 'bold 22px "Microsoft Yahei"';
ctx.fillStyle = '#000';
ctx.fillText(title, 280, titleY);
// 绘制副标题(字体大小除以3
ctx.font = '16px "Microsoft Yahei"';
ctx.fillStyle = '#666';
ctx.fillText(subTitle, 280, 66);
// 调用回调函数
if (onHeaderDrawn) {
onHeaderDrawn(104);
}
}
/**
* 绘制数学模块迷你页眉的参数接口(尺寸除以3)
*/
interface drawBaseMiniHeaderParams {
// canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
options: {
appName: string;
title: string;
};
canvasWidth: number; // 逻辑像素宽度(已除以3
onHeaderDrawn?: (currentY: number) => void;
}
/**
* 绘制数学模块迷你页眉(尺寸除以3)
*/
export function drawBaseMiniHeader({
ctx,
options,
canvasWidth,
onHeaderDrawn,
}: drawBaseMiniHeaderParams): void {
const { appName, title } = options;
const titleY = 46;
const centerX = canvasWidth / 2;
// 字体大小除以3
ctx.font = 'bold 24px "Microsoft Yahei"'; // 64/3
ctx.fillStyle = '#000';
ctx.textAlign = 'center';
ctx.fillText(appName + ' ' + title, centerX, titleY);
// 调用回调函数,传递除以3后的currentY
if (onHeaderDrawn) {
onHeaderDrawn(60); // 约66.67
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* 字体加载工具(core 层,不依赖任何 feature 模块)。
*
* 使用 wx.loadFontFace 将字体注册到 Canvas native 渲染管线,
* 按 URL 去重,同一字体只加载一次。
* 支持 HTTP/HTTPS 直链和 cloud:// 云存储文件 ID。
*/
/** 字体加载所需的最小描述 */
export interface FontFace {
/** wx.loadFontFace 的 family 参数 */
name: string;
/** 字体文件 URLHTTP/HTTPS)或 cloud:// 文件 ID */
url: string;
}
const _loadedIds = new Set<string>();
/**
* 将 cloud:// 文件 ID 解析为临时 HTTPS URL。
* 非 cloud:// 的 URL 原样返回。
*/
async function resolveUrl(raw: string): Promise<string> {
if (!raw.startsWith('cloud://')) return raw;
const res = await wx.cloud.getTempFileURL({ fileList: [raw] });
const file = res.fileList?.[0];
if (file?.tempFileURL) return file.tempFileURL;
throw new Error(`cloud file resolve failed: ${raw}`);
}
/** 加载字体,同一 url 只加载一次 */
export async function loadFontFace(font: FontFace): Promise<void> {
if (_loadedIds.has(font.url)) return;
const resolved = await resolveUrl(font.url);
return new Promise((resolve, reject) => {
wx.loadFontFace({
family: font.name,
source: `url("${resolved}")`,
scopes: ['native'],
success: () => {
_loadedIds.add(font.url);
resolve();
},
fail: (err) => {
console.error(`loadFontFace [${font.name}] failed`, err);
reject(err);
},
});
});
}
+264
View File
@@ -0,0 +1,264 @@
/**
* 字体渲染配置(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 {
/** 唯一标识,同时用作 wx.loadFontFace 的 family 和按名称查找的 key */
name: string;
/** 字体 URL */
url: string;
/** 各分类的默认渲染参数 */
categories: Record<LetterCategory, CategoryMetrics>;
/** 按字母精细微调(优先级高于分类默认值) */
letterOverrides?: Record<string, LetterOverride>;
}
/** 构建 CSS font-family 串(含回退字体) */
export function fontFamilyOf(profile: FontProfile): string {
return `${profile.name}, Roboto, sans-serif`;
}
const CLOUD_PREFIX =
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/';
// ── 字母分类集合 ──
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 = {
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.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.8, baselineOffset: 0 },
f: { scale: 1, baselineOffset: 0.05 },
t: { scale: 0.85, baselineOffset: 0 },
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.68, baselineOffset: 0 },
descender: { scale: 0.68, baselineOffset: 0 },
lowercase: { scale: 0.68, baselineOffset: 0 },
},
letterOverrides: {
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: 1, baselineOffset: 0 },
tallLower: { scale: 1, baselineOffset: 0 },
descender: { scale: 1, baselineOffset: 0 },
lowercase: { scale: 1, baselineOffset: 0 },
},
letterOverrides: {
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: 1, baselineOffset: 0 },
tallLower: { scale: 1, baselineOffset: 0 },
descender: { scale: 1, baselineOffset: 0 },
lowercase: { scale: 1, baselineOffset: 0 },
},
letterOverrides: {
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: 1, baselineOffset: 0 },
tallLower: { scale: 1, baselineOffset: 0 },
descender: { scale: 1, baselineOffset: 0 },
lowercase: { scale: 1, baselineOffset: 0 },
},
letterOverrides: {
i: { scale: 1, baselineOffset: 0 },
f: { scale: 1, baselineOffset: 0 },
t: { scale: 1, baselineOffset: 0 },
j: { scale: 1, baselineOffset: 0 },
},
};
/** ToneOZ Pinyin 拼音字体(含声调字母、ü 变体等专用字形) */
export const TONEOZ_PINYIN: FontProfile = {
name: 'ToneOZPinyin',
url: `${CLOUD_PREFIX}assets/fonts/ToneOZ-Pinyin-Regular.ttf`,
categories: {
uppercase: { scale: 1, baselineOffset: 0 },
tallLower: { scale: 1, baselineOffset: 0 },
descender: { scale: 1, baselineOffset: 0 },
lowercase: { 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 },
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 },
},
};
// ── 字体注册表:按 name 索引所有可用字体 ──
const FONT_PROFILES: ReadonlyArray<FontProfile> = [
ZHIYONG_WRITE,
HANDLEE_REGULAR,
PRINT_CLEARLY_REGULAR,
PRINT_CLEARLY_BOLD,
PRINT_CLEARLY_DASHED,
NUM_FONT,
TONEOZ_PINYIN,
];
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;
}