kanji-recognizer 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,8 +7,9 @@ A lightweight, dependency-free JavaScript library for Kanji stroke order recogni
7
7
 
8
8
  ## ✨ Features
9
9
 
10
- - **🎯 Accurate Recognition** - Uses geometric resampling algorithm to compare user strokes against KanjiVG data
11
- - **📝 Stroke-by-Stroke Validation** - Enforces correct stroke order for proper kanji learning
10
+ - **🎯 Accurate Recognition** - Uses geometric resampling algorithm with centroid-based alignment for robust recognition
11
+ - **📝 Multiple Modes** - Supports Stroke-by-Stroke validation, Full Kanji check, and Free Write
12
+ - **🖼️ Image Export** - Export your drawings as PNG images for AI analysis or saving
12
13
  - **🎨 Fully Customizable** - Easy to style colors, animations, and recognition sensitivity
13
14
  - **⚡ Lightweight** - Zero dependencies, pure SVG-based rendering
14
15
  - **📱 Mobile-Friendly** - Touch-optimized with pointer events
@@ -87,6 +88,7 @@ const writer = new KanjiWriter(elementId, kanjiData, options);
87
88
  // Behavior
88
89
  showGhost: true, // Show red guide for next stroke
89
90
  showGrid: true, // Show background grid
91
+ checkMode: 'stroke', // 'stroke' (immediate), 'full' (manual), or 'free' (no validation)
90
92
 
91
93
  // Recognition (Adjustable!)
92
94
  passThreshold: 15, // Lower = stricter (10-20 recommended)
@@ -141,6 +143,25 @@ Clean up resources and remove event listeners.
141
143
  writer.destroy();
142
144
  ```
143
145
 
146
+ ##### `exportImage(options)`
147
+ Export the current drawing as a base64 PNG image.
148
+
149
+ ```javascript
150
+ const dataUrl = await writer.exportImage({
151
+ includeGrid: false,
152
+ backgroundColor: '#ffffff'
153
+ });
154
+ // Send dataUrl to AI or save it
155
+ ```
156
+
157
+ ##### `check()`
158
+ Manually trigger evaluation of all collected strokes (only for `checkMode: 'full'`).
159
+
160
+ ```javascript
161
+ const result = writer.check();
162
+ if (result.success) console.log("All strokes correct!");
163
+ ```
164
+
144
165
  #### Event Callbacks
145
166
 
146
167
  ```javascript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanji-recognizer",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "A robust Kanji stroke order recognition and validation library using KanjiVG data.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -41,4 +41,4 @@
41
41
  "url": "https://github.com/mxggle/kanji-recognizer/issues"
42
42
  },
43
43
  "homepage": "https://github.com/mxggle/kanji-recognizer#readme"
44
- }
44
+ }
@@ -57,32 +57,73 @@ export class GeometryUtil {
57
57
  return newPoints;
58
58
  }
59
59
 
60
+ /**
61
+ * Get the centroid (center of mass) of a set of points
62
+ */
63
+ static getCentroid(points) {
64
+ if (!points || points.length === 0) return { x: 0, y: 0 };
65
+ let sumX = 0;
66
+ let sumY = 0;
67
+ for (const p of points) {
68
+ sumX += p.x;
69
+ sumY += p.y;
70
+ }
71
+ return { x: sumX / points.length, y: sumY / points.length };
72
+ }
73
+
60
74
  /**
61
75
  * Compare two strokes. Returns a score (lower is better, 0 is perfect).
62
- * Checks shape and direction.
76
+ * Now includes translation normalization to be more robust.
63
77
  * @param {Array} userPoints - User's drawn points
64
78
  * @param {Array} targetPoints - Target stroke points
65
- * @param {number} startDistThreshold - Maximum allowed start point distance (default: 40)
79
+ * @param {Object} options - Thresholds and weights
66
80
  */
67
- static compareStrokes(userPoints, targetPoints, startDistThreshold = 40) {
81
+ static compareStrokes(userPoints, targetPoints, options = {}) {
82
+ const {
83
+ startDistThreshold = 50,
84
+ translationWeight = 0.3, // How much absolute position matters (0-1)
85
+ shapeWeight = 0.7 // How much shape accuracy matters (0-1)
86
+ } = options;
87
+
68
88
  const resampledUser = this.resample(userPoints);
69
89
  const resampledTarget = this.resample(targetPoints);
70
90
 
71
- // 1. Direction Check: Check if start and end points match roughly
91
+ // 1. Initial Position Check
92
+ // We still want the stroke to start *somewhere* near the expected start
72
93
  const startDist = this.distance(resampledUser[0], resampledTarget[0]);
73
- const endDist = this.distance(resampledUser[resampledUser.length - 1], resampledTarget[resampledTarget.length - 1]);
94
+ if (startDist > startDistThreshold) return Infinity;
95
+
96
+ // 2. Alignment (Translation Normalization)
97
+ // Calculate centroids
98
+ const userCentroid = this.getCentroid(resampledUser);
99
+ const targetCentroid = this.getCentroid(resampledTarget);
100
+
101
+ // Calculate translation cost (distance between centroids)
102
+ const translationCost = this.distance(userCentroid, targetCentroid);
74
103
 
75
- // If start is far from start, it's definitely wrong (or drawn backwards)
76
- // We penalize this heavily.
77
- if (startDist > startDistThreshold) return Infinity; // Way off
104
+ // 3. Shape Check (Aligned average distance)
105
+ let shapeDist = 0;
106
+ const dx = targetCentroid.x - userCentroid.x;
107
+ const dy = targetCentroid.y - userCentroid.y;
78
108
 
79
- // 2. Shape Check: Average distance between points
80
- let totalDist = 0;
81
109
  for (let i = 0; i < resampledUser.length; i++) {
82
- totalDist += this.distance(resampledUser[i], resampledTarget[i]);
110
+ // Compare user point (shifted to target space) vs target point
111
+ const shiftedUserPoint = {
112
+ x: resampledUser[i].x + dx,
113
+ y: resampledUser[i].y + dy
114
+ };
115
+ shapeDist += this.distance(shiftedUserPoint, resampledTarget[i]);
83
116
  }
84
- const avgDist = totalDist / resampledUser.length;
117
+ const shapeCost = shapeDist / resampledUser.length;
118
+
119
+ // 4. Combined weighted score
120
+ // This is more robust because if you draw the right shape slightly shifted,
121
+ // the shapeCost will be low, and translationCost will be moderate,
122
+ // allowing it to pass even if the absolute coordinates are off.
123
+ const totalScore = (shapeCost * shapeWeight) + (translationCost * translationWeight);
124
+
125
+ console.log(`Recognition Debug - Shape: ${shapeCost.toFixed(2)}, Trans: ${translationCost.toFixed(2)}, Total: ${totalScore.toFixed(2)}`);
85
126
 
86
- return avgDist;
127
+ return totalScore;
87
128
  }
88
129
  }
@@ -40,7 +40,7 @@ export class KanjiVGParser {
40
40
  throw new Error('Invalid character: must be a non-empty string');
41
41
  }
42
42
 
43
- let code = char.charCodeAt(0).toString(16);
43
+ let code = char.codePointAt(0).toString(16).toLowerCase();
44
44
  while (code.length < 5) code = "0" + code;
45
45
  return code;
46
46
  }
@@ -55,8 +55,8 @@ export class KanjiVGParser {
55
55
  }
56
56
 
57
57
  let hex = char;
58
- // If it looks like a single character, convert to hex
59
- if (char.length === 1 && char.charCodeAt(0) > 255) {
58
+ // If it looks like a single character (including surrogate pairs), convert to hex
59
+ if (Array.from(char).length === 1) {
60
60
  hex = this.getHex(char);
61
61
  }
62
62
 
@@ -18,17 +18,12 @@ export class KanjiWriter {
18
18
  // Toggles
19
19
  showGhost: true, // Show red guide for next stroke
20
20
  showGrid: true, // Show the background grid
21
+ checkMode: 'stroke', // 'stroke' (immediate), 'full' (manual), or 'free' (no validation)
21
22
 
22
23
  // Appearance
23
24
  strokeWidth: 4,
24
25
  gridWidth: 0.5,
25
26
  ghostOpacity: "0.1",
26
-
27
- // Animations
28
- stepDuration: 500,
29
- hintDuration: 800,
30
- snapDuration: 200,
31
-
32
27
  ...options
33
28
  };
34
29
 
@@ -44,6 +39,7 @@ export class KanjiWriter {
44
39
  this.currentStrokeIndex = 0;
45
40
  this.isDrawing = false;
46
41
  this.currentPoints = [];
42
+ this.userStrokes = []; // Store strokes for 'full' mode
47
43
 
48
44
  // Use options.width/height in initSVG
49
45
  this.width = this.options.width;
@@ -112,7 +108,7 @@ export class KanjiWriter {
112
108
  // Optional: render faint outline of the next stroke
113
109
  this.bgGroup.innerHTML = '';
114
110
 
115
- if (!this.options.showGhost) return;
111
+ if (!this.options.showGhost || this.options.checkMode === 'free') return;
116
112
 
117
113
  // If we want to show the full ghost:
118
114
  this.kanjiData.forEach((d, i) => {
@@ -136,7 +132,8 @@ export class KanjiWriter {
136
132
  attachEvents() {
137
133
  // Store bound functions as instance properties for cleanup
138
134
  this.boundStart = (e) => {
139
- if (this.currentStrokeIndex >= this.kanjiData.length) return; // Finished
135
+ // Only stop drawing if in a validation mode and kanji is finished
136
+ if (this.options.checkMode !== 'free' && this.currentStrokeIndex >= this.kanjiData.length) return;
140
137
  e.preventDefault();
141
138
  this.isDrawing = true;
142
139
  this.currentPoints = [];
@@ -168,7 +165,24 @@ export class KanjiWriter {
168
165
  this.boundEnd = (e) => {
169
166
  if (!this.isDrawing) return;
170
167
  this.isDrawing = false;
171
- this.evaluateStroke();
168
+
169
+ if (this.options.checkMode === 'stroke') {
170
+ this.evaluateStroke();
171
+ } else if (this.options.checkMode === 'full') {
172
+ // In 'full' mode, we just store the points and keep the path
173
+ this.userStrokes.push({
174
+ points: this.currentPoints,
175
+ path: this.currentPath
176
+ });
177
+ this.currentStrokeIndex++;
178
+ this.renderUpcomingStrokes();
179
+ } else {
180
+ // In 'free' mode, we just leave the path in the currentGroup
181
+ // and maybe track it if we want to support undo later
182
+ this.userStrokes.push({
183
+ path: this.currentPath
184
+ });
185
+ }
172
186
  };
173
187
 
174
188
  this.svg.addEventListener('pointerdown', this.boundStart);
@@ -258,11 +272,64 @@ export class KanjiWriter {
258
272
  setTimeout(() => { pathRef.remove(); }, 600);
259
273
  }
260
274
 
275
+ /**
276
+ * Manual check for 'full' mode
277
+ * @returns {Object} result - Success and detailed results per stroke
278
+ */
279
+ check() {
280
+ if (this.options.checkMode !== 'full') {
281
+ console.warn("check() called but checkMode is not 'full'");
282
+ return;
283
+ }
284
+
285
+ const results = [];
286
+ let allCorrect = true;
287
+
288
+ // Evaluate each user stroke against corresponding target stroke
289
+ for (let i = 0; i < this.kanjiData.length; i++) {
290
+ const userStroke = this.userStrokes[i];
291
+ const targetD = this.kanjiData[i];
292
+
293
+ if (!userStroke) {
294
+ results.push({ success: false, message: "Missing stroke" });
295
+ allCorrect = false;
296
+ continue;
297
+ }
298
+
299
+ const result = this.recognizer.evaluate(userStroke.points, targetD);
300
+ results.push(result);
301
+
302
+ if (!result.success) {
303
+ allCorrect = false;
304
+ userStroke.path.setAttribute("stroke", this.options.incorrectColor);
305
+ } else {
306
+ userStroke.path.setAttribute("stroke", this.options.correctColor);
307
+ }
308
+ }
309
+
310
+ // If there are extra strokes, they are incorrect
311
+ if (this.userStrokes.length > this.kanjiData.length) {
312
+ allCorrect = false;
313
+ for (let i = this.kanjiData.length; i < this.userStrokes.length; i++) {
314
+ this.userStrokes[i].path.setAttribute("stroke", this.options.incorrectColor);
315
+ results.push({ success: false, message: "Extra stroke" });
316
+ }
317
+ }
318
+
319
+ if (allCorrect && this.userStrokes.length === this.kanjiData.length) {
320
+ console.log("Full Kanji Correct!");
321
+ if (this.onComplete) this.onComplete();
322
+ }
323
+
324
+ return { success: allCorrect, results };
325
+ }
326
+
261
327
  /**
262
328
  * Public API: Clear the canvas and reset progress
263
329
  */
264
330
  clear() {
265
331
  this.currentStrokeIndex = 0;
332
+ this.userStrokes = [];
266
333
  this.drawnGroup.innerHTML = '';
267
334
  this.currentGroup.innerHTML = '';
268
335
  this.bgGroup.innerHTML = '';
@@ -389,4 +456,62 @@ export class KanjiWriter {
389
456
  }
390
457
  });
391
458
  }
459
+
460
+ /**
461
+ * Export the current drawing as a base64 PNG image
462
+ * @param {Object} options - Export options (includeGrid, includeGhost, etc.)
463
+ * @returns {Promise<string>} Base64 Data URL
464
+ */
465
+ async exportImage(options = {}) {
466
+ const {
467
+ includeGrid = false,
468
+ includeGhost = false,
469
+ backgroundColor = "#ffffff",
470
+ width = 109,
471
+ height = 109
472
+ } = options;
473
+
474
+ // Create a clone of the SVG to manipulate without affecting UI
475
+ const clone = this.svg.cloneNode(true);
476
+
477
+ // Find the groups in the clone
478
+ const groups = Array.from(clone.querySelectorAll('g'));
479
+ const gridGroup = groups[0];
480
+ const bgGroup = groups[1];
481
+
482
+ // Toggle visibility based on options
483
+ if (!includeGrid && gridGroup) gridGroup.setAttribute('visibility', 'hidden');
484
+ if (!includeGhost && bgGroup) bgGroup.setAttribute('visibility', 'hidden');
485
+
486
+ // Ensure background color if SVG doesn't have one
487
+ clone.style.background = backgroundColor;
488
+
489
+ // Serialize to XML
490
+ const serializer = new XMLSerializer();
491
+ const svgString = serializer.serializeToString(clone);
492
+ const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
493
+ const url = URL.createObjectURL(svgBlob);
494
+
495
+ return new Promise((resolve, reject) => {
496
+ const img = new Image();
497
+ img.onload = () => {
498
+ const canvas = document.createElement('canvas');
499
+ // Use higher resolution for export if desired, but 109x109 is KanjiVG base
500
+ // We scales up to the container's current pixel size for better quality
501
+ const scale = 2; // Export at 2x for better AI recognition
502
+ canvas.width = width * scale;
503
+ canvas.height = height * scale;
504
+
505
+ const ctx = canvas.getContext('2d');
506
+ ctx.fillStyle = backgroundColor;
507
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
508
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
509
+
510
+ URL.revokeObjectURL(url);
511
+ resolve(canvas.toDataURL('image/png'));
512
+ };
513
+ img.onerror = reject;
514
+ img.src = url;
515
+ });
516
+ }
392
517
  }
@@ -68,7 +68,11 @@ export class StrokeRecognizer {
68
68
  return { success: false, score: 100, message: "Length mismatch" };
69
69
  }
70
70
 
71
- const score = GeometryUtil.compareStrokes(userPoints, targetPoints, this.options.startDistThreshold);
71
+ const score = GeometryUtil.compareStrokes(userPoints, targetPoints, {
72
+ startDistThreshold: this.options.startDistThreshold,
73
+ translationWeight: 0.3,
74
+ shapeWeight: 0.7
75
+ });
72
76
 
73
77
  return {
74
78
  success: score < this.options.passThreshold,