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
153 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'));
});
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more