feat(dictionary): characters of Chinese, Korean and Greek, written by hand; pinyin typed

Parity with Japanese's kanji and kana:

- The characters' tab is named by its language (Hanzi, Hangul & Hanja,
  Alphabet), its groups too: HSK levels and the characters in common use
  (Hong Kong's grades for traditional ones); Hangul and the Hanja taught
  in schools; Greek's letters. Radicals for Chinese and Korean in their
  own forms (讠 for 言 in simplified Chinese), none for Greek.
- A character's readings are named as its language names them: On and
  Kun, Pinyin, Eum and Hun (음, 훈); a letter means its sound and name.
- How characters are written comes by language: Chinese from Make Me a
  Hanzi (its medians in KanjiVG's square, Arphic Public License, a data
  file beside the app, never in cards), the Hangul letters drawn by the
  curator; a Korean word is written letter by letter (닭: ㄷ ㅏ ㄺ). Words
  of alphabets (German, Greek) aren't written by hand.
- A Chinese word's pinyin is typed in a session, tone numbers turning
  into marks (ni3 → nǐ); KRDict's levels (초급, 중급, 고급) tag Korean
  words; a Korean word's pronunciation shows in brackets ([가치]).
- Smaller: Look up in the composer keeps a noun's article; the Watch
  filter names its level list; hints and placeholders by language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-14 12:35:17 +02:00
commit 5479b973fc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 2559 additions and 230 deletions

View file

@ -286,6 +286,42 @@ box, in the order they're written) and the components KanjiVG first splits it
into (`c`, when it has any). Then update the release named in the folder's
`ATTRIBUTION.md`.
Chinese characters are written as Make Me a Hanzi draws them
([skishore/makemeahanzi](https://github.com/skishore/makemeahanzi), decided
2026-10-14 for parity with KanjiVG: its own shapes and the mainland's stroke
order, 9,574 characters of both scripts; the strokes under the Arphic Public
License, the components under the LGPL). Download its `graphics.txt` and
`dictionary.txt`, then:
```sh
dart run halkyon_learn_curator:curate makemeahanzi \
--input graphics.txt --dictionary dictionary.txt \
--output ../halkyon_learn_flutter/assets/makemeahanzi
```
It writes the same files as for KanjiVG (99 files, 2.7 MB): each stroke is the
line Make Me a Hanzi draws through the middle of its outline (its median),
scaled from its 1024 square, whose y axis points up from 124 below the bottom,
into KanjiVG's 109 square, y pointing down; the outlines are left out, as the
app draws and checks strokes along their middle. A character's components are
the first split of its decomposition (海: 氵, 每). Each file says how and when it
was changed (`notice`), as the Arphic Public License asks of a changed file.
They stay a data file beside the app, never in cards (the folder's
`ATTRIBUTION.md`).
The Hangul letters are drawn by the curator itself (CC0), as no openly
licensed data draws them with their strokes:
```sh
dart run halkyon_learn_curator:curate hangul \
--output ../halkyon_learn_flutter/assets/hangul
```
writes `031.json.gz`: the 40 letters in the order Korean schools teach their
strokes (`lib/src/sources/hangul_strokes.dart`), and the pairs of consonants
(ㄲ, and the clusters closing syllables, 닭's ㄺ) as their two letters side by
side, so a Korean word is written letter by letter.
## Adding a source
Write an adapter in `lib/src/sources/` that turns the source into the lines

View file

@ -4,12 +4,14 @@ import 'dart:io';
import 'package:archive/archive.dart';
import 'package:args/args.dart';
import 'package:halkyon_learn_curator/src/sources/cedict.dart';
import 'package:halkyon_learn_curator/src/sources/hangul_strokes.dart';
import 'package:halkyon_learn_curator/src/sources/jlpt.dart';
import 'package:halkyon_learn_curator/src/sources/jmdict.dart';
import 'package:halkyon_learn_curator/src/sources/jmdict_xml.dart';
import 'package:halkyon_learn_curator/src/sources/kanjidic.dart';
import 'package:halkyon_learn_curator/src/sources/kanjivg.dart';
import 'package:halkyon_learn_curator/src/sources/krdict.dart';
import 'package:halkyon_learn_curator/src/sources/makemeahanzi.dart';
import 'package:halkyon_learn_curator/src/sources/tatoeba.dart';
import 'package:halkyon_learn_curator/src/sources/unihan.dart';
import 'package:halkyon_learn_curator/src/sources/wiktionary.dart';
@ -30,6 +32,18 @@ import 'package:halkyon_learn_curator/src/wordfreq.dart';
/// --input kanjivg-20260714.xml.gz \
/// --output ../halkyon_learn_flutter/assets/kanjivg
///
/// The Hangul letters' strokes, the curator's own, into the app's stroke
/// files:
///
/// dart run halkyon_learn_curator:curate hangul \
/// --output ../halkyon_learn_flutter/assets/hangul
///
/// Make Me a Hanzi into the app's stroke files of Chinese characters:
///
/// dart run halkyon_learn_curator:curate makemeahanzi \
/// --input graphics.txt --dictionary dictionary.txt \
/// --output ../halkyon_learn_flutter/assets/makemeahanzi
///
/// Wiktionary, as kaikki.org's dump of it, into dictionary files of the
/// languages named (German and Greek with English meanings, English with
/// German ones):
@ -143,6 +157,10 @@ Future<void> main(List<String> args) async {
'unihan: CC-CEDICT, whose words tell traditional characters in use '
'and rank the characters.',
)
..addOption(
'dictionary',
help: "makemeahanzi: its dictionary.txt, for the characters' components.",
)
..addOption(
'korean',
help:
@ -382,6 +400,56 @@ Future<void> main(List<String> args) async {
);
return;
}
if (source == 'hangul') {
final output = options.option('output');
if (output == null) {
_fail('Usage: curate hangul --output <folder>\n${parser.usage}');
}
final strokes = hangulStrokes();
final folder = await Directory(output).create(recursive: true);
final files = strokeFiles(strokes);
for (final MapEntry(key: name, value: characters) in files.entries) {
await File('${folder.path}/$name.json.gz')
.writeAsBytes(gzip.encode(utf8.encode(jsonEncode(characters))));
}
stdout.writeln(
'Wrote the strokes of ${strokes.length} Hangul letters to $output.',
);
return;
}
if (source == 'makemeahanzi') {
final input = options.option('input');
final dictionary = options.option('dictionary');
final output = options.option('output');
if (input == null || output == null) {
_fail(
'Usage: curate makemeahanzi --input graphics.txt '
'[--dictionary dictionary.txt] --output <folder>\n${parser.usage}',
);
}
final strokes = makemeahanziStrokes(
await File(input).readAsLines(),
dictionary: dictionary == null
? const []
: await File(dictionary).readAsLines(),
);
final folder = await Directory(output).create(recursive: true);
final files = strokeFiles(
strokes,
notice: makemeahanziNotice(
DateTime.now().toUtc().toIso8601String().substring(0, 10),
),
);
for (final MapEntry(key: name, value: characters) in files.entries) {
await File('${folder.path}/$name.json.gz')
.writeAsBytes(gzip.encode(utf8.encode(jsonEncode(characters))));
}
stdout.writeln(
'Wrote the strokes of ${strokes.length} characters to '
'${files.length} files in $output.',
);
return;
}
final input = options.option('input');
final output = options.option('output');
if (!['jmdict', 'kanjidic', 'kanjivg'].contains(source) ||

View file

@ -0,0 +1,118 @@
import 'package:halkyon_learn_curator/src/sources/kanjivg.dart';
/// How the Hangul letters (jamo) are written, the curator's own (CC0): no
/// openly licensed data draws them, so they are drawn here, each stroke a
/// line through KanjiVG's 109 × 109 square, in the order Korean schools
/// teach (ㄹ: ㄱ, the middle bar, ㄴ; ㅇ from the top, anticlockwise).
/// Doubled consonants and the clusters that close syllables (ㄲ, ㄺ) are
/// their two letters side by side, each narrowed to its half.
Map<String, CharacterStrokes> hangulStrokes() => {
for (final MapEntry(key: letter, value: strokes) in _letters.entries)
letter: (strokes: strokes, components: const []),
for (final (letter, left, right) in _pairs)
letter: (
strokes: [
for (final stroke in _letters[left]!) _narrowed(stroke, 12, 53),
for (final stroke in _letters[right]!) _narrowed(stroke, 56, 97),
],
components: [left, right],
),
};
/// The letters drawn on their own, each filling the square as a letter
/// learnt alone is written.
const _letters = {
'ㄱ': ['M24,26 L80,26 L79,58 L66,88'],
'ㄴ': ['M28,22 L28,82 L86,82'],
'ㄷ': ['M26,24 L84,24', 'M28,24 L28,84 L86,84'],
'ㄹ': ['M26,22 L80,22 L80,52', 'M28,52 L80,52', 'M28,52 L28,86 L84,86'],
'ㅁ': ['M26,24 L26,86', 'M26,24 L82,24 L82,86', 'M26,86 L82,86'],
'ㅂ': ['M28,20 L28,86', 'M80,20 L80,86', 'M28,52 L80,52', 'M28,86 L80,86'],
'ㅅ': ['M56,20 L52,48 L24,86', 'M52,50 L86,86'],
'ㅇ': [_ieung],
'ㅈ': ['M24,26 L80,26 L50,56 L22,88', 'M52,54 L86,88'],
'ㅊ': ['M54,12 L56,24', 'M24,34 L80,34 L50,62 L22,92', 'M52,60 L86,92'],
'ㅋ': ['M24,26 L80,26 L79,58 L66,88', 'M26,54 L78,54'],
'ㅌ': ['M28,22 L84,22', 'M28,52 L82,52', 'M28,22 L28,86 L86,86'],
'ㅍ': ['M22,24 L86,24', 'M38,24 L40,84', 'M70,24 L68,84', 'M18,86 L90,86'],
'ㅎ': ['M54,10 L54,22', 'M26,32 L82,32', _hieutCircle],
'ㅏ': ['M46,12 L46,96', 'M46,52 L76,52'],
'ㅑ': ['M46,12 L46,96', 'M46,40 L76,40', 'M46,64 L76,64'],
'ㅓ': ['M30,52 L62,52', 'M62,12 L62,96'],
'ㅕ': ['M30,40 L62,40', 'M30,64 L62,64', 'M62,12 L62,96'],
'ㅗ': ['M54,38 L54,66', 'M14,68 L94,68'],
'ㅛ': ['M40,40 L40,66', 'M68,40 L68,66', 'M14,68 L94,68'],
'ㅜ': ['M14,42 L94,42', 'M54,44 L54,76'],
'ㅠ': ['M14,42 L94,42', 'M40,44 L40,76', 'M68,44 L68,76'],
'ㅡ': ['M14,54 L94,54'],
'ㅣ': ['M54,12 L54,96'],
'ㅐ': ['M34,12 L34,96', 'M34,52 L56,52', 'M66,12 L66,96'],
'ㅒ': ['M34,12 L34,96', 'M34,40 L56,40', 'M34,64 L56,64', 'M66,12 L66,96'],
'ㅔ': ['M22,52 L48,52', 'M48,12 L48,96', 'M72,12 L72,96'],
'ㅖ': ['M22,40 L48,40', 'M22,64 L48,64', 'M48,12 L48,96', 'M72,12 L72,96'],
'ㅘ': ['M36,48 L36,68', 'M10,70 L60,70', 'M72,10 L72,98', 'M72,54 L94,54'],
'ㅙ': [
'M30,48 L30,68',
'M8,70 L50,70',
'M62,10 L62,98',
'M62,54 L78,54',
'M86,10 L86,98',
],
'ㅚ': ['M38,48 L38,68', 'M10,70 L64,70', 'M78,10 L78,98'],
'ㅝ': ['M8,46 L58,46', 'M34,48 L34,76', 'M56,58 L76,58', 'M76,10 L76,98'],
'ㅞ': [
'M6,46 L48,46',
'M28,48 L28,76',
'M50,58 L64,58',
'M64,10 L64,98',
'M86,10 L86,98',
],
'ㅟ': ['M8,46 L62,46', 'M36,48 L36,76', 'M78,10 L78,98'],
'ㅢ': ['M10,58 L64,58', 'M78,10 L78,98'],
};
/// ㅇ's circle, from its top anticlockwise.
const _ieung =
'M54.5,24.5 C38,24.5 24.5,38 24.5,54.5 C24.5,71 38,84.5 54.5,84.5 '
'C71,84.5 84.5,71 84.5,54.5 C84.5,38 71,24.5 54.5,24.5';
/// ㅎ's circle, smaller below its bars.
const _hieutCircle =
'M54,42 C42,42 32,52 32,64 C32,76 42,86 54,86 C66,86 76,76 76,64 '
'C76,52 66,42 54,42';
/// The letters of two consonants side by side: the doubled ones, and the
/// clusters that close syllables (닭's ㄺ).
const _pairs = [
('ㄲ', 'ㄱ', 'ㄱ'),
('ㄸ', 'ㄷ', 'ㄷ'),
('ㅃ', 'ㅂ', 'ㅂ'),
('ㅆ', 'ㅅ', 'ㅅ'),
('ㅉ', 'ㅈ', 'ㅈ'),
('ㄳ', 'ㄱ', 'ㅅ'),
('ㄵ', 'ㄴ', 'ㅈ'),
('ㄶ', 'ㄴ', 'ㅎ'),
('ㄺ', 'ㄹ', 'ㄱ'),
('ㄻ', 'ㄹ', 'ㅁ'),
('ㄼ', 'ㄹ', 'ㅂ'),
('ㄽ', 'ㄹ', 'ㅅ'),
('ㄾ', 'ㄹ', 'ㅌ'),
('ㄿ', 'ㄹ', 'ㅍ'),
('ㅀ', 'ㄹ', 'ㅎ'),
('ㅄ', 'ㅂ', 'ㅅ'),
];
/// [stroke] with its x coordinates narrowed from the square's 10 to 99
/// into [from] to [to], its y as it was.
String _narrowed(String stroke, double from, double to) {
String scaled(String x) {
final value = from + (double.parse(x) - 10) * (to - from) / 89;
return value.toStringAsFixed(1).replaceAll(RegExp(r'\.0$'), '');
}
// Every point is `x,y` after a command letter or a space.
return stroke.replaceAllMapped(
RegExp(r'(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)'),
(match) => '${scaled(match[1]!)},${match[2]}',
);
}

View file

@ -44,14 +44,20 @@ String strokeFileOf(String character) =>
(character.runes.first >> 8).toRadixString(16).padLeft(3, '0');
/// [strokes] as the app's stroke files, by name: for each character its
/// strokes (`s`) and, when it has any, its components (`c`).
/// strokes (`s`) and, when it has any, its components (`c`); each file
/// with [notice] (`notice`), where its licence asks a changed file to say
/// how it was changed.
Map<String, Map<String, Object>> strokeFiles(
Map<String, CharacterStrokes> strokes,
) {
Map<String, CharacterStrokes> strokes, {
String? notice,
}) {
final files = <String, Map<String, Object>>{};
for (final MapEntry(key: character, value: (:strokes, :components))
in strokes.entries) {
files.putIfAbsent(strokeFileOf(character), () => {})[character] = {
files.putIfAbsent(
strokeFileOf(character),
() => {'notice': ?notice},
)[character] = {
's': strokes,
if (components.isNotEmpty) 'c': components,
};

View file

@ -0,0 +1,133 @@
import 'dart:convert';
import 'package:halkyon_learn_curator/src/sources/kanjivg.dart';
/// How Chinese characters are written, from Make Me a Hanzi
/// (<https://github.com/skishore/makemeahanzi>): its `graphics.txt`
/// ([graphics], a JSON object per line, Arphic Public License) as each
/// character's strokes in the order they are written, each the median
/// Make Me a Hanzi draws through its outline as a line in KanjiVG's 109 ×
/// 109 square (the outlines are left out: the app draws and checks strokes
/// along their middle, as KanjiVG gives them); and its `dictionary.txt`
/// ([dictionary], LGPL-3.0-or-later) as the characters each is built from,
/// the first split of its decomposition (海: 氵, 每).
Map<String, CharacterStrokes> makemeahanziStrokes(
Iterable<String> graphics, {
Iterable<String> dictionary = const [],
}) {
final components = <String, List<String>>{};
for (final line in dictionary) {
if (line.trim().isEmpty) {
continue;
}
final json = jsonDecode(line);
if (json case {
'character': final String character,
'decomposition': final String decomposition,
}) {
components[character] = _firstSplit(decomposition);
}
}
return {
for (final line in graphics)
if (line.trim().isNotEmpty)
if (jsonDecode(line) case {
'character': final String character,
'medians': final List<Object?> medians,
})
character: (
strokes: [
for (final median in medians)
if (median is List<Object?> && median.isNotEmpty)
_pathOf(median),
],
components: components[character] ?? const [],
),
};
}
/// A median, Make Me a Hanzi's points (`[[336, 704], …]`) in its 1024
/// square whose y axis points up from 124 below the bottom, as SVG path
/// data in KanjiVG's 109 square, y pointing down.
String _pathOf(List<Object?> median) {
String point(Object? xy) {
final [x, y] = switch (xy) {
[final num x, final num y] => [x, y],
_ => const [0, 0],
};
String scaled(num value) =>
(value * 109 / 1024).toStringAsFixed(1).replaceAll(RegExp(r'\.0$'), '');
return '${scaled(x)},${scaled(900 - y)}';
}
return [
'M${point(median.first)}',
for (final xy in median.skip(1)) 'L${point(xy)}',
].join(' ');
}
/// The characters an ideographic description sequence ([ids], `⿰氵每`)
/// joins at its top: each part that is one character, none that is a
/// sequence of its own or unknown (`?`).
List<String> _firstSplit(String ids) {
final characters = ids.runes.map(String.fromCharCode).toList();
if (characters.isEmpty || !_operands.containsKey(characters.first)) {
return const [];
}
final parts = <String>[];
var at = 1;
for (var part = 0; part < _operands[characters.first]!; part++) {
if (at >= characters.length) {
break;
}
final start = at;
at = _skip(characters, at);
if (at == start + 1 && characters[start] != '?') {
parts.add(characters[start]);
}
}
return parts;
}
/// Where the part of [characters] starting at [at] ends.
int _skip(List<String> characters, int at) {
if (at >= characters.length) {
return at;
}
final operands = _operands[characters[at]];
if (operands == null) {
return at + 1;
}
var next = at + 1;
for (var i = 0; i < operands; i++) {
next = _skip(characters, next);
}
return next;
}
/// The ideographic description characters, by how many parts each joins.
const _operands = {
'⿰': 2,
'⿱': 2,
'⿲': 3,
'⿳': 3,
'⿴': 2,
'⿵': 2,
'⿶': 2,
'⿷': 2,
'⿸': 2,
'⿹': 2,
'⿺': 2,
'⿻': 2,
};
/// What each stroke file made from Make Me a Hanzi says of it, as the
/// Arphic Public License asks of a changed file: how and when ([date]) it
/// was changed.
String makemeahanziNotice(String date) =>
"Changed from Make Me a Hanzi's graphics.txt (Arphic Public License) and "
'dictionary.txt (LGPL-3.0-or-later) by halkyon_learn_curator on $date: '
"each stroke's median scaled into a 109 by 109 square with its y axis "
'pointing down, the outlines left out, the characters split up across '
"files by code point; a character's components are the first split of "
'its decomposition.';

View file

@ -70,3 +70,11 @@ three senses, their English and Japanese equivalents and two examples, without
sound files or related words. 한국어기초사전 is by the National Institute of
Korean Language (국립국어원), CC BY-SA 2.0 KR
(<https://krdict.korean.go.kr/kboardPolicy/copyRightTermsInfo>).
`makemeahanzi-graphics-sample.txt` and `makemeahanzi-dictionary-sample.txt` hold
the lines of Make Me a Hanzi's `graphics.txt` and `dictionary.txt` (commit
`618dbab`, 2018-10-16, <https://github.com/skishore/makemeahanzi>) for 水 and 海.
The strokes derive from the fonts Arphic PL KaitiM GB and Arphic PL UKai, © 1999
Arphic Technology Co., Ltd., under the Arphic Public License (its text is in the
app's `assets/makemeahanzi/ARPHICPL.TXT`); the dictionary's lines are under the
LGPL, version 3 or later (`assets/makemeahanzi/LGPL.txt`).

View file

@ -0,0 +1,2 @@
{"character":"水","definition":"water, liquid, lotion, juice","pinyin":["shuǐ"],"decomposition":"⿻亅?","etymology":{"type":"pictographic","hint":"A river running between two banks; compare 川"},"radical":"水","matches":[[0],null,null,null]}
{"character":"海","definition":"sea, ocean; maritime","pinyin":["hǎi"],"decomposition":"⿰氵每","etymology":{"type":"pictophonetic","phonetic":"每","semantic":"氵","hint":"water"},"radical":"氵","matches":[[0],[0],[0],[1],[1],[1],[1],[1],[1],[1]]}

View file

@ -0,0 +1,2 @@
{"character":"水","strokes":["M 535 506 Q 538 699 560 762 Q 578 793 520 817 Q 486 836 465 830 Q 447 823 463 799 Q 485 771 486 736 Q 490 697 478 121 Q 477 97 463 88 Q 454 81 432 88 Q 407 94 382 99 Q 348 111 351 100 Q 352 93 373 78 Q 440 24 457 -5 Q 476 -41 493 -42 Q 508 -43 524 -7 Q 543 41 541 117 Q 531 294 534 470 L 535 506 Z","M 154 501 Q 141 501 139 492 Q 138 485 153 477 Q 199 452 227 461 Q 333 489 343 489 Q 359 486 347 456 Q 296 326 249 262 Q 201 190 114 119 Q 99 106 110 103 Q 120 102 141 113 Q 217 153 281 224 Q 342 288 419 454 Q 429 478 441 489 Q 456 501 447 511 Q 437 524 399 537 Q 378 549 336 530 Q 270 509 154 501 Z","M 590 446 Q 630 476 766 584 Q 787 603 814 615 Q 838 627 825 647 Q 809 666 779 681 Q 752 696 738 692 Q 723 691 729 675 Q 735 639 659 553 Q 620 508 577 459 C 557 436 566 428 590 446 Z","M 577 459 Q 555 484 535 506 C 515 528 516 494 534 470 Q 756 161 817 160 Q 898 169 967 175 Q 995 178 996 185 Q 997 192 964 205 Q 810 253 753 295 Q 690 346 590 446 L 577 459 Z"],"medians":[[[473,814],[500,795],[521,770],[508,455],[507,91],[485,42],[442,56],[371,91],[369,98],[358,98]],[[147,491],[209,482],[354,512],[381,505],[392,495],[384,459],[353,387],[280,261],[187,160],[113,110]],[[737,682],[750,671],[766,637],[721,583],[591,461],[590,452]],[[539,500],[550,465],[618,387],[729,271],[782,229],[820,207],[990,185]]]}
{"character":"海","strokes":["M 246 711 Q 267 686 289 655 Q 302 639 320 636 Q 333 635 340 649 Q 349 665 343 700 Q 339 730 249 767 Q 233 773 224 771 Q 218 768 218 753 Q 219 740 246 711 Z","M 154 542 Q 191 493 228 471 Q 244 467 255 481 Q 262 494 260 512 Q 254 537 236 553 Q 188 586 150 586 Q 146 587 142 585 Q 135 584 137 570 Q 138 557 154 542 Z","M 123 190 Q 111 186 113 168 Q 131 89 165 72 Q 172 65 183 68 Q 190 69 191 95 Q 197 141 293 399 Q 300 415 299 422 Q 298 432 289 425 Q 273 415 163 232 Q 151 211 123 190 Z","M 520 686 Q 593 788 594 793 Q 593 796 593 798 Q 589 810 563 832 Q 538 848 520 849 Q 502 848 510 827 Q 525 793 458 683 Q 427 632 383 577 Q 373 567 371 561 Q 367 551 381 553 Q 420 559 500 661 L 520 686 Z","M 500 661 Q 548 640 599 650 Q 687 666 778 684 Q 820 694 826 699 Q 836 706 831 716 Q 824 728 794 736 Q 770 742 661 710 Q 579 692 520 686 C 490 683 472 672 500 661 Z","M 754 149 Q 809 139 863 120 Q 884 113 894 115 Q 904 119 898 137 Q 889 156 843 189 Q 825 205 762 196 L 694 195 Q 550 196 422 187 Q 409 186 413 196 Q 449 281 474 343 L 488 379 Q 506 428 517 460 Q 527 497 535 517 Q 538 524 538 529 C 541 538 541 538 521 557 Q 487 594 475 586 Q 468 577 467 568 Q 488 514 443 375 L 430 336 Q 388 227 345 190 Q 338 183 339 169 Q 343 151 360 132 Q 367 122 382 129 Q 419 147 493 154 Q 578 166 685 158 L 754 149 Z","M 685 158 Q 667 80 651 53 Q 641 37 619 45 Q 553 64 557 68 Q 526 80 548 50 Q 582 7 607 -37 Q 629 -68 649 -56 Q 728 -11 754 149 L 762 196 Q 775 311 780 365 L 785 413 Q 794 503 812 525 Q 831 549 819 561 Q 795 583 758 598 Q 740 607 718 599 Q 669 583 631 574 Q 589 564 521 557 C 491 554 508 528 538 529 Q 545 528 551 528 Q 572 528 688 560 Q 709 564 723 556 Q 739 544 726 405 L 722 365 Q 710 262 694 195 L 685 158 Z","M 579 484 Q 595 426 617 415 Q 630 411 641 424 Q 647 436 645 457 Q 635 488 592 507 Q 585 511 580 502 Q 576 495 579 484 Z","M 780 365 Q 859 368 941 362 Q 960 362 966 370 Q 972 380 956 395 Q 896 440 855 424 Q 822 420 785 413 L 726 405 Q 677 401 621 393 Q 557 387 488 379 L 443 375 Q 382 371 317 365 Q 296 364 311 347 Q 324 334 341 327 Q 360 321 377 325 Q 402 331 430 336 L 474 343 Q 568 362 722 365 L 780 365 Z","M 552 292 Q 567 253 590 231 Q 602 225 612 234 Q 619 243 622 256 Q 622 277 611 291 Q 599 306 571 319 Q 559 326 551 326 Q 545 326 545 316 Q 544 306 552 292 Z"],"medians":[[[229,762],[301,700],[321,656]],[[147,575],[210,530],[235,493]],[[177,78],[165,108],[161,162],[292,418]],[[519,836],[542,813],[549,793],[530,752],[463,648],[402,578],[379,561]],[[508,661],[530,670],[632,679],[754,707],[820,710]],[[480,577],[505,524],[492,462],[449,331],[385,194],[384,171],[416,164],[592,179],[775,172],[824,164],[888,126]],[[531,556],[548,544],[559,545],[715,580],[743,575],[772,543],[735,230],[710,107],[683,34],[668,14],[642,-4],[548,61]],[[590,495],[620,450],[623,432]],[[314,356],[368,346],[631,379],[883,397],[955,376]],[[554,317],[589,277],[599,247]]]}

View file

@ -0,0 +1,56 @@
import 'dart:io';
import 'package:halkyon_learn_curator/src/sources/hangul_strokes.dart';
import 'package:halkyon_learn_curator/src/sources/kanjivg.dart';
import 'package:halkyon_learn_curator/src/sources/makemeahanzi.dart';
import 'package:test/test.dart';
void main() {
test("Make Me a Hanzi's medians as lines in KanjiVG's square, y pointing "
'down; the first split of the decomposition as components', () {
final strokes = makemeahanziStrokes(
File('test/fixtures/makemeahanzi-graphics-sample.txt').readAsLinesSync(),
dictionary: File('test/fixtures/makemeahanzi-dictionary-sample.txt')
.readAsLinesSync(),
);
final water = strokes['水']!;
expect(water.strokes, hasLength(4));
// [473, 814] in its square whose y points up from 124 below the
// bottom: x 473 × 109 / 1024, y (900 − 814) × 109 / 1024.
expect(water.strokes.first, startsWith('M50.3,9.2 L53.2,11.2'));
// 水's decomposition (⿻亅?) names one part; the unknown one is left out.
expect(water.components, ['亅']);
expect(strokes['海']!.components, ['氵', '每']);
expect(strokes['海']!.strokes, hasLength(10));
});
test('stroke files say how they were changed where the licence asks', () {
final files = strokeFiles(
makemeahanziStrokes(
File('test/fixtures/makemeahanzi-graphics-sample.txt')
.readAsLinesSync(),
),
notice: makemeahanziNotice('2026-10-14'),
);
expect(files.keys, ['06c', '06d']);
expect(files['06c']!['notice'], contains('on 2026-10-14'));
expect(files['06c']!['水'], isA<Map<String, Object>>());
});
test('the Hangul letters: each in the order schools teach, the pairs of '
'consonants side by side, each in its half', () {
final strokes = hangulStrokes();
expect(strokes, hasLength(51));
expect(strokes['ㄹ']!.strokes, hasLength(3));
expect(strokes['ㅇ']!.strokes.single, startsWith('M54.5,24.5 C38,24.5'));
final cluster = strokes['ㄺ']!;
expect(cluster.components, ['ㄹ', 'ㄱ']);
expect(cluster.strokes, hasLength(4));
// ㄹ narrowed into the left half, ㄱ into the right one.
expect(cluster.strokes.first, startsWith('M19.4,22 L44.2,22'));
expect(cluster.strokes.last, startsWith('M62.4,26'));
});
}