diff --git a/backup/fonts-1/LXGWWenKai-Regular.ttf b/backup/fonts-1/LXGWWenKai-Regular.ttf new file mode 100644 index 0000000..3e050e8 Binary files /dev/null and b/backup/fonts-1/LXGWWenKai-Regular.ttf differ diff --git a/backup/fonts-1/ToneOZ-Pinyin-Tsuipita-TC.ttf b/backup/fonts-1/ToneOZ-Pinyin-Tsuipita-TC.ttf new file mode 100644 index 0000000..f1e035e Binary files /dev/null and b/backup/fonts-1/ToneOZ-Pinyin-Tsuipita-TC.ttf differ diff --git a/backup/fonts-1/ToneOZ-Pinyin-WenKai-Regular.ttf b/backup/fonts-1/ToneOZ-Pinyin-WenKai-Regular.ttf new file mode 100644 index 0000000..6df3756 Binary files /dev/null and b/backup/fonts-1/ToneOZ-Pinyin-WenKai-Regular.ttf differ diff --git a/backup/fonts-1/ToneOZ-Tsuipita-TC.ttf b/backup/fonts-1/ToneOZ-Tsuipita-TC.ttf new file mode 100644 index 0000000..23fa1dc Binary files /dev/null and b/backup/fonts-1/ToneOZ-Tsuipita-TC.ttf differ diff --git a/docs/描红样式规范.md b/docs/描红样式规范.md new file mode 100644 index 0000000..1fa0432 --- /dev/null +++ b/docs/描红样式规范.md @@ -0,0 +1,94 @@ +# 描红/练字样式规范 + +> 配置文件:`miniprogram/core/data/tracingStyles.ts` + +## 设计原则 + +- **格子线条**:使用护眼绿色系,打印清晰且长时间书写不疲劳 +- **描红字体**:使用中性灰色,不带色彩偏向,打印友好 +- **统一管理**:所有绘制服务引用 `tracingStyles.ts`,避免硬编码 + +--- + +## 格子线条颜色 + +适用于田字格(汉字)、四线三格(拼音/英文字母)。 + +| 用途 | 色值 | 色块 | 说明 | +|------|------|------|------| +| 外框/实线 | `#7fb069` | 🟩 中等绿 | 外框、上下实线、垂直分隔线 | +| 中线/虚线 | `#a8d5a8` | 🟩 浅绿 | 田字格十字线、四线三格中间两条虚线 | +| 对角线 | `#d4f0d4` | ⬜ 极淡绿 | 田字格对角辅助线(四线三格不使用) | + +### 四线三格结构 + +``` +───────────────── ← 上线(实线,#7fb069) +- - - - - - - - - ← 中上线(虚线,#a8d5a8) +- - - - - - - - - ← 中下线(虚线,#a8d5a8) +───────────────── ← 下线(实线,#7fb069) +│ │ ← 垂直分隔线(实线,#7fb069) +``` + +### 田字格结构 + +``` +┌───────┬───────┐ ← 外框(#7fb069) +│ ╲ │ ╱ │ +│─ ─ ─ ─│─ ─ ─ ─│ ← 横中线(#a8d5a8) +│ ╱ │ ╲ │ 对角线(#d4f0d4) +└───────┴───────┘ +``` + +虚线样式:`[3, 3]` + +--- + +## 描红字体颜色 + +适用于汉字描红、拼音描红、英文字母描红。 + +| 用途 | 色值 | 色块 | 说明 | +|------|------|------|------| +| 参照字/预览字 | `#555555` | ⬛ 深灰 | 完整展示字形供对照,如首格预览 | +| 描红引导字 | `#d0d0d0` | ⬜ 中性灰 | 跟着描写的半透明字,标准描红色 | +| 极浅引导字 | `#e0e0e0` | ⬜ 浅灰 | 水印效果,更轻的引导 | +| 首笔/强调 | `#1a1a1a` | ⬛ 近黑 | 参照格首字母,黑色标示 | + +### 颜色梯度示意 + +``` +首笔参照 → 参照字 → 描红引导 → 极浅引导 → 空白 +#1a1a1a #555555 #d0d0d0 #e0e0e0 (无) +████████ ▓▓▓▓▓▓▓▓ ░░░░░░░░ ░░░░░░░░ +``` + +--- + +## 使用方式 + +```typescript +import { GRID_COLORS, GRID_DASH, TRACING_COLORS } from '../../core/data/tracingStyles'; + +// 格子线条 +ctx.strokeStyle = GRID_COLORS.border; // 外框 +ctx.strokeStyle = GRID_COLORS.middleLine; // 虚线 +ctx.strokeStyle = GRID_COLORS.diagonal; // 对角线 +ctx.setLineDash([...GRID_DASH]); // 虚线样式 + +// 描红字体 +ctx.fillStyle = TRACING_COLORS.reference; // 预览字 +ctx.fillStyle = TRACING_COLORS.guide; // 描红引导 +ctx.fillStyle = TRACING_COLORS.guideLight; // 极浅引导 +ctx.fillStyle = TRACING_COLORS.strong; // 首笔黑色 +``` + +## 应用范围 + +| 绘制服务 | 文件路径 | +|---------|---------| +| 拼音描红 | `pinyinPages/pinyinDictation/draw/pinyinDictationDraw.ts` | +| 田字格练字 | `service/wordDrawService.ts` | +| 英文字母描红 | `englishPages/shared/draw/drawTools.ts` | +| 英文各模式 | `englishPages/letterTracing/draw/*.ts` | +| 英文数据生成 | `englishPages/letterTracing/generators/letter-tracing-generator.ts` | diff --git a/miniprogram/app.json b/miniprogram/app.json index 9cbcb8c..e9ac2ea 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -41,6 +41,12 @@ "pages": ["focusDraw/focusDraw", "shape/shape"], "independent": false }, + { + "root": "pinyinPages", + "name": "pinyinPages", + "pages": ["pinyinDictation/pinyinDictation"], + "independent": false + }, { "root": "chinesePages", "name": "chinesePages", diff --git a/miniprogram/service/wordDrawService.ts b/miniprogram/chinesePages/handwritingSheet/draw/wordDrawService.ts similarity index 93% rename from miniprogram/service/wordDrawService.ts rename to miniprogram/chinesePages/handwritingSheet/draw/wordDrawService.ts index 5619ed0..f429a7b 100644 --- a/miniprogram/service/wordDrawService.ts +++ b/miniprogram/chinesePages/handwritingSheet/draw/wordDrawService.ts @@ -8,8 +8,9 @@ * - letter:字母 * - symbol:符号 */ -import { BaseDrawService } from '../core/draw/baseDraw'; -import { CharacterItem } from '../types/characterType'; +import { BaseDrawService } from '../../../core/draw/baseDraw'; +import { GRID_COLORS, TRACING_COLORS } from '../../../core/data/tracingStyles'; +import { CharacterItem } from '../../../types/characterType'; /** * cnchar-data 坐标系配置 @@ -210,29 +211,24 @@ function drawTianZiGrid({ x, y, size, - lineColor = '#a8d5a8', // 浅绿色(中线) - boldColor = '#7fb069', // 中等绿色(外框) + lineColor = GRID_COLORS.middleLine, + boldColor = GRID_COLORS.border, }: DrawTianZiGridParams) { - // 外框(逻辑像素)- 使用中等绿色,清晰可见 ctx.strokeStyle = boldColor; - ctx.lineWidth = 1; // 2/3≈1,逻辑像素 + ctx.lineWidth = 1; ctx.strokeRect(x - size / 2, y - size / 2, size, size); - // 中线(逻辑像素)- 使用浅绿色,柔和护眼 ctx.strokeStyle = lineColor; - ctx.lineWidth = 1; // 逻辑像素 + ctx.lineWidth = 1; ctx.beginPath(); - // 竖线 ctx.moveTo(x, y - size / 2); ctx.lineTo(x, y + size / 2); - // 横线 ctx.moveTo(x - size / 2, y); ctx.lineTo(x + size / 2, y); ctx.stroke(); ctx.closePath(); - // 对角线(淡绿色,逻辑像素)- 使用很淡的绿色,提供辅助参考线 - ctx.strokeStyle = '#d4f0d4'; // 很淡的绿色 + ctx.strokeStyle = GRID_COLORS.diagonal; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x - size / 2, y - size / 2); @@ -544,9 +540,9 @@ class WordDrawService extends BaseDrawService { offsetY: y, size: cellSize, uptoInclusive: strokes.length - 1, - fillStyle: 'rgb(85,85,85)', // 深灰色填充(#555555),比#666666更深一点 - strokeStyle: 'rgb(85,85,85)', // 深灰色描边 - lineWidth: 1.2, // Fill + Stroke 模式,线条宽度调小一点(逻辑像素) + fillStyle: TRACING_COLORS.reference, + strokeStyle: TRACING_COLORS.reference, + lineWidth: 1.2, }); } @@ -590,9 +586,9 @@ class WordDrawService extends BaseDrawService { offsetY: y, size: cellSize, uptoInclusive: strokeIndex, - fillStyle: 'rgb(170,170,170)', // 浅灰色填充(#aaaaaa),参考练字贴颜色 - strokeStyle: 'rgb(170,170,170)', // 浅灰色描边 - lineWidth: 1.2, // Fill + Stroke 模式,线条宽度调小一点(逻辑像素) + fillStyle: TRACING_COLORS.guide, + strokeStyle: TRACING_COLORS.guide, + lineWidth: 1.2, }); } diff --git a/miniprogram/chinesePages/handwritingSheet/handwritingSheet.ts b/miniprogram/chinesePages/handwritingSheet/handwritingSheet.ts index a3ddd86..4563220 100644 --- a/miniprogram/chinesePages/handwritingSheet/handwritingSheet.ts +++ b/miniprogram/chinesePages/handwritingSheet/handwritingSheet.ts @@ -1,4 +1,4 @@ -import WordDrawService from '../../service/wordDrawService'; +import WordDrawService from './draw/wordDrawService'; import { downloadPrint } from '../../utils/downloadPrint'; import { getWordsSvgData } from '../../utils/getWordsSvgJson'; import { WORDS } from '../../core/data/words'; diff --git a/miniprogram/chinesePages/shared/draw/drawServiceFactory.ts b/miniprogram/chinesePages/wordColoring/draw/drawServiceFactory.ts similarity index 85% rename from miniprogram/chinesePages/shared/draw/drawServiceFactory.ts rename to miniprogram/chinesePages/wordColoring/draw/drawServiceFactory.ts index bdb7920..6066869 100644 --- a/miniprogram/chinesePages/shared/draw/drawServiceFactory.ts +++ b/miniprogram/chinesePages/wordColoring/draw/drawServiceFactory.ts @@ -7,11 +7,11 @@ * - find:找字模板 * * 支持的绘制服务: - * - TextDrawService:文字涂色服务 + * - WordColoringDraw:文字涂色服务 * - FindWordDrawService:找字涂色服务 */ -import TextDrawService from './textDrawService'; +import WordColoringDraw from './wordColoringDraw'; import FindWordDrawService from './findWordDrawService'; /** @@ -49,12 +49,12 @@ export class DrawServiceFactory { ): IDrawService { switch (templateType) { case 'grid': - return new TextDrawService(canvas, ctx, options); + return new WordColoringDraw(canvas, ctx, options); case 'find': return new FindWordDrawService(canvas, ctx, options); default: // 默认使用网格模板 - return new TextDrawService(canvas, ctx, options); + return new WordColoringDraw(canvas, ctx, options); } } } diff --git a/miniprogram/chinesePages/shared/draw/findWordDrawService.ts b/miniprogram/chinesePages/wordColoring/draw/findWordDrawService.ts similarity index 100% rename from miniprogram/chinesePages/shared/draw/findWordDrawService.ts rename to miniprogram/chinesePages/wordColoring/draw/findWordDrawService.ts diff --git a/miniprogram/chinesePages/shared/draw/findWordTemplate.ts b/miniprogram/chinesePages/wordColoring/draw/findWordTemplate.ts similarity index 100% rename from miniprogram/chinesePages/shared/draw/findWordTemplate.ts rename to miniprogram/chinesePages/wordColoring/draw/findWordTemplate.ts diff --git a/miniprogram/chinesePages/shared/draw/textDrawService.ts b/miniprogram/chinesePages/wordColoring/draw/wordColoringDraw.ts similarity index 98% rename from miniprogram/chinesePages/shared/draw/textDrawService.ts rename to miniprogram/chinesePages/wordColoring/draw/wordColoringDraw.ts index 7141b7f..3880ccb 100644 --- a/miniprogram/chinesePages/shared/draw/textDrawService.ts +++ b/miniprogram/chinesePages/wordColoring/draw/wordColoringDraw.ts @@ -48,7 +48,7 @@ function calculateCircleCenters( return centers; } -class TextDrawService extends BaseDrawService { +class WordColoringDrawService extends BaseDrawService { colors: string[]; characters: string[]; @@ -204,4 +204,4 @@ class TextDrawService extends BaseDrawService { } } -export default TextDrawService; +export default WordColoringDrawService; diff --git a/miniprogram/chinesePages/wordColoring/wordColoring.ts b/miniprogram/chinesePages/wordColoring/wordColoring.ts index 02672d7..a35cd19 100644 --- a/miniprogram/chinesePages/wordColoring/wordColoring.ts +++ b/miniprogram/chinesePages/wordColoring/wordColoring.ts @@ -3,7 +3,7 @@ import { WORDS } from '../../core/data/words'; import { DrawServiceFactory, type IDrawService, -} from '../shared/draw/drawServiceFactory'; +} from './draw/drawServiceFactory'; import { createPage, type CanvasDataState } from '../../base/pageMixin'; import { defaultShareConfig } from '../../config/config'; import { diff --git a/miniprogram/core/data/pinyin.ts b/miniprogram/core/data/pinyin.ts new file mode 100644 index 0000000..bb1bc92 --- /dev/null +++ b/miniprogram/core/data/pinyin.ts @@ -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, + }, +]; diff --git a/miniprogram/core/data/tracingStyles.ts b/miniprogram/core/data/tracingStyles.ts new file mode 100644 index 0000000..8fb590a --- /dev/null +++ b/miniprogram/core/data/tracingStyles.ts @@ -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; diff --git a/miniprogram/core/draw/backup/baseDraw.ts b/miniprogram/core/draw/backup/baseDraw.ts deleted file mode 100644 index 111f331..0000000 --- a/miniprogram/core/draw/backup/baseDraw.ts +++ /dev/null @@ -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; - paperSize: PaperSize; - currentX: number; - currentY: number; - canvasWidth: number; // 逻辑像素宽度 - canvasHeight: number; // 逻辑像素高度 - - constructor( - canvas: Canvas, - ctx: RenderingContext, - options?: Record, - ) { - 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); - } -} diff --git a/miniprogram/core/draw/backup/baseHeaderDraw.ts b/miniprogram/core/draw/backup/baseHeaderDraw.ts deleted file mode 100644 index 4428e1e..0000000 --- a/miniprogram/core/draw/backup/baseHeaderDraw.ts +++ /dev/null @@ -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 { - 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 - } -} diff --git a/miniprogram/core/font/fontLoader.ts b/miniprogram/core/font/fontLoader.ts new file mode 100644 index 0000000..a2024bc --- /dev/null +++ b/miniprogram/core/font/fontLoader.ts @@ -0,0 +1,52 @@ +/** + * 字体加载工具(core 层,不依赖任何 feature 模块)。 + * + * 使用 wx.loadFontFace 将字体注册到 Canvas native 渲染管线, + * 按 URL 去重,同一字体只加载一次。 + * 支持 HTTP/HTTPS 直链和 cloud:// 云存储文件 ID。 + */ + +/** 字体加载所需的最小描述 */ +export interface FontFace { + /** wx.loadFontFace 的 family 参数 */ + name: string; + /** 字体文件 URL(HTTP/HTTPS)或 cloud:// 文件 ID */ + url: string; +} + +const _loadedIds = new Set(); + +/** + * 将 cloud:// 文件 ID 解析为临时 HTTPS URL。 + * 非 cloud:// 的 URL 原样返回。 + */ +async function resolveUrl(raw: string): Promise { + 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 { + 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); + }, + }); + }); +} diff --git a/miniprogram/englishPages/shared/data/fontProfiles.ts b/miniprogram/core/font/fontProfiles.ts similarity index 93% rename from miniprogram/englishPages/shared/data/fontProfiles.ts rename to miniprogram/core/font/fontProfiles.ts index ef01b63..b9c521c 100644 --- a/miniprogram/englishPages/shared/data/fontProfiles.ts +++ b/miniprogram/core/font/fontProfiles.ts @@ -48,6 +48,9 @@ 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']); @@ -207,6 +210,18 @@ export const PRINT_CLEARLY_DASHED: FontProfile = { }, }; +/** 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', @@ -233,6 +248,7 @@ const FONT_PROFILES: ReadonlyArray = [ PRINT_CLEARLY_BOLD, PRINT_CLEARLY_DASHED, NUM_FONT, + TONEOZ_PINYIN, ]; const _profileByName = new Map( diff --git a/miniprogram/englishPages/letterTracing/draw/casePairingDraw.ts b/miniprogram/englishPages/letterTracing/draw/casePairingDraw.ts index 6796e7f..b0a4ef6 100644 --- a/miniprogram/englishPages/letterTracing/draw/casePairingDraw.ts +++ b/miniprogram/englishPages/letterTracing/draw/casePairingDraw.ts @@ -3,7 +3,7 @@ import type { LetterTracingData, TracingRow, } from '../generators/letter-tracing-generator'; -import { fontFamilyOf } from '../../shared/data/fontProfiles'; +import { fontFamilyOf } from '../../../core/font/fontProfiles'; import { calcLetterFontSizes, drawFourLineGrid, @@ -11,8 +11,10 @@ import { FOUR_LINE_GRID_H, type LetterFontSizes, } from '../../shared/draw/drawTools'; -const COLOR_BLACK = '#1a1a1a'; -const COLOR_LIGHT_RED = '#FF8E8E'; +import { TRACING_COLORS } from '../../../core/data/tracingStyles'; + +const COLOR_BLACK = TRACING_COLORS.strong; +const COLOR_LIGHT_RED = TRACING_COLORS.guide; /** 左右大写/小写两栏之间的间距(相对行宽 + 下限,避免过窄屏过小) */ const COLUMN_GUTTER_RATIO = 0.02; diff --git a/miniprogram/englishPages/letterTracing/draw/dailyCheckinDraw.ts b/miniprogram/englishPages/letterTracing/draw/dailyCheckinDraw.ts index 0c30e62..0e6cada 100644 --- a/miniprogram/englishPages/letterTracing/draw/dailyCheckinDraw.ts +++ b/miniprogram/englishPages/letterTracing/draw/dailyCheckinDraw.ts @@ -8,7 +8,7 @@ import { PRINT_CLEARLY_DASHED, PRINT_CLEARLY_REGULAR, fontFamilyOf, -} from '../../shared/data/fontProfiles'; +} from '../../../core/font/fontProfiles'; import { calcLetterFontSizes, drawClosedFourLineGrid, @@ -53,15 +53,13 @@ const GRID_COL_GAP = 6; // 格子行间距(px) const GRID_ROW_GAP = 8; -// 网格描边主色 -const GRID_BORDER = '#9AA9A2'; -// 网格中间线虚线颜色 -const GRID_MIDDLE = 'rgba(154, 169, 162, 0.65)'; +import { GRID_COLORS, TRACING_COLORS } from '../../../core/data/tracingStyles'; -// 黑色文本主色 -const COLOR_BLACK = '#1a1a1a'; -// 数据红色(例:未签到状态) -const COLOR_LIGHT_RED = '#FF8E8E'; +const GRID_BORDER = GRID_COLORS.border; +const GRID_MIDDLE = GRID_COLORS.middleLine; + +const COLOR_BLACK = TRACING_COLORS.strong; +const COLOR_LIGHT_RED = TRACING_COLORS.guide; export default class DailyCheckinDraw extends BaseDrawService { async draw(data: DailyCheckinData) { diff --git a/miniprogram/englishPages/letterTracing/draw/halfLetterDraw.ts b/miniprogram/englishPages/letterTracing/draw/halfLetterDraw.ts index 13668d7..750a901 100644 --- a/miniprogram/englishPages/letterTracing/draw/halfLetterDraw.ts +++ b/miniprogram/englishPages/letterTracing/draw/halfLetterDraw.ts @@ -6,7 +6,7 @@ import type { import { fontFamilyOf, PRINT_CLEARLY_DASHED, -} from '../../shared/data/fontProfiles'; +} from '../../../core/font/fontProfiles'; import { calcLetterFontSizes, drawFourLineGrid, diff --git a/miniprogram/englishPages/letterTracing/draw/singleLetterDraw.ts b/miniprogram/englishPages/letterTracing/draw/singleLetterDraw.ts index 44eccf2..7b66209 100644 --- a/miniprogram/englishPages/letterTracing/draw/singleLetterDraw.ts +++ b/miniprogram/englishPages/letterTracing/draw/singleLetterDraw.ts @@ -8,7 +8,7 @@ import { fontFamilyOf, PRINT_CLEARLY_BOLD, PRINT_CLEARLY_DASHED, -} from '../../shared/data/fontProfiles'; +} from '../../../core/font/fontProfiles'; import { calcLetterFontSizes, drawFourLineGrid, @@ -18,8 +18,9 @@ import { loadLetterFont, type LetterFontSizes, } from '../../shared/draw/drawTools'; +import { GRID_COLORS, TRACING_COLORS } from '../../../core/data/tracingStyles'; -const LINE_INK_ALPHA = 'rgba(50, 46, 37, 1)'; +const LINE_INK_ALPHA = GRID_COLORS.border; const ACCENT_RED = 'rgba(252, 46, 0, 1)'; const TITLE_TEXT_FONT = '14px sans-serif'; const TITLE_ICON_GAP = 4; @@ -167,10 +168,10 @@ export default class SingleLetterDraw extends BaseDrawService { const groupsPerLine = [8, 6, 3, 1]; const slotW = lineW / groupsPerLine[0]; // 以第一行的 8 组为基准 const lineStyles = [ - { color: 'rgba(26, 26, 26, 1)', alpha: 1, dashed: false }, - { color: '#FF8E8E', alpha: 1, dashed: false }, - { color: '#FF8E8E', alpha: 0.5, dashed: false }, - { color: '#FF8E8E', alpha: 0.5, dashed: true }, + { color: TRACING_COLORS.strong, alpha: 1, dashed: false }, + { color: TRACING_COLORS.guide, alpha: 1, dashed: false }, + { color: TRACING_COLORS.guide, alpha: 0.5, dashed: false }, + { color: TRACING_COLORS.guide, alpha: 0.5, dashed: true }, ] as const; for (let r = 0; r < lineTops.length; r++) { diff --git a/miniprogram/englishPages/letterTracing/draw/tripleDraw.ts b/miniprogram/englishPages/letterTracing/draw/tripleDraw.ts index 460ff88..342ba30 100644 --- a/miniprogram/englishPages/letterTracing/draw/tripleDraw.ts +++ b/miniprogram/englishPages/letterTracing/draw/tripleDraw.ts @@ -7,7 +7,7 @@ import type { import { fontFamilyOf, PRINT_CLEARLY_DASHED, -} from '../../shared/data/fontProfiles'; +} from '../../../core/font/fontProfiles'; import { calcLetterFontSizes, drawFourLineGrid, diff --git a/miniprogram/englishPages/letterTracing/draw/twoColumnDraw.ts b/miniprogram/englishPages/letterTracing/draw/twoColumnDraw.ts index 9fae8f7..8c73de5 100644 --- a/miniprogram/englishPages/letterTracing/draw/twoColumnDraw.ts +++ b/miniprogram/englishPages/letterTracing/draw/twoColumnDraw.ts @@ -3,7 +3,7 @@ import type { LetterTracingData, TracingRow, } from '../generators/letter-tracing-generator'; -import { fontFamilyOf } from '../../shared/data/fontProfiles'; +import { fontFamilyOf } from '../../../core/font/fontProfiles'; import { calcLetterFontSizes, drawFourLineGrid, @@ -12,8 +12,10 @@ import { type LetterFontSizes, } from '../../shared/draw/drawTools'; -const COLOR_BLACK = '#1a1a1a'; -const COLOR_LIGHT_RED = '#FF8E8E'; +import { TRACING_COLORS } from '../../../core/data/tracingStyles'; + +const COLOR_BLACK = TRACING_COLORS.strong; +const COLOR_LIGHT_RED = TRACING_COLORS.guide; const COLUMN_GUTTER = 18; type TwoColumnData = Extract< diff --git a/miniprogram/englishPages/letterTracing/draw/upperLowerDraw.ts b/miniprogram/englishPages/letterTracing/draw/upperLowerDraw.ts index f869e59..4c3fd2f 100644 --- a/miniprogram/englishPages/letterTracing/draw/upperLowerDraw.ts +++ b/miniprogram/englishPages/letterTracing/draw/upperLowerDraw.ts @@ -4,7 +4,7 @@ import { PRINT_CLEARLY_BOLD, PRINT_CLEARLY_DASHED, fontFamilyOf, -} from '../../shared/data/fontProfiles'; +} from '../../../core/font/fontProfiles'; import { calcLetterFontSizes, drawFourLineGrid, @@ -13,26 +13,21 @@ import { loadLetterFont, type LetterFontSizes, } from '../../shared/draw/drawTools'; +import { TRACING_COLORS } from '../../../core/data/tracingStyles'; type UpperLowerData = Extract< LetterTracingData, { mode: 'letter-tracing-upper-lower' } >; -/** 页面左右边距(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 TRACE_LIGHT_RED = TRACING_COLORS.guide; const DASHED_PROFILE = PRINT_CLEARLY_DASHED; const TITLE_PROFILE = PRINT_CLEARLY_BOLD; diff --git a/miniprogram/englishPages/letterTracing/generators/letter-tracing-generator.ts b/miniprogram/englishPages/letterTracing/generators/letter-tracing-generator.ts index c8b4261..efbc0f2 100644 --- a/miniprogram/englishPages/letterTracing/generators/letter-tracing-generator.ts +++ b/miniprogram/englishPages/letterTracing/generators/letter-tracing-generator.ts @@ -1,4 +1,5 @@ import { ALPHABET_BY_LETTER, LETTERS_UPPER } from '../../shared/data/alphabet'; +import { TRACING_COLORS } from '../../../core/data/tracingStyles'; /** 字母描红的布局模式(与产品文档六类练习一致) */ export type LetterTracingMode = @@ -227,8 +228,8 @@ function overviewRowsFrom(letters: string[]): string[][] { ]; } -const _COLOR_BLACK = '#1a1a1a'; -const _COLOR_LIGHT_RED = '#FF8E8E'; +const _COLOR_BLACK = TRACING_COLORS.strong; +const _COLOR_LIGHT_RED = TRACING_COLORS.guide; /** 构建 6 slot 的单行字母数据:前 4 格有字母(颜色递进),后 2 格空白 */ function buildSingleLineCells(L: string): (SingleLineCell | null)[] { diff --git a/miniprogram/englishPages/letterTracing/letterTracing.ts b/miniprogram/englishPages/letterTracing/letterTracing.ts index fc86883..ec4ace1 100644 --- a/miniprogram/englishPages/letterTracing/letterTracing.ts +++ b/miniprogram/englishPages/letterTracing/letterTracing.ts @@ -19,10 +19,14 @@ import { DEFAULT_LETTER_PROFILE, getFontProfile, type FontProfile, -} from '../shared/data/fontProfiles'; +} from '../../core/font/fontProfiles'; import { loadLetterFont } from '../shared/draw/drawTools'; import type { DebugPublishMeta } from '../../utils/debugPublish'; -import { addFavorite, removeFavorite, batchCheckFavorited } from '../../utils/favorites'; +import { + addFavorite, + removeFavorite, + batchCheckFavorited, +} from '../../utils/favorites'; /** 三字母精练分组:26 字母每 3 个一组 */ const TRIPLE_GROUPS: { title: string; letters: string[] }[] = []; diff --git a/miniprogram/englishPages/shared/draw/drawTools.ts b/miniprogram/englishPages/shared/draw/drawTools.ts index 11cdf15..293f85c 100644 --- a/miniprogram/englishPages/shared/draw/drawTools.ts +++ b/miniprogram/englishPages/shared/draw/drawTools.ts @@ -7,14 +7,14 @@ import { fontFamilyOf, getLetterMetrics, type FontProfile, -} from '../data/fontProfiles'; +} from '../../../core/font/fontProfiles'; +import { GRID_COLORS, GRID_DASH } from '../../../core/data/tracingStyles'; +import { loadFontFace } from '../../../core/font/fontLoader'; // ── 四线三格统一高度(所有页面共用) ── export const FOUR_LINE_GRID_H = 43; -// ── 字体加载(支持多字体,按 URL 去重) ── - -const _loadedUrls = new Set(); +// ── 字体加载(委托到 core/font/fontLoader) ── /** * 加载字体到 Canvas native 渲染管线。 @@ -23,22 +23,7 @@ const _loadedUrls = new Set(); export function loadLetterFont( profile: FontProfile = DEFAULT_LETTER_PROFILE, ): Promise { - if (_loadedUrls.has(profile.url)) return Promise.resolve(); - return new Promise((resolve, reject) => { - wx.loadFontFace({ - family: profile.name, - source: `url("${profile.url}")`, - scopes: ['native'], - success: () => { - _loadedUrls.add(profile.url); - resolve(); - }, - fail: (err) => { - console.error(`loadLetterFont [${profile.name}] failed`, err); - reject(err); - }, - }); - }); + return loadFontFace(profile); } // ── LetterFontSizes:持有 FontProfile 引用,运行时按字母查表 ── @@ -74,7 +59,7 @@ function getLetterBaselineOffset( } // ── 向后兼容:导出 DESCENDERS 集合 ── -export { DESCENDERS } from '../data/fontProfiles'; +export { DESCENDERS } from '../../../core/font/fontProfiles'; /** * 在四线三格中绘制单个字母(基线固定在 y + 2h/3 + baselineOffset)。 @@ -138,10 +123,10 @@ export function drawFourLineGrid( h: number, style?: FourLineGridStyle, ) { - const ink = style?.ink ?? '#322E25'; - const middleInk = style?.middleInk ?? 'rgba(50, 46, 37, 0.3)'; + const ink = style?.ink ?? GRID_COLORS.border; + const middleInk = style?.middleInk ?? GRID_COLORS.middleLine; const lineWidth = style?.lineWidth ?? 1; - const dash = style?.dash ?? [4, 4]; + const dash = style?.dash ?? [...GRID_DASH]; const yTop = y; const y1 = y + h / 3; @@ -188,10 +173,10 @@ export function drawClosedFourLineGrid( h: number, style?: FourLineGridStyle, ) { - const ink = style?.ink ?? '#9AA9A2'; - const middleInk = style?.middleInk ?? 'rgba(154, 169, 162, 0.65)'; + const ink = style?.ink ?? GRID_COLORS.border; + const middleInk = style?.middleInk ?? GRID_COLORS.middleLine; const lineWidth = style?.lineWidth ?? 1; - const dash = style?.dash ?? [3, 3]; + const dash = style?.dash ?? [...GRID_DASH]; const y1 = y + h / 3; const y2 = y + (h * 2) / 3; diff --git a/miniprogram/pinyinPages/pinyinDictation/draw/pinyinDictationDraw.ts b/miniprogram/pinyinPages/pinyinDictation/draw/pinyinDictationDraw.ts new file mode 100644 index 0000000..9a3fd20 --- /dev/null +++ b/miniprogram/pinyinPages/pinyinDictation/draw/pinyinDictationDraw.ts @@ -0,0 +1,327 @@ +import { BaseDrawService } from '../../../core/draw/baseDraw'; +import { + PINYIN_SECTIONS, + type PinyinSection, + type PinyinSubCategory, +} from '../../../core/data/pinyin'; +import { + GRID_COLORS, + GRID_DASH, + TRACING_COLORS, +} from '../../../core/data/tracingStyles'; +import type { PinyinDictationMode } from '../pinyinDictation.config'; +import { TONEOZ_PINYIN, fontFamilyOf } from '../../../core/font/fontProfiles'; + +export interface PinyinDictationData { + mode: PinyinDictationMode; +} + +// ── 布局 ── + +const MARGIN_X = 24; // 左右页边距 +const MIN_CELL_W = 44; // 格子最小宽度,实际宽度按内容区等分后可能略大 +const CELL_H = 30; // 四线三格高度 +const ROW_GAP = 5; // 同一分类中,行与行之间的纵向间距 +const SUB_INDENT = 20; // 子分类(翘舌音、单韵母等)相对 MARGIN_X 的额外缩进 + +// ── 垂直间距 ── + +const CONTENT_TOP_GAP = 8; // Header 分割线到第一个分类标题的间距 +const TITLE_AFTER_GAP = 24; // 分类标题(一、声母)到其下方格子的间距 +const SECTION_GAP = 8; // 大分类之间(声母 ↔ 韵母 ↔ 整体认读)的间距 +const SECTION_TO_SUB_GAP = 8; // 主格子到「其中——」的间距 +const QIZHONG_GAP = 16; // 「其中——」到第一个子分类块的间距 +const SUB_LABEL_H = 14; // 子分类标签文字占用高度 +const SUB_LABEL_GAP = 4; // 子分类标签到其下方格子的间距 +const SUB_BLOCK_GAP = 8; // 相邻子分类块之间的间距 + +// ── 字体 & 颜色 ── + +const SECTION_TITLE_FONT = 'bold 14px "Microsoft Yahei"'; // 大分类标题 +const SUB_LABEL_FONT = '11px "Microsoft Yahei"'; // 子分类标签 +const SECTION_TITLE_COLOR = '#322E25'; // 标题/标签文字色 +const SUB_LABEL_COLOR = '#c0392b'; // 「其中——」文字色 + +export default class PinyinDictationDraw extends BaseDrawService { + private cellW = MIN_CELL_W; + private contentW = 0; + + async draw(data: PinyinDictationData) { + this.prepareDraw(); + + this.contentW = this.canvasWidth - 2 * MARGIN_X; + const maxCols = Math.floor(this.contentW / MIN_CELL_W); + this.cellW = this.contentW / maxCols; + + await this.drawHeaderAndDivider(); + this.drawContent(data); + } + + private drawContent(data: PinyinDictationData) { + const isTracing = data.mode === 'pinyin-tracing'; + let y = this.currentY + CONTENT_TOP_GAP; + + for (const section of PINYIN_SECTIONS) { + y = this.drawSection(section, y, isTracing); + } + } + + private drawSection( + section: PinyinSection, + startY: number, + isTracing: boolean, + ): number { + const ctx = this.ctx; + let y = startY; + + ctx.font = SECTION_TITLE_FONT; + ctx.fillStyle = SECTION_TITLE_COLOR; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ctx.fillText(`${section.title}(${section.count}个)`, MARGIN_X, y); + y += TITLE_AFTER_GAP; + + y = this.drawItemRows( + section.items, + MARGIN_X, + y, + isTracing, + this.cellW, + CELL_H, + ); + + if (section.subCategories && section.subCategories.length > 0) { + y += SECTION_TO_SUB_GAP; + y = this.drawSubCategories( + section.subCategories, + y, + isTracing, + section.key, + ); + } + + y += SECTION_GAP; + return y; + } + + private drawSubCategories( + subCategories: PinyinSubCategory[], + startY: number, + isTracing: boolean, + sectionKey: string, + ): number { + const ctx = this.ctx; + let y = startY; + + ctx.font = SUB_LABEL_FONT; + ctx.fillStyle = SUB_LABEL_COLOR; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ctx.fillText('其中——', MARGIN_X, y); + y += QIZHONG_GAP; + + const subX = MARGIN_X + SUB_INDENT; + + if (sectionKey === 'shengmu') { + y = this.drawShengmuSubs(subCategories, subX, y, isTracing); + } else { + for (const sub of subCategories) { + y = this.drawLabeledSubBlock( + `${sub.label}(${sub.count}个)`, + sub.items, + subX, + y, + isTracing, + ); + y += SUB_BLOCK_GAP; + } + } + + return y; + } + + /** + * 声母子分类:翘舌音 / 平舌音左右并列,标签在格子上方 + */ + private drawShengmuSubs( + subs: PinyinSubCategory[], + subX: number, + startY: number, + isTracing: boolean, + ): number { + const qiaoshe = subs.find((s) => s.key === 'qiaoshe'); + const pingshe = subs.find((s) => s.key === 'pingshe'); + const col2X = MARGIN_X + Math.round(this.contentW / 2); + + let bottomY = startY; + + if (qiaoshe) { + const endY = this.drawLabeledSubBlock( + `翘舌音(${qiaoshe.count}个)`, + qiaoshe.items, + subX, + startY, + isTracing, + ); + bottomY = Math.max(bottomY, endY); + } + + if (pingshe) { + const endY = this.drawLabeledSubBlock( + `平舌音(${pingshe.count}个)`, + pingshe.items, + col2X, + startY, + isTracing, + ); + bottomY = Math.max(bottomY, endY); + } + + return bottomY + SUB_BLOCK_GAP; + } + + /** + * 带标签的子分类块:标签在格子上方 + */ + private drawLabeledSubBlock( + label: string, + items: string[], + startX: number, + startY: number, + isTracing: boolean, + ): number { + const ctx = this.ctx; + let y = startY; + + ctx.save(); + ctx.font = SUB_LABEL_FONT; + ctx.fillStyle = SECTION_TITLE_COLOR; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ctx.fillText(label, startX, y); + ctx.restore(); + + y += SUB_LABEL_H + SUB_LABEL_GAP; + + return this.drawItemRows( + items, + startX, + y, + isTracing, + this.cellW, + CELL_H, + ); + } + + /** + * 绘制四线三格拼音行,同一行的格子共享边框 + */ + private drawItemRows( + items: string[], + startX: number, + startY: number, + isTracing: boolean, + cellW: number, + cellH: number, + ): number { + const maxWidth = this.canvasWidth - startX - MARGIN_X; + const maxCols = Math.max(1, Math.floor(maxWidth / cellW)); + let y = startY; + + for (let i = 0; i < items.length; i += maxCols) { + const rowItems = items.slice(i, i + maxCols); + const cols = rowItems.length; + + this.drawFourLineRow(startX, y, cols, cellW, cellH); + + if (isTracing) { + for (let j = 0; j < cols; j++) { + const cx = startX + j * cellW + cellW / 2; + this.drawPinyinText(rowItems[j], cx, y, cellH); + } + } + + y += cellH + ROW_GAP; + } + + return y; + } + + /** + * 绘制一行四线三格 + */ + private drawFourLineRow( + x: number, + y: number, + cols: number, + cellW: number, + cellH: number, + ) { + const ctx = this.ctx; + const totalW = cols * cellW; + const yTop = y; + const y1 = y + cellH / 3; + const y2 = y + (cellH * 2) / 3; + const yBot = y + cellH; + + ctx.lineWidth = 1; + + ctx.strokeStyle = GRID_COLORS.border; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(x, yTop); + ctx.lineTo(x + totalW, yTop); + ctx.stroke(); + + ctx.strokeStyle = GRID_COLORS.middleLine; + ctx.setLineDash([...GRID_DASH]); + ctx.beginPath(); + ctx.moveTo(x, y1); + ctx.lineTo(x + totalW, y1); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x, y2); + ctx.lineTo(x + totalW, y2); + ctx.stroke(); + + ctx.strokeStyle = GRID_COLORS.border; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(x, yBot); + ctx.lineTo(x + totalW, yBot); + ctx.stroke(); + + for (let i = 0; i <= cols; i++) { + const vx = x + i * cellW; + ctx.beginPath(); + ctx.moveTo(vx, yTop); + ctx.lineTo(vx, yBot); + ctx.stroke(); + } + } + + /** + * 在四线三格中绘制拼音: + * alphabetic 基线对齐第 3 条线(中下虚线), + * 小写字母主体填充中间格(第 2~3 线之间), + * 声调标记占据上格(第 1~2 线之间)。 + */ + private drawPinyinText( + pinyin: string, + cx: number, + gridY: number, + gridH: number, + ) { + const ctx = this.ctx; + const fontSize = Math.round(gridH * 0.65); + const baselineY = gridY + (gridH * 2) / 3; + + ctx.save(); + ctx.font = `${fontSize}px ${fontFamilyOf(TONEOZ_PINYIN)}`; + ctx.fillStyle = TRACING_COLORS.guide; + ctx.textAlign = 'center'; + ctx.textBaseline = 'alphabetic'; + ctx.fillText(pinyin, cx, baselineY); + ctx.restore(); + } +} diff --git a/miniprogram/pinyinPages/pinyinDictation/pinyinDictation.config.ts b/miniprogram/pinyinPages/pinyinDictation/pinyinDictation.config.ts new file mode 100644 index 0000000..ac2714f --- /dev/null +++ b/miniprogram/pinyinPages/pinyinDictation/pinyinDictation.config.ts @@ -0,0 +1,83 @@ +import type { DebugPublishMeta } from '../../utils/debugPublish'; +import { inferGradeFromAge } from '../../utils/debugPublish'; + +export type PinyinDictationMode = 'pinyin-tracing' | 'pinyin-dictation'; + +interface PinyinDictationWorksheetDefinition { + id: PinyinDictationMode; + icon: string; + title: string; + subtitle: string; + ageMin: number; + ageMax: number; + difficulty: 1 | 2 | 3 | 4; + tags: string[]; + sortOrder: number; +} + +export const PINYIN_DICTATION_WORKSHEET_DEFINITIONS = [ + { + id: 'pinyin-tracing', + icon: 'draw-o', + title: '描红练习', + subtitle: '跟着描红学拼音字母', + ageMin: 5, + ageMax: 7, + difficulty: 1, + tags: ['拼音', '描红', '声母', '韵母'], + sortOrder: 50, + }, + { + id: 'pinyin-dictation', + icon: 'start-a', + title: '默写练习', + subtitle: '空白格子默写拼音字母', + ageMin: 6, + ageMax: 8, + difficulty: 2, + tags: ['拼音', '默写', '声母', '韵母'], + sortOrder: 51, + }, +] as const satisfies ReadonlyArray; + +type PinyinDictationWorksheetRow = + (typeof PINYIN_DICTATION_WORKSHEET_DEFINITIONS)[number]; + +const PINYIN_DICTATION_WORKSHEET_BY_ID = Object.fromEntries( + PINYIN_DICTATION_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]), +) as Record; + +export const PINYIN_DICTATION_MODE_OPTIONS = + PINYIN_DICTATION_WORKSHEET_DEFINITIONS; + +export function getModeInfo(id: string) { + const m = PINYIN_DICTATION_WORKSHEET_BY_ID[id]; + return m ? { title: m.title, desc: m.subtitle } : undefined; +} + +export function isValidMode(id: string): boolean { + return id in PINYIN_DICTATION_WORKSHEET_BY_ID; +} + +export function getPublishMetaByMode(id: string): DebugPublishMeta | null { + const m = PINYIN_DICTATION_WORKSHEET_BY_ID[id]; + if (!m) return null; + return { + id: m.id, + title: m.title, + subtitle: m.subtitle, + category: 'pinyin', + subcategory: 'pinyin-dictation', + path: `/pinyinPages/pinyinDictation/pinyinDictation?id=${m.id}`, + ageMin: m.ageMin, + ageMax: m.ageMax, + grade: inferGradeFromAge(m.ageMin, m.ageMax), + difficulty: m.difficulty, + previewImg: '', + tags: [...m.tags], + isNew: false, + isHot: false, + sortOrder: m.sortOrder, + status: 'draft', + }; +} diff --git a/miniprogram/pinyinPages/pinyinDictation/pinyinDictation.json b/miniprogram/pinyinPages/pinyinDictation/pinyinDictation.json new file mode 100644 index 0000000..a6546e4 --- /dev/null +++ b/miniprogram/pinyinPages/pinyinDictation/pinyinDictation.json @@ -0,0 +1,16 @@ +{ + "navigationStyle": "custom", + "navigationBarTitleText": "拼音字母默写", + "navigationBarBackgroundColor": "#F8F0E0", + "navigationBarTextStyle": "black", + "backgroundColor": "#FEF6E7", + "enablePullDownRefresh": false, + "usingComponents": { + "nav-bar": "../../components3.0/nav-bar/nav-bar", + "share-guide-popup": "../../components/share-guide-popup/share-guide-popup", + "preview-footer-actions": "../../components3.0/preview-footer-actions/preview-footer-actions", + "debug-publish-tools": "../../components3.0/debug-publish-tools/debug-publish-tools", + "toy-icon": "../../toy/icon/icon", + "preview-card": "../../components3.0/preview-card/preview-card" + } +} diff --git a/miniprogram/pinyinPages/pinyinDictation/pinyinDictation.less b/miniprogram/pinyinPages/pinyinDictation/pinyinDictation.less new file mode 100644 index 0000000..6855551 --- /dev/null +++ b/miniprogram/pinyinPages/pinyinDictation/pinyinDictation.less @@ -0,0 +1,93 @@ +@import '../../style/theme.less'; + +page { + background-color: @bg-page; +} + +.pd-page { + min-height: 100vh; + padding: 0 @page-padding-x; + padding-bottom: calc(200rpx + env(safe-area-inset-bottom)); + box-sizing: border-box; +} + +.pd-main { + padding-top: 24rpx; + display: flex; + flex-direction: column; + gap: 64rpx; +} + +.pd-section { + display: flex; + flex-direction: column; + gap: 32rpx; +} + +.pd-section-title { + font-size: 32rpx; + font-weight: 700; + color: #6d3b00; + padding-left: 8rpx; +} + +.pd-mode-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32rpx; +} + +.pd-mode-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12rpx; + padding: 32rpx 24rpx; + border-radius: 24rpx; + background: #f8f0e0; + box-sizing: border-box; + transition: + transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), + background 0.2s cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.pd-mode-card__icon { + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 4rpx; +} + +.pd-mode-card--active { + background: linear-gradient(145deg, #ffd709 0%, #efc900 100%); + box-shadow: 0 8rpx 32rpx rgba(50, 46, 37, 0.06); +} + +.pd-mode-card--pressed { + transform: scale(0.95); +} + +.pd-mode-card__label { + font-size: 30rpx; + font-weight: 700; + color: @text-secondary; + text-align: center; +} + +.pd-mode-card--active .pd-mode-card__label { + color: #453900; +} + +.pd-mode-card__desc { + font-size: 22rpx; + color: @text-secondary; + text-align: center; + opacity: 0.7; +} + +.pd-mode-card--active .pd-mode-card__desc { + color: #453900; + opacity: 0.8; +} diff --git a/miniprogram/pinyinPages/pinyinDictation/pinyinDictation.ts b/miniprogram/pinyinPages/pinyinDictation/pinyinDictation.ts new file mode 100644 index 0000000..0e0accd --- /dev/null +++ b/miniprogram/pinyinPages/pinyinDictation/pinyinDictation.ts @@ -0,0 +1,168 @@ +import PinyinDictationDraw from './draw/pinyinDictationDraw'; +import type { PinyinDictationData } from './draw/pinyinDictationDraw'; +import { createPage, type CanvasDataState } from '../../base/pageMixin'; +import { defaultShareConfig } from '../../config/config'; +import { + getModeInfo, + getPublishMetaByMode, + isValidMode, + PINYIN_DICTATION_MODE_OPTIONS, + type PinyinDictationMode, +} from './pinyinDictation.config'; +import type { DebugPublishMeta } from '../../utils/debugPublish'; +import { + addFavorite, + removeFavorite, + batchCheckFavorited, +} from '../../utils/favorites'; +import { loadFontFace } from '../../core/font/fontLoader'; +import { TONEOZ_PINYIN } from '../../core/font/fontProfiles'; + +const pageInfoLookup = getModeInfo; + +type PageData = CanvasDataState & { + worksheetId: string; + currentMode: PinyinDictationMode; + modeOptions: typeof PINYIN_DICTATION_MODE_OPTIONS; + isPreviewFavorite: boolean; + isDevEnv: boolean; + debugPublishVisible: boolean; + debugPublishLoading: boolean; + debugPublishMeta: DebugPublishMeta | null; +}; + +createPage( + { + canvas: null as Canvas | null, + ctx: null as RenderingContext | null, + boxHeight: 0, + boxWidth: 0, + drawService: null as PinyinDictationDraw | null, + _favoritedMap: {} as Record, + + data: { + pageTitle: '拼音字母默写', + functionId: '', + hasContent: false, + showShareDialog: false, + worksheetId: 'pinyin-tracing', + currentMode: 'pinyin-tracing' as PinyinDictationMode, + modeOptions: PINYIN_DICTATION_MODE_OPTIONS, + isPreviewFavorite: false, + isDevEnv: false, + debugPublishVisible: false, + debugPublishLoading: false, + debugPublishMeta: null, + } as unknown as PageData, + + onLoad(options: { id?: string }) { + const worksheetId = + options.id && isValidMode(options.id) + ? options.id + : 'pinyin-tracing'; + this.syncDebugPublishEnv(); + this.applyWorksheet(worksheetId); + this.loadFavoritedMap(); + }, + + onSelectMode(e: WechatMiniprogram.TouchEvent) { + const id = e.currentTarget.dataset.id as string | undefined; + if (!id || id === this.data.worksheetId) return; + this.applyWorksheet(id, { redraw: true }); + }, + + onCanvasReady(e: WechatMiniprogram.CustomEvent) { + this.initCanvasFromComponent(e.detail, { + createDrawService: ( + canvas: Canvas, + ctx: RenderingContext, + options?: Record, + ) => new PinyinDictationDraw(canvas, ctx, options), + drawServiceOptions: { + title: this.data.pageTitle, + }, + onCanvasReady: () => { + this.drawCanvas(); + }, + }); + }, + + async drawCanvas() { + if (!this.ctx || !this.drawService) return; + try { + await loadFontFace(TONEOZ_PINYIN); + const data: PinyinDictationData = { + mode: this.data.currentMode, + }; + await (this.drawService as PinyinDictationDraw).draw(data); + this.setData({ hasContent: true }); + } catch (e) { + console.error('pinyinDictation draw failed', e); + this.setData({ hasContent: false }); + } + }, + + async onPreviewFavorite() { + const next = !this.data.isPreviewFavorite; + this.setData({ isPreviewFavorite: next }); + const id = this.data.worksheetId; + if (id) { + this._favoritedMap[id] = next; + if (next) { + addFavorite(id); + } else { + removeFavorite(id); + } + } + wx.showToast({ + title: next ? '收藏成功' : '已取消收藏', + icon: 'none', + }); + }, + + async loadFavoritedMap() { + const ids = PINYIN_DICTATION_MODE_OPTIONS.map((d) => d.id); + this._favoritedMap = await batchCheckFavorited(ids); + const currentId = this.data.worksheetId; + if (this._favoritedMap[currentId]) { + this.setData({ isPreviewFavorite: true }); + } + }, + + getPublishMeta(): DebugPublishMeta { + const meta = getPublishMetaByMode(this.data.worksheetId); + if (!meta) { + throw new Error('当前题型配置不存在'); + } + return meta; + }, + + applyWorksheet(worksheetId: string, options?: { redraw?: boolean }) { + if (!isValidMode(worksheetId)) return; + + this.setData( + { + worksheetId, + functionId: worksheetId, + currentMode: worksheetId as PinyinDictationMode, + isPreviewFavorite: !!this._favoritedMap[worksheetId], + }, + () => { + this.initPageInfo(worksheetId, '拼音字母默写'); + + if (this.drawService) { + this.drawService.options.title = this.data.pageTitle; + } + + if (options?.redraw) { + this.drawCanvas(); + } + }, + ); + }, + }, + { + shareConfig: defaultShareConfig, + pageInfoLookup, + }, +); diff --git a/miniprogram/pinyinPages/pinyinDictation/pinyinDictation.wxml b/miniprogram/pinyinPages/pinyinDictation/pinyinDictation.wxml new file mode 100644 index 0000000..76b5d56 --- /dev/null +++ b/miniprogram/pinyinPages/pinyinDictation/pinyinDictation.wxml @@ -0,0 +1,57 @@ + + + + + + + + + 练习类型 + + + + {{item.title}} + {{item.subtitle}} + + + + + + + + + + + diff --git a/project.private.config.json b/project.private.config.json index 8704609..21a1ec4 100644 --- a/project.private.config.json +++ b/project.private.config.json @@ -24,12 +24,19 @@ "miniprogram": { "list": [ { - "name": "chinesePages/handwritingSheet/handwritingSheet", - "pathName": "chinesePages/handwritingSheet/handwritingSheet", + "name": "pinyinPages/pinyinDictation/pinyinDictation", + "pathName": "pinyinPages/pinyinDictation/pinyinDictation", "query": "", "scene": null, "launchMode": "default" }, + { + "name": "chinesePages/handwritingSheet/handwritingSheet", + "pathName": "chinesePages/handwritingSheet/handwritingSheet", + "query": "", + "launchMode": "default", + "scene": null + }, { "name": "chinesePages/wordColoring/wordColoring", "pathName": "chinesePages/wordColoring/wordColoring", diff --git a/skills/create-draw-page/SKILL.md b/skills/create-draw-page/SKILL.md index 6551550..143e997 100644 --- a/skills/create-draw-page/SKILL.md +++ b/skills/create-draw-page/SKILL.md @@ -1,9 +1,9 @@ --- name: create-draw-page description: >- - 基于现有 draw 页面模板创建新的绘制页(worksheet page)。 - 当用户说「基于现有的draw页面来开发新页面」「基于draw页面开发新页面」 - 「新建绘制页」「创建一个新的draw页面」「新增一个worksheet页」时触发。 + 基于现有 draw 页面模板创建新的绘制页(worksheet page)。 + 当用户说「基于现有的draw页面来开发新页面」「基于draw页面开发新页面」 + 「新建绘制页」「创建一个新的draw页面」「新增一个worksheet页」时触发。 --- # 创建新的绘制页(Draw Page) @@ -45,14 +45,14 @@ newPage/ ```ts interface XxxWorksheetDefinition { - id: string; // 唯一标识,格式:{pageName}-{variant} - icon: string; // 图标名(使用 toy-icon 支持的图标) - title: string; // 标题(简短,4-6 字) + id: string; // 唯一标识,格式:{pageName}-{variant} + icon: string; // 图标名(使用 toy-icon 支持的图标) + title: string; // 标题(简短,4-6 字) subtitle: string; // 副标题(描述该模式的特点) - ageMin: number; // 最小适用年龄 - ageMax: number; // 最大适用年龄 + ageMin: number; // 最小适用年龄 + ageMax: number; // 最大适用年龄 difficulty: 1 | 2 | 3 | 4; // 难度等级 - tags: string[]; // 标签数组 + tags: string[]; // 标签数组 sortOrder: number; // 排序权重 } @@ -80,6 +80,7 @@ export const XXX_WORKSHEET_DEFINITIONS = [ - 模式选项常量(如 `XXX_MODE_OPTIONS`) `getPublishMetaByMode` 中的 `category` 和 `subcategory` 需根据页面所属学科正确设置: + - `category`:`math` | `puzzle` | `pinyin` | `chinese` | `english` | `craft` - `subcategory`:页面功能简称,如 `letter-tracing`、`word-coloring` @@ -214,6 +215,7 @@ createPage( ``` `createPage` 自动混入的公共方法包括: + - `initCanvas` / `initCanvasFromComponent` — Canvas 初始化 - `exportToPrint` — 导出打印 - `onShareAppMessage` / `onShareTimeline` — 分享 @@ -237,6 +239,7 @@ page { ``` 关键设计 token(来自 `theme.less`): + - 页面背景:`@bg-page` - 卡片背景:`@bg-card` - 品牌色/选中态:`@brand` diff --git a/tools/font-tools/extract_pinyin.py b/tools/font-tools/extract_pinyin.py new file mode 100644 index 0000000..ca68bb8 --- /dev/null +++ b/tools/font-tools/extract_pinyin.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""从 ToneOZ-Pinyin-WenKai-Regular.ttf 中提取拼音/字母/数字字符,生成新的字体文件。""" + +from fontTools.ttLib import TTFont +from fontTools.subset import Subsetter +from pathlib import Path + +BASE = Path(__file__).resolve().parent +INPUT = BASE / "input-fonts" / "ToneOZ-Pinyin-WenKai-Regular.ttf" +OUTPUT = BASE / "output-fonts" / "ToneOZ-Pinyin-Kai-Regular.ttf" + +# Unicode 码点范围 +RANGES = [ + (0x0020, 0x007E), # 基本 ASCII(字母、数字、标点) + (0x00C0, 0x00FF), # 拉丁字母-1 补充 + (0x0100, 0x017F), # 拉丁字母扩展 A + (0x01D6, 0x01DC), # ü 的四个声调变体 +] + + +def build_unicodes(): + codepoints = set() + for start, end in RANGES: + for cp in range(start, end + 1): + codepoints.add(cp) + return sorted(codepoints) + + +def main(): + font = TTFont(INPUT) + unicodes = build_unicodes() + + # 使用 fonttools Subsetter 提取指定字符 + sub = Subsetter() + sub.populate(unicodes=unicodes) + sub.subset(font) + + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + font.save(OUTPUT) + print(f"提取完成: {OUTPUT}") + print(f"包含 {len(unicodes)} 个码点") + + # 输出范围统计 + for start, end in RANGES: + name = f"U+{start:04X}-U+{end:04X}" + glyphs_in_range = sum( + 1 for cp in range(start, end + 1) if cp in set(unicodes) + ) + print(f" {name}: {glyphs_in_range} 个字符") + + +if __name__ == "__main__": + main() diff --git a/tools/font-tools/input-fonts/ToneOZ-Pinyin-WenKai-Regular.ttf b/tools/font-tools/input-fonts/ToneOZ-Pinyin-WenKai-Regular.ttf new file mode 100644 index 0000000..6df3756 Binary files /dev/null and b/tools/font-tools/input-fonts/ToneOZ-Pinyin-WenKai-Regular.ttf differ diff --git a/tools/font-tools/output-fonts/ToneOZ-Pinyin-Regular.ttf b/tools/font-tools/output-fonts/ToneOZ-Pinyin-Regular.ttf new file mode 100644 index 0000000..5550873 Binary files /dev/null and b/tools/font-tools/output-fonts/ToneOZ-Pinyin-Regular.ttf differ