feat: 完成绘制汉字基础功能

This commit is contained in:
R524809
2025-10-17 17:48:34 +08:00
parent 7ac81eb961
commit 2734b9de9e
18 changed files with 16428 additions and 299 deletions
+13 -1
View File
@@ -4,7 +4,7 @@
"pages/shape/index",
"pages/debug/debug",
"pages/wordDemo/index",
"pages/copyBook/index",
"pages/copyBook/copyBook",
"pages/buttonTest/buttonTest"
],
"window": {},
@@ -42,6 +42,18 @@
"iconPath": "assets/tabBar/icon-shape.png",
"selectedIconPath": "assets/tabBar/icon-shape-active.png",
"text": "图形"
},
{
"pagePath": "pages/copyBook/copyBook",
"iconPath": "assets/tabBar/icon-shape.png",
"selectedIconPath": "assets/tabBar/icon-shape-active.png",
"text": "练字"
},
{
"pagePath": "pages/wordDemo/index",
"iconPath": "assets/tabBar/icon-shape.png",
"selectedIconPath": "assets/tabBar/icon-shape-active.png",
"text": "练字2"
}
]
}
@@ -18,6 +18,10 @@ Component({
type: String,
value: '',
},
disabledColor: {
type: Boolean,
value: false,
},
},
/**
* 组件的初始数据
@@ -27,7 +31,7 @@ Component({
color: '',
},
lifetimes: {
attached() {},
attached() { },
},
/**
* 组件的方法列表
@@ -1,5 +1,6 @@
<view class="word-card">
<view
wx:if="{{!disabledColor}}"
class="color-box"
style="background-color: {{color}};"
bindtap="onColorTap"></view>
@@ -30,12 +30,12 @@ Component({
ready() {
},
},
observers: {
/* observers: {
cardList(newVal: CardList) {
const selectedWords = newVal.map((item) => item.word);
this.setData({ selectedWords });
},
},
}, */
/**
* 组件的方法列表
*/
+137
View File
@@ -0,0 +1,137 @@
{
"其": [
"M8.1 14.04 L9.72 15.66 L27 13.5 L44.82 11.88 L42.66 10.26 L39.96 10.8 L27 12.42 Z",
"M18.36 35.1 L18.36 7.02 L19.44 4.86 L16.2 1.62 L16.74 7.02 L16.74 35.1 Z",
"M34.56 18.9 L34.56 5.4 L35.64 3.24 L32.4 0 L32.94 5.4 L32.94 18.9 L31.86 34.02 L33.48 34.02 Z",
"M17.82 20.52 L18.9 21.6 L24.3 21.06 L29.7 20.52 L27.54 18.9 L25.38 19.44 L24.3 19.98 Z",
"M17.82 28.08 L18.9 29.16 L24.3 28.62 L29.7 28.08 L28.08 26.46 L24.3 27.54 Z",
"M2.7 36.18 L4.32 37.8 L26.46 35.64 L48.6 34.02 L45.9 31.86 L43.2 32.94 L26.46 34.56 Z",
"M19.98 38.34 L18.9 40.5 L16.74 42.66 L11.34 46.98 L8.1 49.14 L4.86 50.76 L6.48 50.76 L10.26 48.6 L16.74 44.28 L21.06 42.12 Z",
"M41.58 49.14 L41.58 45.36 L35.1 41.58 L28.62 38.34 Z"
],
"值": [
"M15.12 9.18 L17.82 5.94 L15.66 2.16 L14.58 6.48 L10.26 14.58 L4.32 22.14 L1.08 25.38 L2.16 25.38 L7.56 19.98 Z",
"M11.88 42.66 L11.88 14.58 L10.8 14.58 L10.8 42.66 L9.72 46.44 L11.88 50.22 Z",
"M16.74 12.42 L18.36 14.04 L31.86 11.88 L45.36 10.8 L43.2 8.64 L39.42 9.72 L31.86 10.8 Z",
"M31.32 9.18 L31.32 5.94 L32.4 3.78 L29.16 0.54 L29.7 5.94 L29.7 9.18 L29.16 18.36 L30.24 18.36 Z",
"M23.22 44.82 L23.22 19.98 L23.22 18.9 L21.6 16.2 L21.6 18.36 L22.14 19.98 L22.14 44.82 Z",
"M38.34 43.74 L39.96 19.44 L41.58 17.82 L39.42 15.66 L37.26 17.82 L23.76 18.9 L23.76 19.98 L38.34 18.9 L36.72 43.74 Z",
"M22.68 25.38 L23.76 26.46 L29.16 25.92 L34.56 24.84 L33.48 23.76 L29.16 24.84 Z",
"M22.68 31.32 L23.76 32.4 L28.62 31.86 L34.02 31.32 L32.94 30.24 L28.62 30.78 Z",
"M22.68 37.8 L23.76 38.88 L28.62 38.34 L34.02 37.26 L32.94 36.18 L28.62 37.26 Z",
"M14.04 45.36 L15.66 46.98 L31.86 45.36 L48.06 44.82 L45.9 42.66 L42.12 43.74 L31.86 44.28 Z"
],
"窿": [
"M27 5.94 L27.54 3.24 L22.68 0.54 Z",
"M9.72 4.86 L8.64 9.72 L6.48 14.04 L7.56 16.2 L8.64 14.04 L9.72 10.26 Z",
"M42.66 8.64 L43.2 7.56 L45.36 5.4 L43.2 3.24 L41.04 5.4 L10.26 8.1 L10.26 9.18 L41.58 6.48 L41.04 8.64 L39.96 11.34 Z",
"M7.56 18.9 L15.12 15.12 L19.44 12.42 L21.06 11.34 L20.52 8.64 L16.2 12.96 L11.34 16.2 L7.02 18.9 Z",
"M30.24 9.18 L29.7 9.18 L36.18 12.96 L41.58 16.74 L40.5 13.5 Z",
"M17.82 22.68 L18.9 20.52 L21.06 18.36 L18.9 16.2 L16.74 18.36 L10.26 19.44 L10.26 20.52 L17.82 19.44 L16.74 22.68 L15.12 25.92 L16.74 27.54 L18.36 30.78 L18.36 32.94 L17.82 35.64 L11.88 32.94 L17.28 38.34 L18.9 36.72 L19.44 34.56 L19.44 30.78 L17.28 26.46 L16.2 25.38 Z",
"M10.8 43.2 L10.8 21.06 L10.8 19.98 L9.18 17.28 L9.18 19.44 L9.72 21.06 L9.72 43.2 L8.64 46.98 L10.8 50.76 Z",
"M21.06 25.38 L25.92 19.98 L27.54 16.2 L29.7 14.04 L27.54 10.8 L27.54 13.5 L24.84 19.98 L20.52 25.38 Z",
"M35.64 20.52 L37.26 18.36 L38.88 16.74 L36.72 15.12 L34.56 16.74 L27 17.28 L27 18.36 L35.64 17.82 L34.02 20.52 L29.16 25.38 L25.38 28.08 L19.98 31.32 L19.98 31.86 L26.46 28.62 L32.4 23.76 Z",
"M42.12 31.32 L49.68 29.7 L42.12 29.16 L36.18 25.92 L29.16 20.52 L27 20.52 L30.24 22.68 Z",
"M24.84 29.16 L25.92 30.24 L31.86 29.16 L37.8 28.08 L36.72 27 L34.02 27.54 L31.86 28.08 Z",
"M19.98 42.12 L23.76 36.72 L25.38 32.94 L23.76 31.32 L23.22 35.1 L19.44 42.12 Z",
"M23.22 35.1 L24.3 36.18 L32.94 35.1 L42.12 34.02 L39.96 32.4 L36.72 33.48 L32.94 34.02 Z",
"M23.76 41.04 L24.84 42.12 L32.94 41.04 L41.04 40.5 L38.88 38.88 L36.72 39.42 L32.94 39.96 Z",
"M32.4 47.52 L32.4 32.4 L32.94 30.78 L30.24 28.08 L30.24 29.16 L30.78 32.4 L30.78 47.52 Z",
"M17.82 48.06 L19.44 49.68 L32.94 48.06 L46.44 46.98 L44.28 45.36 L41.58 45.9 L32.94 46.98 Z"
],
"怪": [
"M7.02 12.42 L5.4 19.44 L3.78 23.76 L4.86 26.46 L5.4 25.38 L6.48 21.06 Z",
"M19.98 17.82 L19.44 14.58 L12.96 11.88 Z",
"M12.96 43.74 L12.96 24.84 L12.96 5.4 L14.04 2.7 L10.8 0.54 L11.34 2.16 L11.88 5.4 L11.88 24.3 L11.88 43.74 L10.26 46.98 L12.42 50.76 L12.96 47.52 Z",
"M37.26 10.26 L39.42 7.02 L41.58 4.86 L39.42 2.7 L37.26 4.86 L25.92 6.48 L20.52 7.02 L23.22 8.64 L25.92 7.56 L37.8 5.94 L35.64 10.26 L29.7 18.36 L22.68 25.38 L14.58 31.32 L15.66 31.32 L23.22 25.92 L31.32 18.9 Z",
"M41.58 28.62 L50.22 27.54 L44.28 26.46 L39.96 24.3 L34.02 19.98 L25.38 11.88 L24.84 10.8 L22.14 10.8 L25.92 13.5 Z",
"M19.44 35.1 L21.06 36.72 L31.32 35.1 L42.12 34.02 L39.96 31.86 L36.18 32.94 L31.32 34.02 Z",
"M31.32 44.82 L31.32 28.08 L32.4 25.38 L30.24 23.76 L29.16 22.68 L28.62 22.68 L29.16 26.46 L29.7 28.08 L29.7 44.82 Z",
"M15.12 45.36 L16.74 46.98 L32.4 45.9 L48.06 44.82 L45.9 42.66 L42.12 43.74 L32.4 44.82 Z"
],
"活": [
"M9.72 5.4 L9.18 5.4 L12.42 9.18 L15.12 12.96 L15.66 9.18 Z",
"M5.94 19.44 L4.86 19.44 L7.56 22.14 L10.8 25.92 L11.34 22.68 Z",
"M18.36 19.44 L18.36 17.82 L10.26 42.66 L9.72 43.2 L5.94 39.96 L5.4 39.96 L10.8 46.44 Z",
"M41.58 5.4 L38.34 2.7 L35.1 5.4 L27 8.64 L18.9 11.34 L23.76 10.8 Z",
"M18.36 21.06 L19.98 22.68 L33.48 20.52 L46.98 19.44 L44.82 17.28 L41.04 18.36 L33.48 19.44 Z",
"M31.86 16.74 L31.86 11.88 L32.4 10.26 L29.7 7.56 L29.7 8.64 L30.24 11.88 L30.24 16.74 L29.7 31.32 L31.32 31.32 Z",
"M23.76 44.28 L23.76 32.4 L23.76 31.32 L22.14 28.62 L22.14 30.78 L22.68 32.4 L22.68 44.28 L21.6 46.98 L23.22 49.68 L23.76 46.44 Z",
"M38.34 42.66 L39.96 32.4 L42.12 30.24 L39.96 28.62 L38.34 30.24 L23.76 32.4 L23.76 33.48 L38.34 31.32 L36.72 42.66 Z",
"M23.22 44.28 L24.3 45.36 L31.86 44.82 L39.96 44.28 L38.34 42.66 L31.86 43.74 Z"
],
"甜": [
"M23.22 7.02 L21.06 4.86 L18.9 7.02 L4.32 12.96 L5.4 12.96 Z",
"M2.16 20.52 L3.78 22.14 L14.04 19.98 L24.84 18.36 L22.68 16.74 L19.44 17.82 L14.04 18.9 Z",
"M15.12 29.16 L15.12 9.72 L13.5 9.72 L13.5 29.16 Z",
"M9.72 42.12 L9.72 31.86 L9.72 30.24 L7.56 26.46 L7.56 28.62 L8.1 31.86 L8.1 42.12 L7.56 44.28 L9.72 46.98 Z",
"M19.44 39.96 L19.98 29.7 L21.6 28.08 L19.44 26.46 L17.82 28.08 L9.72 29.16 L9.72 30.24 L18.36 29.16 L17.82 39.96 Z",
"M9.18 41.04 L10.26 42.12 L15.66 41.04 L21.06 40.5 L19.98 39.42 L15.66 39.96 Z",
"M21.6 21.06 L23.22 22.68 L36.18 20.52 L49.14 18.9 L46.98 17.28 L44.28 17.82 L36.18 19.44 Z",
"M29.7 43.74 L29.7 25.38 L29.7 6.48 L30.24 4.86 L27.54 2.16 L27.54 3.24 L28.08 6.48 L28.08 24.84 L28.08 43.74 L27 46.44 L29.16 50.22 L29.7 46.44 Z",
"M42.12 8.1 L42.12 6.48 L43.2 4.32 L39.96 1.08 L40.5 6.48 L40.5 8.1 L38.88 40.5 L37.8 45.9 L39.42 48.6 L40.5 40.5 Z",
"M28.62 31.86 L29.7 32.94 L32.94 32.4 L36.72 31.86 L35.64 30.78 L32.94 31.32 Z",
"M28.62 44.82 L29.7 45.9 L33.48 45.36 L37.26 44.82 L35.64 43.2 L33.48 44.28 Z"
],
"赛": [
"M24.84 0.54 L24.3 0.54 L25.92 3.78 L27 7.02 L28.62 4.32 Z",
"M11.88 5.94 L10.8 10.26 L8.64 14.04 L10.26 16.2 L10.8 14.58 L11.88 10.26 Z",
"M43.2 9.18 L44.28 7.56 L45.9 5.94 L43.74 4.32 L42.12 5.94 L12.42 8.64 L12.42 9.72 L42.66 7.02 L41.58 9.18 L39.42 11.88 L40.5 11.88 Z",
"M12.96 14.58 L14.04 15.66 L26.46 14.58 L38.88 13.5 L36.72 11.88 L34.56 12.42 L26.46 13.5 Z",
"M14.04 19.44 L15.12 20.52 L27 19.44 L39.42 18.36 L37.26 16.74 L35.1 17.28 L27 18.36 Z",
"M22.14 23.76 L22.14 14.04 L22.68 11.34 L19.98 8.64 L20.52 10.26 L21.06 14.04 L21.06 23.76 Z",
"M31.86 16.2 L31.86 11.88 L32.4 10.26 L29.7 7.56 L29.7 8.64 L30.24 11.88 L30.24 16.2 L29.7 23.76 L30.78 23.76 Z",
"M5.94 25.38 L7.56 27 L26.46 24.84 L45.9 23.22 L43.74 21.6 L40.5 22.68 L26.46 23.76 Z",
"M17.28 26.46 L12.42 31.32 L7.02 35.1 L2.16 37.8 L3.24 37.8 L9.18 34.56 L14.04 31.32 L17.82 28.08 L20.52 25.38 Z",
"M42.12 35.64 L49.14 35.1 L43.2 33.48 L38.34 30.24 L32.4 24.3 L30.78 24.3 Z",
"M18.9 38.88 L18.9 32.4 L18.9 31.32 L17.28 28.62 L17.28 30.78 L17.82 32.4 L17.82 38.88 L17.28 40.5 L18.9 42.12 Z",
"M34.02 36.72 L34.02 30.78 L35.1 29.7 L33.48 28.08 L32.4 29.16 L18.36 30.78 L18.36 31.86 L32.94 30.24 L32.94 36.72 L31.86 40.5 L33.48 42.12 L34.02 38.88 Z",
"M24.84 32.94 L24.84 36.18 L22.68 42.12 L19.98 45.36 L16.2 48.06 L10.8 50.76 L13.5 50.22 L19.44 46.98 L23.22 43.2 L25.38 38.88 L27 34.56 Z",
"M28.08 42.66 L34.56 46.98 L38.34 49.68 L37.8 46.44 L31.86 43.74 L28.62 42.66 Z"
],
"省": [
"M25.92 11.88 L25.92 5.4 L27 2.7 L23.76 0.54 L24.3 2.16 L24.84 5.4 L24.84 11.88 L23.76 15.12 L25.92 17.28 Z",
"M17.82 6.48 L16.2 10.26 L12.96 14.04 L5.94 21.06 L7.56 20.52 L11.88 16.74 L17.82 10.8 L19.44 9.72 L19.44 9.18 Z",
"M41.04 12.96 L43.74 16.2 L44.28 12.42 L38.88 8.64 L34.56 6.48 Z",
"M34.02 9.72 L32.94 11.34 L28.62 15.66 L21.06 21.06 L17.28 23.76 L9.72 28.08 L2.7 31.32 L3.78 31.32 L7.02 30.24 L11.34 28.08 L17.28 24.84 L22.68 21.6 L29.16 16.74 L35.1 12.96 Z",
"M17.28 45.36 L17.28 28.62 L17.28 27.54 L15.66 24.84 L15.66 27 L16.2 28.62 L16.2 45.36 L15.12 48.06 L16.74 50.76 L17.28 47.52 Z",
"M36.72 39.42 L37.26 25.38 L38.88 23.76 L36.72 21.6 L34.56 23.76 L17.28 26.46 L17.28 27.54 L35.64 24.84 L35.1 39.42 L34.56 43.2 L34.02 46.98 L35.64 49.68 L36.18 45.9 Z",
"M16.74 32.94 L17.82 34.02 L24.84 32.94 L32.4 32.4 L30.78 30.78 L24.84 31.86 Z",
"M16.74 39.42 L17.82 40.5 L24.84 39.42 L31.86 38.88 L30.24 37.26 L24.84 38.34 Z",
"M17.28 45.9 L18.36 46.98 L25.92 46.44 L34.02 45.9 L31.86 44.28 L29.7 44.82 L25.92 45.36 Z"
],
"边": [
"M38.34 28.62 L40.5 17.82 L42.12 14.58 L39.96 12.96 L38.34 15.12 L24.3 17.28 L23.76 17.28 L18.9 17.82 L20.52 18.9 L23.76 18.36 L24.3 18.36 L38.88 16.2 L36.72 28.62 L34.56 36.72 L34.02 37.26 L32.4 36.18 L30.24 34.02 L28.62 33.48 L30.24 35.1 L32.4 38.34 L34.02 41.04 L36.72 35.1 Z",
"M31.32 10.8 L31.32 6.48 L32.94 3.78 L29.16 0.54 L29.7 6.48 L29.7 10.8 L29.16 15.66 L27.54 24.3 L25.38 29.7 L22.14 36.18 L18.9 39.42 L19.98 39.42 L22.68 36.72 L27.54 28.62 L28.62 25.38 L30.78 15.66 Z",
"M9.72 6.48 L9.18 6.48 L11.88 9.72 L13.5 11.34 L15.66 14.58 L16.74 10.8 Z",
"M1.62 23.22 L3.78 24.84 L14.04 22.68 L11.88 30.24 L14.04 34.56 L13.5 37.8 L11.88 40.5 L12.96 40.5 L14.58 36.18 L15.12 35.1 L15.12 33.48 L12.96 29.7 L16.2 22.68 L17.28 21.06 L15.66 19.44 L12.96 22.14 Z",
"M11.88 40.5 L2.7 40.5 L4.86 42.66 L8.1 41.58 L12.42 41.58 L22.68 43.74 L27.54 44.82 L39.96 49.14 L48.6 46.44 L42.66 46.44 L38.88 46.44 L35.1 45.9 L25.38 43.2 L17.28 41.58 L12.96 40.5 Z"
],
"兰": [
"M15.12 3.78 L14.58 3.78 L16.74 7.02 L20.52 15.12 L22.14 11.88 Z",
"M34.02 1.08 L34.02 2.7 L33.48 4.86 L28.62 17.82 L29.7 17.82 L34.56 7.56 L36.18 5.94 Z",
"M8.64 19.98 L10.26 21.6 L26.46 19.44 L43.2 18.9 L40.5 16.74 L37.8 17.82 L26.46 18.36 Z",
"M12.96 31.32 L14.58 32.94 L25.92 31.32 L37.26 30.24 L35.1 28.62 L32.94 29.16 L25.92 30.24 Z",
"M3.78 45.36 L5.4 46.98 L25.38 45.9 L45.9 44.82 L43.74 42.66 L39.96 43.74 L25.38 44.82 Z"
],
"掌": [
"M25.92 11.88 L25.92 4.86 L27 2.16 L23.76 0 L24.3 1.62 L24.84 4.86 L24.84 11.88 Z",
"M18.9 10.8 L19.44 7.56 L14.04 3.24 L13.5 3.24 Z",
"M35.1 1.62 L34.56 2.7 L34.02 5.4 L30.24 11.34 L30.78 11.34 L34.56 6.48 L36.72 4.86 Z",
"M10.26 9.72 L9.72 11.88 L9.18 15.12 L7.02 19.98 L8.1 22.68 L9.18 20.52 L10.26 15.12 Z",
"M42.66 10.8 L27 11.88 L9.72 14.04 L10.8 15.12 L27 12.96 L42.525 11.88 L43.2 11.88 L40.5 17.82 L44.28 12.96 L46.98 10.8 L44.82 8.64 L43.2 10.26 Z",
"M18.9 23.76 L18.9 19.98 L18.9 18.9 L17.28 16.2 L17.28 18.36 L17.82 19.98 L17.82 23.76 L17.28 25.38 L18.9 27 Z",
"M32.4 21.6 L32.94 17.82 L34.56 16.2 L32.4 14.58 L30.78 16.2 L18.9 17.82 L18.9 18.9 L31.86 17.28 L31.32 21.6 Z",
"M18.36 23.76 L19.44 24.84 L26.46 23.76 L33.48 22.68 L32.4 21.6 L26.46 22.68 Z",
"M9.18 31.32 L16.2 30.78 L37.8 26.46 L35.64 24.84 L31.32 26.46 Z",
"M12.96 34.56 L14.04 35.64 L26.46 33.48 L38.88 32.4 L36.72 30.78 L34.56 31.32 L26.46 32.4 Z",
"M4.86 39.42 L6.48 41.04 L25.92 38.88 L45.36 37.26 L43.2 35.1 L39.42 36.18 L25.92 37.8 Z",
"M26.46 47.52 L26.46 28.62 L24.84 28.62 L24.84 47.52 L24.3 48.06 L17.28 44.82 L25.38 51.3 Z"
],
"从": [
"M18.9 9.72 L18.9 8.1 L20.52 5.4 L16.74 2.16 L17.28 8.1 L17.28 9.72 L16.2 17.82 L15.12 24.3 L12.96 30.78 L10.8 36.18 L5.4 44.28 L2.16 47.52 L3.78 46.98 L6.48 44.28 L11.88 36.18 L14.04 30.78 L16.74 22.68 Z",
"M16.2 22.68 L16.74 24.3 L20.52 28.08 L23.76 31.86 L24.3 28.62 Z",
"M34.02 13.5 L34.02 6.48 L35.64 3.78 L31.86 0.54 L32.4 6.48 L32.4 13.5 L31.32 18.9 L30.24 25.38 L27.54 32.94 L24.84 38.88 L21.06 44.28 L16.2 49.14 L17.82 48.6 L23.22 43.2 L27.54 36.72 L30.24 29.16 L32.4 22.14 Z",
"M42.12 45.36 L49.68 44.82 L45.9 43.74 L43.2 42.66 L38.88 35.1 L32.4 22.14 L31.86 23.22 Z"
]
}
File diff suppressed because it is too large Load Diff
+94 -20
View File
@@ -56,9 +56,11 @@
li {
background: url(images/bg12.jpg);
}
.afterpage {
page-break-after: always;
}
.noprint {
display: none;
}
@@ -80,73 +82,145 @@
</div>
<script id="myword" charset="UTF-8" src="mwords.json"></script>
<script>
// 测试输出:打印汉字"舨"的笔画数据
console.log(words["舨"]);
// ========== 模板定义 ==========
// SVG田字格模板:每个格子包含一个54x54的SVG画布
let template_li = '<li class="svg"><svg width="54" height="54" version="1.1" xmlns="http://www.w3.org/2000/svg">##paths##</svg></li>';
// 空白格子模板:用于填充行末
let empty_li1 = '<li class="f">&nbsp;</li>';
let empty_li2 = '';
for (let i = 0; i < 11; i++) { empty_li2 += "<li>&nbsp;</li>" }
let first_paths = '<path d="##path_d##" style="fill:rgb(0,0,0);stroke:rgb(0,0,0);" stroke-width="1.5"></path>';
let second_paths = '<path d="##path_d##" style="fill:rgb(184,184,184);stroke:rgb(184,184,184);" stroke-width="1.6"></path>';
second_paths = '<path d="##path_d##" style="fill:rgb(184,184,184);stroke:#999;" stroke-width="1.6"></path>';
let d_key = '##path_d##';
let p_key = '##paths##';
u_printf.innerHTML = "";
let preview = (c) => {
// 生成11个空白格子,用于填充到每行末尾(每行总共12个格子)
for (let i = 0; i < 11; i++) {
empty_li2 += "<li>&nbsp;</li>"
}
// 笔画路径模板定义
// 完整汉字路径:黑色填充和描边,用于预览格
let first_paths = '<path d="##path_d##" style="fill:rgb(0,0,0);stroke:rgb(0,0,0);" stroke-width="1.5"></path>';
// 练习汉字路径:灰色填充和描边,用于练习格
let second_paths = '<path d="##path_d##" style="fill:rgb(184,184,184);stroke:#999;" stroke-width="1.6"></path>';
// 占位符定义
let d_key = '##path_d##'; // 笔画路径占位符
let p_key = '##paths##'; // SVG路径组占位符
// 清空输出区域
u_printf.innerHTML = "";
// ========== 核心函数定义 ==========
/**
* 生成汉字的预览格(完整汉字显示)
* @param {string} c - 汉字字符
* @returns {string} - 预览格的HTML
*/
let preview = (c) => {
let pre_li = "";
// 获取汉字的笔画数据
ds = words[c];
if (!ds) {
return;
return; // 如果没有找到笔画数据,直接返回
}
len = ds.length;
f_path = "";
len = ds.length; // 笔画总数
f_path = ""; // 完整路径字符串
// 遍历所有笔画,生成完整的SVG路径
for (let j = 0; j < len; j++) {
// 将每个笔画路径添加到完整路径中,使用黑色样式
f_path += first_paths.replace(d_key, ds[j]);
}
pre_li = template_li.replace(p_key, f_path);;
// 将完整路径插入到SVG模板中
pre_li = template_li.replace(p_key, f_path);
return pre_li;
};
/**
* 生成汉字的练习格(逐笔画显示)
* @param {string} c - 汉字字符
* @returns {string} - 练习格的HTML
*/
let practice = (c) => {
// 获取汉字的笔画数据
ds = words[c];
if (!ds) {
return;
return; // 如果没有找到笔画数据,直接返回
}
len = ds.length;
let bihua_li = "";
let s_path = "";
len = ds.length; // 笔画总数
let bihua_li = ""; // 练习格HTML字符串
let s_path = ""; // 当前路径字符串
// 生成逐笔画练习格:每个格子显示到当前笔画为止的汉字
for (let bihua = 0; bihua < len; bihua++) {
s_path = "";
s_path = ""; // 重置路径
// 累加从第1笔到当前笔画的所有路径
for (let i = 0; i <= bihua; i++) {
// 将每个笔画路径添加到当前路径中,使用灰色样式
s_path += second_paths.replace(d_key, ds[i]);
}
// 将当前路径插入到SVG模板中,生成一个练习格
bihua_li += template_li.replace(p_key, s_path);
}
// 填充剩余空白格:如果汉字笔画少于23个,用最后一个练习格填充
for (let i = 0; i < 23 - len; i++) {
bihua_li += template_li.replace(p_key, s_path);
}
// 添加行末填充:1个特殊空白格 + 11个普通空白格
bihua_li += empty_li1;
bihua_li += empty_li2;
return bihua_li;
}
/**
* 生成单个汉字的完整练字格(预览格 + 练习格)
* @param {string} c - 汉字字符
* @returns {string} - 完整练字格的HTML
*/
let print_char = (c) => {
// 组合预览格和练习格
return preview(c) + practice(c);
}
/**
* 生成整个句子的练字格
* @param {string} s - 汉字字符串
* @returns {string} - 整个句子的练字格HTML
*/
let print_sentence = (s) => {
sen_li = "";
sen_li = ""; // 句子HTML字符串
// 遍历每个汉字,生成对应的练字格
for (let i in s) {
sen_li += print_char(s[i]);
}
return sen_li;
}
/**
* 生成按钮点击事件处理函数
* 根据用户输入的文本生成练字贴
*/
onclick_gen = () => {
// 清空输出区域
u_printf.innerHTML = "";
lis = print_sentence(to_print.value.replace(/ /g,"").trim());
// 获取用户输入,去除空格并生成练字贴
lis = print_sentence(to_print.value.replace(/ /g, "").trim());
// 将生成的HTML插入到页面中
u_printf.innerHTML = lis;
}
+125
View File
@@ -0,0 +1,125 @@
# SVG 路径分析 - 汉字笔画解析
## 📝 概述
本文档详细解析汉字练字贴中使用的 SVG 路径数据,帮助理解汉字笔画的数字化表示方法。
## 🎯 示例路径分析
### 路径片段
```
"M15.66 8.1 L15.66 7.56 L17.28 4.86 L13.5 1.62 L14.04 7.56 L14.04 8.1 L9.18 32.4 L16.2 36.18 L23.76 42.12 L23.76 38.88 L10.8 31.86 Z"
```
## 🔧 SVG 路径命令
### 基本命令
- **M** = MoveTo(移动到)- 开始一个新的子路径,不绘制线条
- **L** = LineTo(画线到)- 从当前位置画直线到指定点
- **Z** = ClosePath(闭合路径)- 闭合当前子路径,连接起点和终点
### 坐标系统
- 使用 54x54 的视窗坐标系
- 坐标原点 (0,0) 在左上角
- X 轴向右递增,Y 轴向下递增
- 坐标范围:0-54
## 📊 逐点分析
| 命令 | 坐标 | 描述 | 移动方向 |
| ---- | -------------- | -------------- | ---------- |
| M | (15.66, 8.1) | 起始点 | - |
| L | (15.66, 7.56) | 向上移动 | ↑ |
| L | (17.28, 4.86) | 向右上移动 | ↗ |
| L | (13.5, 1.62) | 向左上移动 | ↖ |
| L | (14.04, 7.56) | 向右下移动 | ↘ |
| L | (14.04, 8.1) | 向下移动 | ↓ |
| L | (9.18, 32.4) | 向左下大幅移动 | ↙ |
| L | (16.2, 36.18) | 向右下移动 | ↘ |
| L | (23.76, 42.12) | 向右下移动 | ↘ |
| L | (23.76, 38.88) | 向上移动 | ↑ |
| L | (10.8, 31.86) | 向左上移动 | ↖ |
| Z | - | 闭合路径 | 回到起始点 |
## 🖌️ 笔画特征分析
### 形状特征
1. **起始区域**:在坐标 (15.66, 8.1) 附近,位于画布上方
2. **转折点**:包含多个转折,形成复杂的曲线
3. **主要移动**:从上方区域移动到下方区域
4. **闭合性**:使用 Z 命令闭合路径
### 书法特征
- **复杂笔画**:包含多个方向的线条变化
- **曲线特征**:有明显的弯曲和转折
- **可能类型**:撇捺组合、弯钩笔画或复合笔画
## 🎨 在汉字中的应用
### 汉字"好"的笔画结构
这个路径很可能是汉字"好"字中的某个重要笔画:
#### 可能的组成部分
1. **女字旁**:左边的"女"字的一部分
2. **子字旁**:右边的"子"字的一部分
3. **复合笔画**:多个基本笔画的组合
### 笔画顺序
在汉字书写中,这个笔画可能:
- 不是第一笔(起始位置不在左上角)
- 包含多个书写方向的变化
- 需要一定的书写技巧
## 🔍 技术实现
### 在代码中的使用
```typescript
// SVG 路径数据
const strokes = [
'M15.66 8.1 L15.66 7.56 L17.28 4.86 L13.5 1.62 L14.04 7.56 L14.04 8.1 L9.18 32.4 L16.2 36.18 L23.76 42.12 L23.76 38.88 L10.8 31.86 Z',
// ... 更多笔画
];
// 绘制函数
function drawSvgPathCommands(ctx, pathD, offsetX, offsetY, scale) {
// 解析并绘制 SVG 路径
}
```
### 坐标变换
- **缩放**:根据田字格大小进行缩放
- **偏移**:根据田字格位置进行偏移
- **适配**:适配不同的显示尺寸
## 📚 相关文档
- [SVG 路径规范](https://www.w3.org/TR/SVG/paths.html)
- [汉字笔画分类](https://zh.wikipedia.org/wiki/汉字笔画)
- [田字格练字原理](./tianzige-principles.md)
## 🎯 总结
这个 SVG 路径片段展示了汉字数字化的精确表示方法:
1. **精确性**:每个转折点都有精确的坐标
2. **完整性**:完整描述了笔画的形状和走向
3. **可缩放性**:通过坐标系统支持不同尺寸的显示
4. **书法性**:保持了汉字书法的艺术特征
通过这种数字化表示,我们可以在计算机上精确重现汉字的书写过程,为练字教学提供技术支持。
---
_文档创建时间:2024年_
_最后更新:2024年_
@@ -1,5 +1,9 @@
{
"navigationBarTitleText": "练字",
"navigationBarBackgroundColor": "#FFD719",
"homeButton": true,
"backgroundColor": "#F6F6F6",
"enablePullDownRefresh": false,
"usingComponents": {
"toy-button": "/ui/button/button",
"van-icon": "@vant/weapp/icon/index",
@@ -6,6 +6,10 @@ page {
background-color: #f6f6f6;
padding: 0 24rpx;
box-sizing: border-box;
.empty {
height: 120rpx;
}
}
.wrapper {
@@ -145,3 +149,37 @@ page {
color: #999;
font-size: 28rpx;
}
.canvas-wrapper {
display: flex;
justify-content: center;
align-items: center;
min-height: 400rpx;
background: #f8f9fa;
border-radius: 12rpx;
border: 2rpx dashed #dee2e6;
margin-top: 20rpx;
}
.canvas-content {
max-width: 100%;
border-radius: 8rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.bottom-btn-box {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
height: 120rpx;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #fff;
box-shadow: 0px -3.9rpx 5.2rpx rgba(0, 0, 0, 0.15);
padding: 0 24rpx;
box-sizing: border-box;
border-radius: 16rpx 16rpx 0 0;
}
+223
View File
@@ -0,0 +1,223 @@
import WordDrawService from '../../service/wordDrawService';
import { checkAndSaveImage } from '../../utils/saveImage';
import { PAPER_SIZE } from '../../constants/colors';
Page({
canvas: null as Canvas | null,
ctx: null as RenderingContext | null,
wordDrawService: null as WordDrawService | null,
svgWords: {} as Record<string, string[]>,
data: {
// 选中的汉字列表
words: ['赢', '张', '政'] as string[],
showSelectWordPopup: false,
showColorPopup: false,
currentKey: 0,
currentColor: '',
// 田字格配置
// rows: 10,
// cols: 12,
// rowsArray: [] as number[],
// colsArray: [] as number[],
boxWidth: 0,
boxHeight: 0,
},
onLoad() {
// this.initGrid();
this.loadSvgWords();
},
onReady() {
this.setCanvasBoxSize();
},
loadSvgWords() {
const fs = wx.getFileSystemManager();
fs.readFile({
filePath: 'constants/svgWords.json', // JSON文件路径,相对于项目根目录
encoding: 'utf8', // 重要:指定编码为utf8以读取文本内容
success: (res) => {
try {
wx.showToast({ title: '加载中...', icon: 'loading' });
const parsedData = JSON.parse(res.data as string);
this.svgWords = parsedData;
console.log('this.svgWords.length----:', Object.keys(this.svgWords).length)
wx.hideToast();
console.log('SVG words loaded successfully');
const { words } = this.data as any;
if (words && words.length > 0) {
this.drawPracticeSheet().catch(console.error);
}
} catch (e) {
console.error('解析JSON失败', e);
}
},
fail: (err) => {
console.error('读取文件失败', err);
wx.hideToast();
}
});
},
// 输入组件回调
onConfirmInput(e: any) {
const value: string = (e && e.detail && e.detail.value) || '';
const text = (value || '').trim();
if (!text) return;
if (!this.isChineseText(text)) {
wx.showToast({ title: '请输入汉字', icon: 'none' });
return;
}
const chars = this.splitToSingleChars(text);
const { words } = this.data as any;
const exists = new Set(words);
const newWords = [...words];
chars.forEach((ch) => {
if (!exists.has(ch)) {
newWords.push(ch);
}
});
this.setData({ words: newWords }, () => this.drawPracticeSheet().catch(console.error));
},
// 选择面板
openSelectWordPopup() {
this.setData({ showSelectWordPopup: true });
},
closeSelectWordPopup() {
this.setData({ showSelectWordPopup: false });
},
onChangeWord(e: any) {
const selectedWords = e.detail.selectedWords as string[];
const newWords = [...this.data.words, ...selectedWords];
this.setData({ words: newWords, showSelectWordPopup: false }, () => this.drawPracticeSheet().catch(console.error));
},
// 删除与颜色
deleteWordCard(e: any) {
const { word } = e.detail;
const { words } = this.data as any;
const next = words.filter((w: string) => w !== word);
this.setData({ words: next }, () => this.drawPracticeSheet().catch(console.error));
},
// 校验 & 分割
isChineseText(text: string): boolean {
const chineseRegex = /^[\u4e00-\u9fff]+$/;
return chineseRegex.test(text);
},
splitToSingleChars(text: string): string[] {
const chineseRegex = /[\u4e00-\u9fff]/g;
const matches = text.match(chineseRegex);
return matches || [];
},
// 田字格
/* initGrid() {
const { rows, cols } = this.data as any;
const rowsArray = Array.from({ length: rows }, (_, i) => i);
const colsArray = Array.from({ length: cols }, (_, i) => i);
console.log('rowsArray----:', rowsArray)
console.log('colsArray----:', colsArray)
this.setData({ rowsArray, colsArray });
}, */
setCanvasBoxSize() {
const query = wx.createSelectorQuery();
query
.select('#canvasWrapper')
.boundingClientRect((rect) => {
if (rect) {
const { width, height } = PAPER_SIZE['A4'];
const boxWidth = rect.width;
const boxHeight = boxWidth / (width / height);
this.setData({ boxWidth, boxHeight });
}
})
.exec();
},
initCanvas() {
// 然后初始化canvas
wx.createSelectorQuery()
.select('#canvasContent')
.fields({
node: true,
size: true,
})
.exec((res) => {
if (res[0] && res[0].node) {
const canvas = res[0].node;
const ctx = canvas.getContext('2d');
if (ctx) {
this.canvas = canvas;
this.ctx = ctx;
this.wordDrawService = new WordDrawService(canvas, ctx, {
appName: '涂鸦丫小程序',
appHint: '练字|识字|打印',
title: '田字格 练 字 贴',
subTitle: '按笔画临摹练习'
});
this.drawPracticeSheet().catch(console.error);
}
}
});
},
// 绘制练字贴
async drawPracticeSheet() {
if (!this.canvas || !this.wordDrawService) {
this.initCanvas();
}
const { words } = this.data as any;
if (!words || words.length === 0) {
return;
}
// 构建汉字笔画数据
const wordsMap: Record<string, string[]> = {};
console.log('this.svgWords----:', this.svgWords)
words.forEach((word: string) => {
if (this.svgWords[word]) {
wordsMap[word] = this.svgWords[word];
}
});
if (Object.keys(wordsMap).length === 0) {
wx.showToast({ title: '暂不支持这些汉字', icon: 'none' });
return;
}
await this.wordDrawService?.draw(wordsMap);
},
// 下载打印练字贴
exportToPrint() {
const { words } = this.data as any;
if (!this.canvas || words.length <= 0) {
wx.showToast({ title: '请先生成练字贴', icon: 'none' });
return;
}
checkAndSaveImage(this.canvas);
},
// 分享功能
onShareAppMessage() {
return {
title: '涂鸦丫-练字|识字|打印',
path: '/pages/copyBook/index',
};
},
onShareTimeline() {
return {
title: '涂鸦丫-练字|识字|打印',
query: '/pages/copyBook/index',
};
},
});
@@ -12,18 +12,18 @@
</view>
<view class="word-card-box">
<view class="word-cards-grid" wx:if="{{cardList.length > 0}}">
<view class="word-cards-grid" wx:if="{{words.length > 0}}">
<word-card
disabledColor="{{true}}"
wx:key="index"
wx:for="{{cardList}}"
wx:for-item="item"
wx:for="{{words}}"
wx:for-item="word"
wx:for-index="index"
word="{{item.word}}"
color="{{item.color}}"
word="{{word}}"
bind:onDelete="deleteWordCard"
bind:onColorTap="onColorTap" />
</view>
<view class="word-list-empty" wx:if="{{!cardList.length}}">
<view class="word-list-empty" wx:if="{{!words.length}}">
<view class="empty-icon">
<text class="toy-icon toy-icon-translate"></text>
</view>
@@ -37,31 +37,46 @@
<view id="previewWrapper" class="wrapper">
<text class="wrapper-title">预览练字田字格</text>
<view class="tianzige" wx:if="{{rowHeaderWords.length > 0}}">
<view
class="tzg-row"
wx:for="{{rowsArray}}"
wx:for-index="rowIndex"
wx:key="rowIndex">
<view
class="tzg-cell"
wx:for="{{colsArray}}"
wx:for-index="colIndex"
wx:key="{{rowIndex}}-{{colIndex}}">
<text wx:if="{{colIndex === 0}}" class="tzg-word"
>{{rowHeaderWords[rowIndex]}}</text
>
<view class="mid-line h"></view>
<view class="mid-line v"></view>
</view>
</view>
</view>
<view id="canvasWrapper" class="canvas-wrapper">
<canvas
wx:if="{{words}}"
type="2d"
id="canvasContent"
class="canvas-content"
style="width:{{boxWidth}}px;height:{{boxHeight}}px" />
<view wx:else class="empty-tip">请选择或输入汉字后生成田字格</view>
</view>
</view>
<view class="bottom-btn-box">
<toy-button
openType="share"
type="green"
flat="{{true}}"
bind:click="onShareAppMessage"
width="220rpx"
height="80rpx"
icon="wechat"
icon-class-prefix="toy-icon">
分享
</toy-button>
<toy-button
type="primary"
flat="{{true}}"
bind:click="exportToPrint"
width="420rpx"
height="80rpx"
disabled="{{words.length <= 0}}"
icon="download"
icon-class-prefix="toy-icon">
下载打印
</toy-button>
</view>
<word-picker
show="{{showSelectWordPopup}}"
cardList="{{cardList}}"
selectedWords="{{words}}"
bind:onClose="closeSelectWordPopup"
bind:onChange="onChangeWord" />
<view class="empty"></view>
</view>
-91
View File
@@ -1,91 +0,0 @@
Page({
data: {
// 选中字卡列表,复用 index 页的结构 [{ word, color }]
cardList: [] as { word: string; color: string }[],
showSelectWordPopup: false,
// 田字格配置
rows: 10,
cols: 12,
rowsArray: [] as number[],
colsArray: [] as number[],
rowHeaderWords: [] as string[],
},
onLoad() {
this.initGrid();
},
// 输入组件回调
onConfirmInput(e: any) {
const value: string = (e && e.detail && e.detail.value) || '';
const text = (value || '').trim();
if (!text) return;
if (!this.isChineseText(text)) {
wx.showToast({ title: '请输入汉字', icon: 'none' });
return;
}
const chars = this.splitToSingleChars(text);
const { cardList } = this.data as any;
const exists = new Set(cardList.map((c: any) => c.word));
const newList = [...cardList];
chars.forEach((ch) => {
if (!exists.has(ch)) {
newList.push({ word: ch, color: '#141414' });
}
});
this.setData({ cardList: newList }, () => this.updateRowHeaderWords());
},
// 选择面板
openSelectWordPopup() {
this.setData({ showSelectWordPopup: true });
},
closeSelectWordPopup() {
this.setData({ showSelectWordPopup: false });
},
onChangeWord(e: any) {
const { cardList } = e.detail || {};
this.setData({ cardList }, () => this.updateRowHeaderWords());
},
// 删除与颜色
deleteWordCard(e: any) {
const index = e.detail && e.detail.index;
const { cardList } = this.data as any;
const next = cardList.filter((_: any, i: number) => i !== index);
this.setData({ cardList: next }, () => this.updateRowHeaderWords());
},
onColorTap() { },
// 校验 & 分割
isChineseText(text: string): boolean {
const chineseRegex = /^[\u4e00-\u9fff]+$/;
return chineseRegex.test(text);
},
splitToSingleChars(text: string): string[] {
const chineseRegex = /[\u4e00-\u9fff]/g;
const matches = text.match(chineseRegex);
return matches || [];
},
// 田字格
initGrid() {
const { rows, cols } = this.data as any;
const rowsArray = Array.from({ length: rows }, (_, i) => i);
const colsArray = Array.from({ length: cols }, (_, i) => i);
this.setData({ rowsArray, colsArray });
},
updateRowHeaderWords() {
const { rows, cardList } = this.data as any;
const rowHeaderWords: string[] = [];
for (let i = 0; i < rows; i++) {
rowHeaderWords.push((cardList[i] && cardList[i].word) || '');
}
this.setData({ rowHeaderWords });
},
});
+8 -3
View File
@@ -12,6 +12,7 @@ Page({
data: {
cardList: [] as CardList,
selectedWords: [] as string[],
showColorPopup: false,
currentKey: 0,
currentColor: '',
@@ -43,6 +44,7 @@ Page({
{ color: '#FFFF00', word: '迎' },
{ color: '#8A2BE2', word: '您' },
],
selectedWords: ['涂', '鸦', '丫', '欢', '迎', '您'],
showIntroductionPopup: true,
env: getApp().globalData.env || 'release',
});
@@ -69,8 +71,8 @@ Page({
const boxWidth = rect.width;
const boxHeight = boxWidth / (width / height);
this.boxHeight = boxHeight;
this.boxWidth = boxWidth;
console.log('boxWidth----:', boxWidth)
console.log('boxHeight----:', boxHeight)
// 然后初始化canvas
wx.createSelectorQuery()
@@ -108,7 +110,10 @@ Page({
callback: () => void = () => { },
) {
if (updatedCardList) {
this.setData({ cardList: updatedCardList }, () => {
this.setData({
cardList: updatedCardList,
selectedWords: updatedCardList.map((item) => item.word)
}, () => {
// 如果cardList有内容且canvas未初始化,先初始化canvas
if (!this.canvas) {
this.initCanvas();
+1 -1
View File
@@ -84,7 +84,7 @@
bind:onChange="onChangeColor" />
<word-picker
show="{{showSelectWordPopup}}"
cardList="{{cardList}}"
selectedWords="{{cardList}}"
bind:onClose="closeSelectWordPopup"
bind:onChange="onChangeWord" />
</view>
+1 -1
View File
@@ -71,7 +71,7 @@ class TextDrawService {
this.paperSize = 'A4';
this.options = {
appName: '涂鸦丫小程序',
appHint: '涂色|识字|画画|打印',
appHint: '识字|识图|练字|打印',
title: '找一找 涂 色',
subTitle: '给文字涂上相同的颜色',
...options,
+462 -144
View File
@@ -1,82 +1,159 @@
import { PAPER_SIZE } from '../constants/colors';
import { getMiniCodeImage, getImage } from '../utils/index';
/**
* 仅支持 M/L/Z 的简单 SVG 路径解析与绘制
*/
function drawSvgPathCommands(
ctx: RenderingContext,
pathD: string,
offsetX: number,
offsetY: number,
scale: number,
) {
const tokens = pathD
.replace(/,/g, ' ')
.trim()
.split(/\s+/);
let i = 0;
let currentX = 0;
let currentY = 0;
while (i < tokens.length) {
const cmd = tokens[i++];
switch (cmd) {
case 'M': {
const x = parseFloat(tokens[i++]);
const y = parseFloat(tokens[i++]);
currentX = offsetX + x * scale;
currentY = offsetY + y * scale;
ctx.moveTo(currentX, currentY);
break;
}
case 'L': {
const x = parseFloat(tokens[i++]);
const y = parseFloat(tokens[i++]);
currentX = offsetX + x * scale;
currentY = offsetY + y * scale;
ctx.lineTo(currentX, currentY);
break;
}
case 'Z': {
ctx.closePath();
break;
}
default: {
// 遇到数字(可能因为路径省略了连续的 L),回退一步并按 L 处理
if (!isNaN(parseFloat(cmd))) {
i--;
const x = parseFloat(tokens[i++]);
const y = parseFloat(tokens[i++]);
currentX = offsetX + x * scale;
currentY = offsetY + y * scale;
ctx.lineTo(currentX, currentY);
break;
}
// 其他命令不支持,直接跳过
break;
}
}
}
interface DrawSvgPathCommandsParams {
ctx: RenderingContext;
pathD: string;
offsetX: number;
offsetY: number;
scale: number;
}
function drawStrokes(
ctx: RenderingContext,
strokes: string[],
offsetX: number,
offsetY: number,
size: number,
uptoInclusive: number,
fillStyle: string,
strokeStyle: string,
lineWidth: number,
) {
function drawSvgPathCommands({
ctx,
pathD,
offsetX,
offsetY,
scale
}: DrawSvgPathCommandsParams) {
// console.log('drawSvgPathCommands 参数:', { pathD, offsetX, offsetY, scale });
// 精度处理函数:避免浮点数精度问题
const roundToPrecision = (num: number, precision: number = 2): number => {
return Math.round(num * Math.pow(10, precision)) / Math.pow(10, precision);
};
// 新的解析方法:按命令片段解析
// 使用正则表达式匹配从字母开头到下一个字母(或结尾)的片段
const commandRegex = /([MLZ])([^MLZ]*?)(?=[MLZ]|$)/g;
const commands: Array<{ cmd: string, coords: string }> = [];
let match;
while ((match = commandRegex.exec(pathD)) !== null) {
const cmd = match[1];
const coords = match[2].trim();
commands.push({ cmd, coords });
}
// console.log('解析的命令片段:', commands);
let commandCount = 0;
// 优化的坐标解析和绘制函数
const parseAndDrawCoords = (coords: string, cmd: string, drawFunction: (x: number, y: number) => void) => {
// 更严格的坐标解析:支持负数和小数
const coordParts = coords.split(/\s+/).filter(part => part.trim() !== '');
if (coordParts.length >= 2) {
const x = parseFloat(coordParts[0]);
const y = parseFloat(coordParts[1]);
if (!isNaN(x) && !isNaN(y) && isFinite(x) && isFinite(y)) {
// 计算最终坐标并处理精度
const finalX = roundToPrecision(offsetX + x * scale);
const finalY = roundToPrecision(offsetY + y * scale);
// console.log(`${cmd}: 绘制到 (${finalX}, ${finalY}) [原始: (${x}, ${y})]`);
drawFunction(finalX, finalY);
} else {
console.warn(`${cmd}: 坐标解析失败 - x=${x}, y=${y}, 原始坐标: "${coords}"`);
}
} else {
console.warn(`${cmd}: 坐标数量不足,需要2个,实际${coordParts.length}个,原始: "${coords}"`);
}
};
// 命令处理映射表,提高可读性和可扩展性
const commandHandlers: Record<string, (coords: string) => void> = {
'M': (coords) => parseAndDrawCoords(coords, 'M', (x, y) => ctx.moveTo(x, y)),
'L': (coords) => parseAndDrawCoords(coords, 'L', (x, y) => ctx.lineTo(x, y)),
'Z': () => {
// console.log('Z: 闭合路径');
// 注意:不要在这里调用 closePath(),因为 drawStrokes 中会统一处理
}
};
for (const { cmd, coords } of commands) {
commandCount++;
// console.log(`命令 ${commandCount}: ${cmd}, 坐标: "${coords}"`);
const handler = commandHandlers[cmd];
if (handler) {
handler(coords);
} else {
console.warn(`未知命令: ${cmd}`);
}
}
// console.log(`总共处理了 ${commandCount} 个命令`);
}
interface DrawStrokesParams {
ctx: RenderingContext;
strokes: string[];
offsetX: number;
offsetY: number;
size: number;
uptoInclusive: number;
fillStyle: string;
strokeStyle: string;
lineWidth: number;
}
function drawStrokes({
ctx,
strokes,
offsetX,
offsetY,
size,
uptoInclusive,
fillStyle,
strokeStyle,
lineWidth
}: DrawStrokesParams) {
// 模板中使用 54x54 的视窗尺寸
const viewBoxSize = 54;
const scale = size / viewBoxSize;
// 添加内边距:在田字格四周预留空间,避免笔画贴边
const padding = size * 0.1; // 内边距为田字格大小的10%
const contentSize = size - padding * 2; // 实际绘制区域大小
const contentScale = contentSize / viewBoxSize; // 调整后的缩放比例
// 计算内容区域的起始位置(居中)
const contentOffsetX = offsetX + padding;
const contentOffsetY = offsetY + padding;
console.log('drawStrokes 参数:', {
strokesCount: strokes.length,
uptoInclusive,
offsetX,
offsetY,
size,
padding,
contentSize,
contentOffsetX,
contentOffsetY,
originalScale: scale,
contentScale,
fillStyle,
strokeStyle,
lineWidth
});
for (let s = 0; s <= uptoInclusive && s < strokes.length; s++) {
// console.log(`绘制第 ${s} 个笔画:`, strokes[s]);
ctx.beginPath();
drawSvgPathCommands(ctx, strokes[s], offsetX, offsetY, scale);
drawSvgPathCommands({
ctx,
pathD: strokes[s],
offsetX: contentOffsetX,
offsetY: contentOffsetY,
scale: contentScale
});
ctx.fillStyle = fillStyle;
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = lineWidth;
@@ -86,14 +163,23 @@ function drawStrokes(
}
}
function drawTianZiGrid(
ctx: RenderingContext,
x: number,
y: number,
size: number,
lineColor: string = '#e0e0e0',
boldColor: string = '#cccccc',
) {
interface DrawTianZiGridParams {
ctx: RenderingContext;
x: number;
y: number;
size: number;
lineColor?: string;
boldColor?: string;
}
function drawTianZiGrid({
ctx,
x,
y,
size,
lineColor = '#e0e0e0',
boldColor = '#cccccc'
}: DrawTianZiGridParams) {
// 外框
ctx.strokeStyle = boldColor;
ctx.lineWidth = 2;
@@ -144,7 +230,7 @@ class WordDrawService {
this.paperSize = 'A4';
this.options = {
appName: '涂鸦丫小程序',
appHint: '字|识字|打印',
appHint: '字|识图|练字|打印',
title: '田字格 练 字 贴',
subTitle: '按笔画临摹练习',
...options,
@@ -164,15 +250,19 @@ class WordDrawService {
* 生成练字帖
* @param wordsMap 形如 { "其": ["M.. L.. Z", ...], ... },不超过 10 个汉字
*/
draw(wordsMap: Record<string, string[]>) {
async draw(wordsMap: Record<string, string[]>) {
this.setPrintConfig();
this.clear();
this.setPaper();
// 等待页眉绘制完成,确保 this.currentY 被正确设置
if (this.headerType !== 'minimal') {
this.drawHeader();
await this.drawHeader();
} else {
this.drawMiniHeader();
await this.drawMiniHeader();
}
// 页眉绘制完成后,再绘制内容
this.drawContent(wordsMap);
}
@@ -190,13 +280,25 @@ class WordDrawService {
const logoWidth = 200;
const logoHeight = 200;
// 这里沿用小程序码/Logo策略,保持与其它服务一致
try {
const { getMiniCodeImage } = await import('../utils/index');
switch (this.headerType) {
case 'LogoImage': {
const image = await getImage(canvas, '/assets/imgs/doodle-logo.png');
ctx.drawImage(image, logoX, logoY, logoWidth, logoHeight);
break;
}
case 'noLogoImage': {
titleX = 120;
break;
}
case 'minimal': {
titleX = 120;
break;
}
default: {
const image = await getMiniCodeImage(canvas);
ctx.drawImage(image, logoX, logoY, logoWidth, logoHeight);
} catch (_) {
// 忽略加载失败
break;
}
}
ctx.font = 'bold 64px "Microsoft Yahei"';
@@ -217,7 +319,10 @@ class WordDrawService {
ctx.fillStyle = '#666';
ctx.fillText(subTitle, 1015, 186);
this.currentY = 304;
// 计算页眉的实际高度,包括分割线
const headerHeight = Math.max(logoY + logoHeight, titleY + 64 + 48 + 48) + 20; // 64px字体 + 48px间距 + 48px字体 + 20px边距
// this.currentY = headerHeight;
this.currentY += headerHeight;
this.drawLine(this.currentY);
}
@@ -230,12 +335,24 @@ class WordDrawService {
ctx.fillStyle = '#000';
ctx.textAlign = 'center';
ctx.fillText(appName + ' ' + title, canvas.width / 2, titleY);
this.currentY = 200;
// 计算迷你页眉的实际高度
const miniHeaderHeight = titleY + 64 + 20; // 64px字体 + 20px边距
this.currentY = miniHeaderHeight;
this.drawLine(this.currentY);
}
/**
* 绘制正文内容:为每个汉字生成“预览 + 逐笔画临摹 + 空白格”的练习单元
* 绘制正文内容:两阶段绘制 - 先绘制空田字格,再绘制练字内容
*/
/**
* 绘制正文内容:包括田字格的排布和内容
*
* minGap 作用解释:
* minGap(最小列间距)用于田字格水平方向(每一行格子之间的间距)的初始最小值。它保证多个田字格在一行内不会紧贴排布、而是有一个最小的间隔,整体看起来不会拥挤。后续还会结合画布实际剩余空间动态调整为更合适的实际间距 actualColGap。
*
* rowGap 作用解释:
* rowGap(行间距)用于田字格竖直方向(每一行田字格之间的间隔)的初始最小值。它防止行与行的田字格上下贴得太紧,增加一定的可视呼吸空间。之后同样会结合剩余空间动态调整为实际间距 actualRowGap。
*/
drawContent(wordsMap: Record<string, string[]>) {
const { canvas, ctx } = this;
@@ -248,84 +365,285 @@ class WordDrawService {
const rightMargin = 120;
const bottomMargin = 120;
const contentTop = this.currentY + topGap;
console.log('contentTop----:', contentTop)
const contentWidth = canvas.width - leftMargin - rightMargin;
const contentHeight = canvas.height - contentTop - bottomMargin;
const cellSize = 140;
const minGap = 24;
const minGap = 24; // 最小格子列间距,用于保证横向不拥挤,后续可动态调整为实际间距
const rowGap = 36; // 最小格子行间距,用于保证纵向不拥挤,后续可动态调整为实际间距
// 计算每行可容纳的田字格数量
const maxPerRow = Math.max(1, Math.floor((contentWidth + minGap) / (cellSize + minGap)));
const rowGap = 36;
// 为每个汉字生成一串单元:1个预览 + N个逐笔画 + 2个空白 (便于描摹)
const unitsPerCharBuilder = (strokeCount: number) => {
const preview = 1;
const practice = Math.min(strokeCount, 8); // 最多展示 8 步逐笔画,避免过长
const blanks = 2;
return preview + practice + blanks;
};
// 计算每列可容纳的田字格数量
const actualRows = Math.max(1, Math.floor((contentHeight + rowGap) / (cellSize + rowGap)));
let cursorX = leftMargin;
let cursorY = contentTop + cellSize / 2;
let usedInRow = 0;
// 计算总需要的田字格数量
const totalCellsNeeded = actualRows * maxPerRow;
characters.forEach((char) => {
// 计算实际需要的行数
console.log('actualRows----:', actualRows)
console.log('totalCellsNeeded----:', totalCellsNeeded)
console.log('maxPerRow----:', maxPerRow)
// 动态调整间距以充分利用空间(在允许的最小间距基础上弹性扩展以适配画布)
const actualRowGap = actualRows > 1 ? (contentHeight - actualRows * cellSize) / (actualRows - 1) : 0;
const actualColGap = maxPerRow > 1 ? (contentWidth - maxPerRow * cellSize) / (maxPerRow - 1) : 0;
console.log(`布局信息: 总格数=${totalCellsNeeded}, 行数=${actualRows}, 列数=${maxPerRow}, 行间距=${actualRowGap.toFixed(1)}, 列间距=${actualColGap.toFixed(1)}`);
// 第一阶段:绘制所有空田字格
this.drawEmptyGrids({
ctx,
startX: leftMargin,
startY: contentTop,
cellSize,
colGap: actualColGap,
rowGap: actualRowGap,
maxPerRow,
maxRows: actualRows
});
// 第二阶段:绘制练字内容
this.drawPracticeContent({
ctx,
wordsMap,
startX: leftMargin,
startY: contentTop,
cellSize,
colGap: actualColGap,
rowGap: actualRowGap,
maxPerRow
});
}
/**
* 计算总需要的田字格数量
*/
// calculateTotalCellsNeeded(characters: string[], wordsMap: Record<string, string[]>): number {
// let totalCells = 0;
// characters.forEach((char) => {
// const strokes = wordsMap[char] || [];
// const strokeCount = strokes.length;
// const preview = 1; // 预览格
// const practice = Math.min(strokeCount, 8); // 最多8个练习格
// const blanks = 2; // 空白格
// totalCells += preview + practice + blanks;
// });
// return totalCells;
// }
/**
* 绘制空田字格网格
*/
drawEmptyGrids({
ctx,
startX,
startY,
cellSize,
colGap,
rowGap,
maxPerRow,
maxRows
}: {
ctx: RenderingContext;
startX: number;
startY: number;
cellSize: number;
colGap: number;
rowGap: number;
maxPerRow: number;
maxRows: number;
}) {
console.log('drawEmptyGrids startY----:', startY)
for (let row = 0; row < maxRows; row++) {
for (let col = 0; col < maxPerRow; col++) {
const x = startX + col * (cellSize + colGap) + cellSize / 2;
const y = startY + row * (cellSize + rowGap) + cellSize / 2;
drawTianZiGrid({ ctx, x, y, size: cellSize });
}
}
}
/**
* 绘制练字内容(参考 template.html 的实现)
*/
drawPracticeContent({
ctx,
wordsMap,
startX,
startY,
cellSize,
colGap,
rowGap,
maxPerRow
}: {
ctx: RenderingContext;
wordsMap: Record<string, string[]>;
startX: number;
startY: number;
cellSize: number;
colGap: number;
rowGap: number;
maxPerRow: number;
}) {
const characters = Object.keys(wordsMap);
let cellIndex = 0;
console.log('开始绘制练字内容,汉字数量:', characters.length);
characters.forEach((char, charIndex) => {
const strokes = wordsMap[char] || [];
const totalUnits = unitsPerCharBuilder(strokes.length);
let unitIndex = 0;
const strokeCount = strokes.length;
while (unitIndex < totalUnits) {
// 如果本行放不下,换行
if (usedInRow >= maxPerRow) {
cursorX = leftMargin;
cursorY += cellSize + rowGap;
usedInRow = 0;
// 边界检查
if (cursorY + cellSize / 2 > canvas.height - bottomMargin) return;
console.log(`绘制第 ${charIndex + 1} 个汉字 "${char}",笔画数: ${strokeCount}`);
// 1. 预览格:显示完整汉字(黑色)
this.drawPreviewCell({
ctx,
strokes,
startX,
startY,
cellSize,
colGap,
rowGap,
maxPerRow,
cellIndex
});
cellIndex++;
// 2. 练习格:逐笔画显示(红色)
for (let strokeIndex = 0; strokeIndex < strokeCount; strokeIndex++) {
// 先绘制当前笔画
this.drawPracticeCell({
ctx,
strokes,
strokeIndex,
startX,
startY,
cellSize,
colGap,
rowGap,
maxPerRow,
cellIndex
});
cellIndex++;
console.log(`笔画 ${strokeIndex + 1} 绘制完成,当前 cellIndex: ${cellIndex}`);
}
// 绘制田字
drawTianZiGrid(ctx, cursorX + cellSize / 2, cursorY, cellSize);
// 3. 汉字间留空:每个汉字后留2个空白
cellIndex += 0;
// 单元类型:0 预览;1..practice 逐笔画;最后 blanks 空白
const practiceMax = Math.min(strokes.length, 8);
if (unitIndex === 0) {
// 预览:全部笔画使用较深颜色
drawStrokes(
ctx,
strokes,
cursorX + (cellSize - cellSize) / 2,
cursorY - cellSize / 2,
cellSize,
strokes.length - 1,
'rgb(0,0,0)',
'#000',
1.6,
);
} else if (unitIndex <= practiceMax) {
// 逐笔画:累进显示到第 k 画,使用灰色/描边
drawStrokes(
ctx,
strokes,
cursorX + (cellSize - cellSize) / 2,
cursorY - cellSize / 2,
cellSize,
unitIndex - 1,
'rgb(184,184,184)',
'#999',
1.6,
);
// 4. 强制下一个汉字换行到新行的第一个田字格
if (charIndex < characters.length - 1) { // 不是最后一个汉字
const currentRow = Math.floor(cellIndex / maxPerRow);
const nextRow = currentRow + 1;
cellIndex = nextRow * maxPerRow;
console.log(`汉字 "${char}" 绘制完成,强制换行到新行,下一个汉字从 cellIndex: ${cellIndex} 开始`);
} else {
// 空白格:不画笔画
console.log(`汉字 "${char}" 绘制完成,这是最后一个汉字`);
}
});
console.log('所有汉字绘制完成');
}
// 前进到下一个单元
cursorX += cellSize + minGap;
usedInRow += 1;
unitIndex += 1;
/**
* 绘制预览格(完整汉字)
*/
drawPreviewCell({
ctx,
strokes,
startX,
startY,
cellSize,
colGap,
rowGap,
maxPerRow,
cellIndex
}: {
ctx: RenderingContext;
strokes: string[];
startX: number;
startY: number;
cellSize: number;
colGap: number;
rowGap: number;
maxPerRow: number;
cellIndex: number;
}) {
const row = Math.floor(cellIndex / maxPerRow);
const col = cellIndex % maxPerRow;
const x = startX + col * (cellSize + colGap);
const y = startY + row * (cellSize + rowGap);
console.log(`预览格 [${cellIndex}] 坐标: (${x}, ${y}), 行列: (${row}, ${col}), 笔画数: ${strokes.length}`);
// 绘制完整汉字(黑色,较粗)
drawStrokes({
ctx,
strokes,
offsetX: x,
offsetY: y,
size: cellSize,
uptoInclusive: strokes.length - 1,
fillStyle: 'rgb(0,0,0)', // 黑色填充
strokeStyle: 'rgb(0,0,0)', // 黑色描边
lineWidth: 4, // 较粗的线条
});
}
/**
* 绘制练习格(逐笔画)
*/
drawPracticeCell({
ctx,
strokes,
strokeIndex,
startX,
startY,
cellSize,
colGap,
rowGap,
maxPerRow,
cellIndex
}: {
ctx: RenderingContext;
strokes: string[];
strokeIndex: number;
startX: number;
startY: number;
cellSize: number;
colGap: number;
rowGap: number;
maxPerRow: number;
cellIndex: number;
}) {
const row = Math.floor(cellIndex / maxPerRow);
const col = cellIndex % maxPerRow;
const x = startX + col * (cellSize + colGap);
const y = startY + row * (cellSize + rowGap);
console.log(`练习格 [${cellIndex}] 坐标: (${x}, ${y}), 行列: (${row}, ${col}), 笔画: ${strokeIndex + 1}/${strokes.length}`);
// 绘制到指定笔画的汉字(红色,中等粗细)
drawStrokes({
ctx,
strokes,
offsetX: x,
offsetY: y,
size: cellSize,
uptoInclusive: strokeIndex,
fillStyle: 'rgb(220, 20, 20)', // 深红色填充
strokeStyle: 'rgb(220, 20, 20)', // 深红色描边
lineWidth: 3, // 中等粗细
});
}
drawLine(linY: number) {
console.log('drawLine linY----:', linY)
const { canvas, ctx } = this;
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
+9 -2
View File
@@ -24,11 +24,18 @@
"miniprogram": {
"list": [
{
"name": "pages/buttonTest/buttonTest",
"pathName": "pages/buttonTest/buttonTest",
"name": "pages/copyBook/copyBook",
"pathName": "pages/copyBook/copyBook",
"query": "",
"scene": null,
"launchMode": "default"
},
{
"name": "pages/buttonTest/buttonTest",
"pathName": "pages/buttonTest/buttonTest",
"query": "",
"launchMode": "default",
"scene": null
}
]
}