feat: 拼音字母选择
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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` |
|
||||||
@@ -41,6 +41,12 @@
|
|||||||
"pages": ["focusDraw/focusDraw", "shape/shape"],
|
"pages": ["focusDraw/focusDraw", "shape/shape"],
|
||||||
"independent": false
|
"independent": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"root": "pinyinPages",
|
||||||
|
"name": "pinyinPages",
|
||||||
|
"pages": ["pinyinDictation/pinyinDictation"],
|
||||||
|
"independent": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"root": "chinesePages",
|
"root": "chinesePages",
|
||||||
"name": "chinesePages",
|
"name": "chinesePages",
|
||||||
|
|||||||
+14
-18
@@ -8,8 +8,9 @@
|
|||||||
* - letter:字母
|
* - letter:字母
|
||||||
* - symbol:符号
|
* - symbol:符号
|
||||||
*/
|
*/
|
||||||
import { BaseDrawService } from '../core/draw/baseDraw';
|
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
||||||
import { CharacterItem } from '../types/characterType';
|
import { GRID_COLORS, TRACING_COLORS } from '../../../core/data/tracingStyles';
|
||||||
|
import { CharacterItem } from '../../../types/characterType';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* cnchar-data 坐标系配置
|
* cnchar-data 坐标系配置
|
||||||
@@ -210,29 +211,24 @@ function drawTianZiGrid({
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
size,
|
size,
|
||||||
lineColor = '#a8d5a8', // 浅绿色(中线)
|
lineColor = GRID_COLORS.middleLine,
|
||||||
boldColor = '#7fb069', // 中等绿色(外框)
|
boldColor = GRID_COLORS.border,
|
||||||
}: DrawTianZiGridParams) {
|
}: DrawTianZiGridParams) {
|
||||||
// 外框(逻辑像素)- 使用中等绿色,清晰可见
|
|
||||||
ctx.strokeStyle = boldColor;
|
ctx.strokeStyle = boldColor;
|
||||||
ctx.lineWidth = 1; // 2/3≈1,逻辑像素
|
ctx.lineWidth = 1;
|
||||||
ctx.strokeRect(x - size / 2, y - size / 2, size, size);
|
ctx.strokeRect(x - size / 2, y - size / 2, size, size);
|
||||||
|
|
||||||
// 中线(逻辑像素)- 使用浅绿色,柔和护眼
|
|
||||||
ctx.strokeStyle = lineColor;
|
ctx.strokeStyle = lineColor;
|
||||||
ctx.lineWidth = 1; // 逻辑像素
|
ctx.lineWidth = 1;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
// 竖线
|
|
||||||
ctx.moveTo(x, y - size / 2);
|
ctx.moveTo(x, y - size / 2);
|
||||||
ctx.lineTo(x, y + size / 2);
|
ctx.lineTo(x, y + size / 2);
|
||||||
// 横线
|
|
||||||
ctx.moveTo(x - size / 2, y);
|
ctx.moveTo(x - size / 2, y);
|
||||||
ctx.lineTo(x + size / 2, y);
|
ctx.lineTo(x + size / 2, y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
|
|
||||||
// 对角线(淡绿色,逻辑像素)- 使用很淡的绿色,提供辅助参考线
|
ctx.strokeStyle = GRID_COLORS.diagonal;
|
||||||
ctx.strokeStyle = '#d4f0d4'; // 很淡的绿色
|
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x - size / 2, y - size / 2);
|
ctx.moveTo(x - size / 2, y - size / 2);
|
||||||
@@ -544,9 +540,9 @@ class WordDrawService extends BaseDrawService {
|
|||||||
offsetY: y,
|
offsetY: y,
|
||||||
size: cellSize,
|
size: cellSize,
|
||||||
uptoInclusive: strokes.length - 1,
|
uptoInclusive: strokes.length - 1,
|
||||||
fillStyle: 'rgb(85,85,85)', // 深灰色填充(#555555),比#666666更深一点
|
fillStyle: TRACING_COLORS.reference,
|
||||||
strokeStyle: 'rgb(85,85,85)', // 深灰色描边
|
strokeStyle: TRACING_COLORS.reference,
|
||||||
lineWidth: 1.2, // Fill + Stroke 模式,线条宽度调小一点(逻辑像素)
|
lineWidth: 1.2,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,9 +586,9 @@ class WordDrawService extends BaseDrawService {
|
|||||||
offsetY: y,
|
offsetY: y,
|
||||||
size: cellSize,
|
size: cellSize,
|
||||||
uptoInclusive: strokeIndex,
|
uptoInclusive: strokeIndex,
|
||||||
fillStyle: 'rgb(170,170,170)', // 浅灰色填充(#aaaaaa),参考练字贴颜色
|
fillStyle: TRACING_COLORS.guide,
|
||||||
strokeStyle: 'rgb(170,170,170)', // 浅灰色描边
|
strokeStyle: TRACING_COLORS.guide,
|
||||||
lineWidth: 1.2, // Fill + Stroke 模式,线条宽度调小一点(逻辑像素)
|
lineWidth: 1.2,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import WordDrawService from '../../service/wordDrawService';
|
import WordDrawService from './draw/wordDrawService';
|
||||||
import { downloadPrint } from '../../utils/downloadPrint';
|
import { downloadPrint } from '../../utils/downloadPrint';
|
||||||
import { getWordsSvgData } from '../../utils/getWordsSvgJson';
|
import { getWordsSvgData } from '../../utils/getWordsSvgJson';
|
||||||
import { WORDS } from '../../core/data/words';
|
import { WORDS } from '../../core/data/words';
|
||||||
|
|||||||
+4
-4
@@ -7,11 +7,11 @@
|
|||||||
* - find:找字模板
|
* - find:找字模板
|
||||||
*
|
*
|
||||||
* 支持的绘制服务:
|
* 支持的绘制服务:
|
||||||
* - TextDrawService:文字涂色服务
|
* - WordColoringDraw:文字涂色服务
|
||||||
* - FindWordDrawService:找字涂色服务
|
* - FindWordDrawService:找字涂色服务
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import TextDrawService from './textDrawService';
|
import WordColoringDraw from './wordColoringDraw';
|
||||||
import FindWordDrawService from './findWordDrawService';
|
import FindWordDrawService from './findWordDrawService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,12 +49,12 @@ export class DrawServiceFactory {
|
|||||||
): IDrawService {
|
): IDrawService {
|
||||||
switch (templateType) {
|
switch (templateType) {
|
||||||
case 'grid':
|
case 'grid':
|
||||||
return new TextDrawService(canvas, ctx, options);
|
return new WordColoringDraw(canvas, ctx, options);
|
||||||
case 'find':
|
case 'find':
|
||||||
return new FindWordDrawService(canvas, ctx, options);
|
return new FindWordDrawService(canvas, ctx, options);
|
||||||
default:
|
default:
|
||||||
// 默认使用网格模板
|
// 默认使用网格模板
|
||||||
return new TextDrawService(canvas, ctx, options);
|
return new WordColoringDraw(canvas, ctx, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -48,7 +48,7 @@ function calculateCircleCenters(
|
|||||||
return centers;
|
return centers;
|
||||||
}
|
}
|
||||||
|
|
||||||
class TextDrawService extends BaseDrawService {
|
class WordColoringDrawService extends BaseDrawService {
|
||||||
colors: string[];
|
colors: string[];
|
||||||
characters: string[];
|
characters: string[];
|
||||||
|
|
||||||
@@ -204,4 +204,4 @@ class TextDrawService extends BaseDrawService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TextDrawService;
|
export default WordColoringDrawService;
|
||||||
@@ -3,7 +3,7 @@ import { WORDS } from '../../core/data/words';
|
|||||||
import {
|
import {
|
||||||
DrawServiceFactory,
|
DrawServiceFactory,
|
||||||
type IDrawService,
|
type IDrawService,
|
||||||
} from '../shared/draw/drawServiceFactory';
|
} from './draw/drawServiceFactory';
|
||||||
import { createPage, type CanvasDataState } from '../../base/pageMixin';
|
import { createPage, type CanvasDataState } from '../../base/pageMixin';
|
||||||
import { defaultShareConfig } from '../../config/config';
|
import { defaultShareConfig } from '../../config/config';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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;
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
+16
@@ -48,6 +48,9 @@ export function fontFamilyOf(profile: FontProfile): string {
|
|||||||
return `${profile.name}, Roboto, sans-serif`;
|
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 TALL_LOWERCASE = new Set(['b', 'd', 'h', 'k', 'l']);
|
||||||
const DESCENDERS = new Set(['g', 'p', 'q', 'y']);
|
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 数字字体 */
|
/** Num 数字字体 */
|
||||||
export const NUM_FONT: FontProfile = {
|
export const NUM_FONT: FontProfile = {
|
||||||
name: 'NumFont',
|
name: 'NumFont',
|
||||||
@@ -233,6 +248,7 @@ const FONT_PROFILES: ReadonlyArray<FontProfile> = [
|
|||||||
PRINT_CLEARLY_BOLD,
|
PRINT_CLEARLY_BOLD,
|
||||||
PRINT_CLEARLY_DASHED,
|
PRINT_CLEARLY_DASHED,
|
||||||
NUM_FONT,
|
NUM_FONT,
|
||||||
|
TONEOZ_PINYIN,
|
||||||
];
|
];
|
||||||
|
|
||||||
const _profileByName = new Map<string, FontProfile>(
|
const _profileByName = new Map<string, FontProfile>(
|
||||||
@@ -3,7 +3,7 @@ import type {
|
|||||||
LetterTracingData,
|
LetterTracingData,
|
||||||
TracingRow,
|
TracingRow,
|
||||||
} from '../generators/letter-tracing-generator';
|
} from '../generators/letter-tracing-generator';
|
||||||
import { fontFamilyOf } from '../../shared/data/fontProfiles';
|
import { fontFamilyOf } from '../../../core/font/fontProfiles';
|
||||||
import {
|
import {
|
||||||
calcLetterFontSizes,
|
calcLetterFontSizes,
|
||||||
drawFourLineGrid,
|
drawFourLineGrid,
|
||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
FOUR_LINE_GRID_H,
|
FOUR_LINE_GRID_H,
|
||||||
type LetterFontSizes,
|
type LetterFontSizes,
|
||||||
} from '../../shared/draw/drawTools';
|
} from '../../shared/draw/drawTools';
|
||||||
const COLOR_BLACK = '#1a1a1a';
|
import { TRACING_COLORS } from '../../../core/data/tracingStyles';
|
||||||
const COLOR_LIGHT_RED = '#FF8E8E';
|
|
||||||
|
const COLOR_BLACK = TRACING_COLORS.strong;
|
||||||
|
const COLOR_LIGHT_RED = TRACING_COLORS.guide;
|
||||||
|
|
||||||
/** 左右大写/小写两栏之间的间距(相对行宽 + 下限,避免过窄屏过小) */
|
/** 左右大写/小写两栏之间的间距(相对行宽 + 下限,避免过窄屏过小) */
|
||||||
const COLUMN_GUTTER_RATIO = 0.02;
|
const COLUMN_GUTTER_RATIO = 0.02;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
PRINT_CLEARLY_DASHED,
|
PRINT_CLEARLY_DASHED,
|
||||||
PRINT_CLEARLY_REGULAR,
|
PRINT_CLEARLY_REGULAR,
|
||||||
fontFamilyOf,
|
fontFamilyOf,
|
||||||
} from '../../shared/data/fontProfiles';
|
} from '../../../core/font/fontProfiles';
|
||||||
import {
|
import {
|
||||||
calcLetterFontSizes,
|
calcLetterFontSizes,
|
||||||
drawClosedFourLineGrid,
|
drawClosedFourLineGrid,
|
||||||
@@ -53,15 +53,13 @@ const GRID_COL_GAP = 6;
|
|||||||
// 格子行间距(px)
|
// 格子行间距(px)
|
||||||
const GRID_ROW_GAP = 8;
|
const GRID_ROW_GAP = 8;
|
||||||
|
|
||||||
// 网格描边主色
|
import { GRID_COLORS, TRACING_COLORS } from '../../../core/data/tracingStyles';
|
||||||
const GRID_BORDER = '#9AA9A2';
|
|
||||||
// 网格中间线虚线颜色
|
|
||||||
const GRID_MIDDLE = 'rgba(154, 169, 162, 0.65)';
|
|
||||||
|
|
||||||
// 黑色文本主色
|
const GRID_BORDER = GRID_COLORS.border;
|
||||||
const COLOR_BLACK = '#1a1a1a';
|
const GRID_MIDDLE = GRID_COLORS.middleLine;
|
||||||
// 数据红色(例:未签到状态)
|
|
||||||
const COLOR_LIGHT_RED = '#FF8E8E';
|
const COLOR_BLACK = TRACING_COLORS.strong;
|
||||||
|
const COLOR_LIGHT_RED = TRACING_COLORS.guide;
|
||||||
|
|
||||||
export default class DailyCheckinDraw extends BaseDrawService {
|
export default class DailyCheckinDraw extends BaseDrawService {
|
||||||
async draw(data: DailyCheckinData) {
|
async draw(data: DailyCheckinData) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
fontFamilyOf,
|
fontFamilyOf,
|
||||||
PRINT_CLEARLY_DASHED,
|
PRINT_CLEARLY_DASHED,
|
||||||
} from '../../shared/data/fontProfiles';
|
} from '../../../core/font/fontProfiles';
|
||||||
import {
|
import {
|
||||||
calcLetterFontSizes,
|
calcLetterFontSizes,
|
||||||
drawFourLineGrid,
|
drawFourLineGrid,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
fontFamilyOf,
|
fontFamilyOf,
|
||||||
PRINT_CLEARLY_BOLD,
|
PRINT_CLEARLY_BOLD,
|
||||||
PRINT_CLEARLY_DASHED,
|
PRINT_CLEARLY_DASHED,
|
||||||
} from '../../shared/data/fontProfiles';
|
} from '../../../core/font/fontProfiles';
|
||||||
import {
|
import {
|
||||||
calcLetterFontSizes,
|
calcLetterFontSizes,
|
||||||
drawFourLineGrid,
|
drawFourLineGrid,
|
||||||
@@ -18,8 +18,9 @@ import {
|
|||||||
loadLetterFont,
|
loadLetterFont,
|
||||||
type LetterFontSizes,
|
type LetterFontSizes,
|
||||||
} from '../../shared/draw/drawTools';
|
} 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 ACCENT_RED = 'rgba(252, 46, 0, 1)';
|
||||||
const TITLE_TEXT_FONT = '14px sans-serif';
|
const TITLE_TEXT_FONT = '14px sans-serif';
|
||||||
const TITLE_ICON_GAP = 4;
|
const TITLE_ICON_GAP = 4;
|
||||||
@@ -167,10 +168,10 @@ export default class SingleLetterDraw extends BaseDrawService {
|
|||||||
const groupsPerLine = [8, 6, 3, 1];
|
const groupsPerLine = [8, 6, 3, 1];
|
||||||
const slotW = lineW / groupsPerLine[0]; // 以第一行的 8 组为基准
|
const slotW = lineW / groupsPerLine[0]; // 以第一行的 8 组为基准
|
||||||
const lineStyles = [
|
const lineStyles = [
|
||||||
{ color: 'rgba(26, 26, 26, 1)', alpha: 1, dashed: false },
|
{ color: TRACING_COLORS.strong, alpha: 1, dashed: false },
|
||||||
{ color: '#FF8E8E', alpha: 1, dashed: false },
|
{ color: TRACING_COLORS.guide, alpha: 1, dashed: false },
|
||||||
{ color: '#FF8E8E', alpha: 0.5, dashed: false },
|
{ color: TRACING_COLORS.guide, alpha: 0.5, dashed: false },
|
||||||
{ color: '#FF8E8E', alpha: 0.5, dashed: true },
|
{ color: TRACING_COLORS.guide, alpha: 0.5, dashed: true },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
for (let r = 0; r < lineTops.length; r++) {
|
for (let r = 0; r < lineTops.length; r++) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
fontFamilyOf,
|
fontFamilyOf,
|
||||||
PRINT_CLEARLY_DASHED,
|
PRINT_CLEARLY_DASHED,
|
||||||
} from '../../shared/data/fontProfiles';
|
} from '../../../core/font/fontProfiles';
|
||||||
import {
|
import {
|
||||||
calcLetterFontSizes,
|
calcLetterFontSizes,
|
||||||
drawFourLineGrid,
|
drawFourLineGrid,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type {
|
|||||||
LetterTracingData,
|
LetterTracingData,
|
||||||
TracingRow,
|
TracingRow,
|
||||||
} from '../generators/letter-tracing-generator';
|
} from '../generators/letter-tracing-generator';
|
||||||
import { fontFamilyOf } from '../../shared/data/fontProfiles';
|
import { fontFamilyOf } from '../../../core/font/fontProfiles';
|
||||||
import {
|
import {
|
||||||
calcLetterFontSizes,
|
calcLetterFontSizes,
|
||||||
drawFourLineGrid,
|
drawFourLineGrid,
|
||||||
@@ -12,8 +12,10 @@ import {
|
|||||||
type LetterFontSizes,
|
type LetterFontSizes,
|
||||||
} from '../../shared/draw/drawTools';
|
} from '../../shared/draw/drawTools';
|
||||||
|
|
||||||
const COLOR_BLACK = '#1a1a1a';
|
import { TRACING_COLORS } from '../../../core/data/tracingStyles';
|
||||||
const COLOR_LIGHT_RED = '#FF8E8E';
|
|
||||||
|
const COLOR_BLACK = TRACING_COLORS.strong;
|
||||||
|
const COLOR_LIGHT_RED = TRACING_COLORS.guide;
|
||||||
const COLUMN_GUTTER = 18;
|
const COLUMN_GUTTER = 18;
|
||||||
|
|
||||||
type TwoColumnData = Extract<
|
type TwoColumnData = Extract<
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
PRINT_CLEARLY_BOLD,
|
PRINT_CLEARLY_BOLD,
|
||||||
PRINT_CLEARLY_DASHED,
|
PRINT_CLEARLY_DASHED,
|
||||||
fontFamilyOf,
|
fontFamilyOf,
|
||||||
} from '../../shared/data/fontProfiles';
|
} from '../../../core/font/fontProfiles';
|
||||||
import {
|
import {
|
||||||
calcLetterFontSizes,
|
calcLetterFontSizes,
|
||||||
drawFourLineGrid,
|
drawFourLineGrid,
|
||||||
@@ -13,26 +13,21 @@ import {
|
|||||||
loadLetterFont,
|
loadLetterFont,
|
||||||
type LetterFontSizes,
|
type LetterFontSizes,
|
||||||
} from '../../shared/draw/drawTools';
|
} from '../../shared/draw/drawTools';
|
||||||
|
import { TRACING_COLORS } from '../../../core/data/tracingStyles';
|
||||||
|
|
||||||
type UpperLowerData = Extract<
|
type UpperLowerData = Extract<
|
||||||
LetterTracingData,
|
LetterTracingData,
|
||||||
{ mode: 'letter-tracing-upper-lower' }
|
{ mode: 'letter-tracing-upper-lower' }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/** 页面左右边距(margin,px),用于排版时留白 */
|
|
||||||
const M = 24;
|
const M = 24;
|
||||||
/** 上下两大区(Uppercase / lowercase)之间的间距 */
|
|
||||||
const SECTION_GAP = 25;
|
const SECTION_GAP = 25;
|
||||||
/** 标题字号 */
|
|
||||||
const TITLE_SIZE = 28;
|
const TITLE_SIZE = 28;
|
||||||
/** 标题与第一行四线三格的间距 */
|
|
||||||
const TITLE_GAP = 25;
|
const TITLE_GAP = 25;
|
||||||
/** 同一区域内相邻四线三格行之间的间距 */
|
|
||||||
const ROW_GAP = 24;
|
const ROW_GAP = 24;
|
||||||
const TITLE_COLOR = '#000';
|
const TITLE_COLOR = '#000';
|
||||||
|
|
||||||
/** 浅红色描红(不透明 hex,避免打印时半透明发灰) */
|
const TRACE_LIGHT_RED = TRACING_COLORS.guide;
|
||||||
const TRACE_LIGHT_RED = '#FF8E8E';
|
|
||||||
|
|
||||||
const DASHED_PROFILE = PRINT_CLEARLY_DASHED;
|
const DASHED_PROFILE = PRINT_CLEARLY_DASHED;
|
||||||
const TITLE_PROFILE = PRINT_CLEARLY_BOLD;
|
const TITLE_PROFILE = PRINT_CLEARLY_BOLD;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ALPHABET_BY_LETTER, LETTERS_UPPER } from '../../shared/data/alphabet';
|
import { ALPHABET_BY_LETTER, LETTERS_UPPER } from '../../shared/data/alphabet';
|
||||||
|
import { TRACING_COLORS } from '../../../core/data/tracingStyles';
|
||||||
|
|
||||||
/** 字母描红的布局模式(与产品文档六类练习一致) */
|
/** 字母描红的布局模式(与产品文档六类练习一致) */
|
||||||
export type LetterTracingMode =
|
export type LetterTracingMode =
|
||||||
@@ -227,8 +228,8 @@ function overviewRowsFrom(letters: string[]): string[][] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const _COLOR_BLACK = '#1a1a1a';
|
const _COLOR_BLACK = TRACING_COLORS.strong;
|
||||||
const _COLOR_LIGHT_RED = '#FF8E8E';
|
const _COLOR_LIGHT_RED = TRACING_COLORS.guide;
|
||||||
|
|
||||||
/** 构建 6 slot 的单行字母数据:前 4 格有字母(颜色递进),后 2 格空白 */
|
/** 构建 6 slot 的单行字母数据:前 4 格有字母(颜色递进),后 2 格空白 */
|
||||||
function buildSingleLineCells(L: string): (SingleLineCell | null)[] {
|
function buildSingleLineCells(L: string): (SingleLineCell | null)[] {
|
||||||
|
|||||||
@@ -19,10 +19,14 @@ import {
|
|||||||
DEFAULT_LETTER_PROFILE,
|
DEFAULT_LETTER_PROFILE,
|
||||||
getFontProfile,
|
getFontProfile,
|
||||||
type FontProfile,
|
type FontProfile,
|
||||||
} from '../shared/data/fontProfiles';
|
} from '../../core/font/fontProfiles';
|
||||||
import { loadLetterFont } from '../shared/draw/drawTools';
|
import { loadLetterFont } from '../shared/draw/drawTools';
|
||||||
import type { DebugPublishMeta } from '../../utils/debugPublish';
|
import type { DebugPublishMeta } from '../../utils/debugPublish';
|
||||||
import { addFavorite, removeFavorite, batchCheckFavorited } from '../../utils/favorites';
|
import {
|
||||||
|
addFavorite,
|
||||||
|
removeFavorite,
|
||||||
|
batchCheckFavorited,
|
||||||
|
} from '../../utils/favorites';
|
||||||
|
|
||||||
/** 三字母精练分组:26 字母每 3 个一组 */
|
/** 三字母精练分组:26 字母每 3 个一组 */
|
||||||
const TRIPLE_GROUPS: { title: string; letters: string[] }[] = [];
|
const TRIPLE_GROUPS: { title: string; letters: string[] }[] = [];
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import {
|
|||||||
fontFamilyOf,
|
fontFamilyOf,
|
||||||
getLetterMetrics,
|
getLetterMetrics,
|
||||||
type FontProfile,
|
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;
|
export const FOUR_LINE_GRID_H = 43;
|
||||||
|
|
||||||
// ── 字体加载(支持多字体,按 URL 去重) ──
|
// ── 字体加载(委托到 core/font/fontLoader) ──
|
||||||
|
|
||||||
const _loadedUrls = new Set<string>();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载字体到 Canvas native 渲染管线。
|
* 加载字体到 Canvas native 渲染管线。
|
||||||
@@ -23,22 +23,7 @@ const _loadedUrls = new Set<string>();
|
|||||||
export function loadLetterFont(
|
export function loadLetterFont(
|
||||||
profile: FontProfile = DEFAULT_LETTER_PROFILE,
|
profile: FontProfile = DEFAULT_LETTER_PROFILE,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (_loadedUrls.has(profile.url)) return Promise.resolve();
|
return loadFontFace(profile);
|
||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── LetterFontSizes:持有 FontProfile 引用,运行时按字母查表 ──
|
// ── LetterFontSizes:持有 FontProfile 引用,运行时按字母查表 ──
|
||||||
@@ -74,7 +59,7 @@ function getLetterBaselineOffset(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 向后兼容:导出 DESCENDERS 集合 ──
|
// ── 向后兼容:导出 DESCENDERS 集合 ──
|
||||||
export { DESCENDERS } from '../data/fontProfiles';
|
export { DESCENDERS } from '../../../core/font/fontProfiles';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在四线三格中绘制单个字母(基线固定在 y + 2h/3 + baselineOffset)。
|
* 在四线三格中绘制单个字母(基线固定在 y + 2h/3 + baselineOffset)。
|
||||||
@@ -138,10 +123,10 @@ export function drawFourLineGrid(
|
|||||||
h: number,
|
h: number,
|
||||||
style?: FourLineGridStyle,
|
style?: FourLineGridStyle,
|
||||||
) {
|
) {
|
||||||
const ink = style?.ink ?? '#322E25';
|
const ink = style?.ink ?? GRID_COLORS.border;
|
||||||
const middleInk = style?.middleInk ?? 'rgba(50, 46, 37, 0.3)';
|
const middleInk = style?.middleInk ?? GRID_COLORS.middleLine;
|
||||||
const lineWidth = style?.lineWidth ?? 1;
|
const lineWidth = style?.lineWidth ?? 1;
|
||||||
const dash = style?.dash ?? [4, 4];
|
const dash = style?.dash ?? [...GRID_DASH];
|
||||||
|
|
||||||
const yTop = y;
|
const yTop = y;
|
||||||
const y1 = y + h / 3;
|
const y1 = y + h / 3;
|
||||||
@@ -188,10 +173,10 @@ export function drawClosedFourLineGrid(
|
|||||||
h: number,
|
h: number,
|
||||||
style?: FourLineGridStyle,
|
style?: FourLineGridStyle,
|
||||||
) {
|
) {
|
||||||
const ink = style?.ink ?? '#9AA9A2';
|
const ink = style?.ink ?? GRID_COLORS.border;
|
||||||
const middleInk = style?.middleInk ?? 'rgba(154, 169, 162, 0.65)';
|
const middleInk = style?.middleInk ?? GRID_COLORS.middleLine;
|
||||||
const lineWidth = style?.lineWidth ?? 1;
|
const lineWidth = style?.lineWidth ?? 1;
|
||||||
const dash = style?.dash ?? [3, 3];
|
const dash = style?.dash ?? [...GRID_DASH];
|
||||||
|
|
||||||
const y1 = y + h / 3;
|
const y1 = y + h / 3;
|
||||||
const y2 = y + (h * 2) / 3;
|
const y2 = y + (h * 2) / 3;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PinyinDictationWorksheetDefinition>;
|
||||||
|
|
||||||
|
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<string, PinyinDictationWorksheetRow>;
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, boolean>,
|
||||||
|
|
||||||
|
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<string, unknown>,
|
||||||
|
) => 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,
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<nav-bar title="拼音字母默写" />
|
||||||
|
|
||||||
|
<view class="pd-page">
|
||||||
|
<view class="pd-main">
|
||||||
|
<preview-card
|
||||||
|
id="previewCard"
|
||||||
|
showRefresh="{{false}}"
|
||||||
|
showFavorite="{{true}}"
|
||||||
|
favorited="{{isPreviewFavorite}}"
|
||||||
|
bind:canvas-ready="onCanvasReady"
|
||||||
|
bind:favorite="onPreviewFavorite" />
|
||||||
|
|
||||||
|
<!-- 练习类型切换(描红 / 默写) -->
|
||||||
|
<view class="pd-section">
|
||||||
|
<text class="pd-section-title">练习类型</text>
|
||||||
|
<view class="pd-mode-grid">
|
||||||
|
<view
|
||||||
|
wx:for="{{modeOptions}}"
|
||||||
|
wx:key="id"
|
||||||
|
class="pd-mode-card {{worksheetId === item.id ? 'pd-mode-card--active' : ''}}"
|
||||||
|
data-id="{{item.id}}"
|
||||||
|
hover-class="pd-mode-card--pressed"
|
||||||
|
hover-start-time="0"
|
||||||
|
hover-stay-time="70"
|
||||||
|
bindtap="onSelectMode">
|
||||||
|
<toy-icon
|
||||||
|
name="{{item.icon}}"
|
||||||
|
size="42rpx"
|
||||||
|
color="{{worksheetId === item.id ? '#453900' : '#605b50'}}"
|
||||||
|
custom-class="pd-mode-card__icon" />
|
||||||
|
<text class="pd-mode-card__label">{{item.title}}</text>
|
||||||
|
<text class="pd-mode-card__desc">{{item.subtitle}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<preview-footer-actions
|
||||||
|
disabled="{{!hasContent}}"
|
||||||
|
bind:primary="exportToPrint"
|
||||||
|
bind:secondary="onShare" />
|
||||||
|
|
||||||
|
<debug-publish-tools
|
||||||
|
wx:if="{{isDevEnv && hasContent}}"
|
||||||
|
id="debugPublishTools"
|
||||||
|
visible="{{debugPublishVisible}}"
|
||||||
|
loading="{{debugPublishLoading}}"
|
||||||
|
meta="{{debugPublishMeta}}"
|
||||||
|
bind:open="onOpenDebugPublish"
|
||||||
|
bind:close="onCloseDebugPublish"
|
||||||
|
bind:confirm="onConfirmDebugPublish" />
|
||||||
|
|
||||||
|
<share-guide-popup
|
||||||
|
show="{{showShareDialog}}"
|
||||||
|
bind:onClose="onCloseShareDialog"
|
||||||
|
bind:onShareSuccess="onShareSuccess" />
|
||||||
@@ -24,12 +24,19 @@
|
|||||||
"miniprogram": {
|
"miniprogram": {
|
||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
"name": "chinesePages/handwritingSheet/handwritingSheet",
|
"name": "pinyinPages/pinyinDictation/pinyinDictation",
|
||||||
"pathName": "chinesePages/handwritingSheet/handwritingSheet",
|
"pathName": "pinyinPages/pinyinDictation/pinyinDictation",
|
||||||
"query": "",
|
"query": "",
|
||||||
"scene": null,
|
"scene": null,
|
||||||
"launchMode": "default"
|
"launchMode": "default"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "chinesePages/handwritingSheet/handwritingSheet",
|
||||||
|
"pathName": "chinesePages/handwritingSheet/handwritingSheet",
|
||||||
|
"query": "",
|
||||||
|
"launchMode": "default",
|
||||||
|
"scene": null
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "chinesePages/wordColoring/wordColoring",
|
"name": "chinesePages/wordColoring/wordColoring",
|
||||||
"pathName": "chinesePages/wordColoring/wordColoring",
|
"pathName": "chinesePages/wordColoring/wordColoring",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: create-draw-page
|
name: create-draw-page
|
||||||
description: >-
|
description: >-
|
||||||
基于现有 draw 页面模板创建新的绘制页(worksheet page)。
|
基于现有 draw 页面模板创建新的绘制页(worksheet page)。
|
||||||
当用户说「基于现有的draw页面来开发新页面」「基于draw页面开发新页面」
|
当用户说「基于现有的draw页面来开发新页面」「基于draw页面开发新页面」
|
||||||
「新建绘制页」「创建一个新的draw页面」「新增一个worksheet页」时触发。
|
「新建绘制页」「创建一个新的draw页面」「新增一个worksheet页」时触发。
|
||||||
---
|
---
|
||||||
|
|
||||||
# 创建新的绘制页(Draw Page)
|
# 创建新的绘制页(Draw Page)
|
||||||
@@ -45,14 +45,14 @@ newPage/
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
interface XxxWorksheetDefinition {
|
interface XxxWorksheetDefinition {
|
||||||
id: string; // 唯一标识,格式:{pageName}-{variant}
|
id: string; // 唯一标识,格式:{pageName}-{variant}
|
||||||
icon: string; // 图标名(使用 toy-icon 支持的图标)
|
icon: string; // 图标名(使用 toy-icon 支持的图标)
|
||||||
title: string; // 标题(简短,4-6 字)
|
title: string; // 标题(简短,4-6 字)
|
||||||
subtitle: string; // 副标题(描述该模式的特点)
|
subtitle: string; // 副标题(描述该模式的特点)
|
||||||
ageMin: number; // 最小适用年龄
|
ageMin: number; // 最小适用年龄
|
||||||
ageMax: number; // 最大适用年龄
|
ageMax: number; // 最大适用年龄
|
||||||
difficulty: 1 | 2 | 3 | 4; // 难度等级
|
difficulty: 1 | 2 | 3 | 4; // 难度等级
|
||||||
tags: string[]; // 标签数组
|
tags: string[]; // 标签数组
|
||||||
sortOrder: number; // 排序权重
|
sortOrder: number; // 排序权重
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +80,7 @@ export const XXX_WORKSHEET_DEFINITIONS = [
|
|||||||
- 模式选项常量(如 `XXX_MODE_OPTIONS`)
|
- 模式选项常量(如 `XXX_MODE_OPTIONS`)
|
||||||
|
|
||||||
`getPublishMetaByMode` 中的 `category` 和 `subcategory` 需根据页面所属学科正确设置:
|
`getPublishMetaByMode` 中的 `category` 和 `subcategory` 需根据页面所属学科正确设置:
|
||||||
|
|
||||||
- `category`:`math` | `puzzle` | `pinyin` | `chinese` | `english` | `craft`
|
- `category`:`math` | `puzzle` | `pinyin` | `chinese` | `english` | `craft`
|
||||||
- `subcategory`:页面功能简称,如 `letter-tracing`、`word-coloring`
|
- `subcategory`:页面功能简称,如 `letter-tracing`、`word-coloring`
|
||||||
|
|
||||||
@@ -214,6 +215,7 @@ createPage(
|
|||||||
```
|
```
|
||||||
|
|
||||||
`createPage` 自动混入的公共方法包括:
|
`createPage` 自动混入的公共方法包括:
|
||||||
|
|
||||||
- `initCanvas` / `initCanvasFromComponent` — Canvas 初始化
|
- `initCanvas` / `initCanvasFromComponent` — Canvas 初始化
|
||||||
- `exportToPrint` — 导出打印
|
- `exportToPrint` — 导出打印
|
||||||
- `onShareAppMessage` / `onShareTimeline` — 分享
|
- `onShareAppMessage` / `onShareTimeline` — 分享
|
||||||
@@ -237,6 +239,7 @@ page {
|
|||||||
```
|
```
|
||||||
|
|
||||||
关键设计 token(来自 `theme.less`):
|
关键设计 token(来自 `theme.less`):
|
||||||
|
|
||||||
- 页面背景:`@bg-page`
|
- 页面背景:`@bg-page`
|
||||||
- 卡片背景:`@bg-card`
|
- 卡片背景:`@bg-card`
|
||||||
- 品牌色/选中态:`@brand`
|
- 品牌色/选中态:`@brand`
|
||||||
|
|||||||
@@ -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()
|
||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user