Skip to content

Commit bda57cb

Browse files
Merge pull request #15 from word-view/hiragana-and-katakana-support
Add hiragana and katakana support
2 parents ac330c3 + 123af07 commit bda57cb

8 files changed

Lines changed: 240 additions & 4 deletions

File tree

.github/workflows/maven.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
uses: actions/setup-java@v4
2626
with:
2727
java-version: '21'
28-
distribution: 'temurin'
28+
distribution: 'corretto'
2929
cache: maven
3030
- name: Build with Maven
3131
run: mvn -B package --file pom.xml

src/main/kotlin/cc/wordview/gengolex/languages/japanese/JapaneseTokenizer.kt

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,56 @@ object JapaneseTokenizer : Tokenizer {
2525

2626
while (i < chars.size) {
2727
val char = chars[i].toString()
28-
tokenizeKanji(char, input.substring(i))?.let {
28+
29+
// tokenize hiragana first to parse kanji words that are prefixed with a kana character (e.g. お願い, お姉さん)
30+
val foundWord =
31+
tokenizeHiragana(char, input.substring(i), wordsFound) ?:
32+
tokenizeKanji(char, input.substring(i)) ?:
33+
tokenizeKatakana(char, input.substring(i), wordsFound)
34+
35+
foundWord?.let {
2936
wordsFound.add(it)
30-
i += it.word.length // Skip characters based on the word's length
37+
i += it.word.length
3138
} ?: i++
3239
}
3340

3441
return wordsFound
3542
}
3643

44+
private fun tokenizeKatakana(char: String, original: String, wordsFound: ArrayList<Word>): Word? {
45+
if (wordsFound.any { it.word == char }) return null
46+
if (char.isEmpty() || char[0].code !in 0x30A0..0x30FF) return null
47+
48+
wordMap[char]?.let { katakanaWord ->
49+
return when (kanjiStrategy) {
50+
PREFER_PARENT -> katakanaWord
51+
PREFER_DERIVATION -> {
52+
@Suppress("UNNECESSARY_SAFE_CALL")
53+
katakanaWord.derivations?.find { original.startsWith(it.word) }
54+
}
55+
}
56+
}
57+
58+
return null
59+
}
60+
61+
private fun tokenizeHiragana(char: String, original: String, wordsFound: ArrayList<Word>): Word? {
62+
if (wordsFound.any { it.word == char }) return null
63+
if (char.isEmpty() || char[0].code !in 0x3040..0x309F) return null
64+
65+
wordMap[char]?.let { hiraganaWord ->
66+
return when (kanjiStrategy) {
67+
PREFER_PARENT -> hiraganaWord
68+
PREFER_DERIVATION -> {
69+
@Suppress("UNNECESSARY_SAFE_CALL")
70+
hiraganaWord.derivations?.find { original.startsWith(it.word) }
71+
}
72+
}
73+
}
74+
75+
return null
76+
}
77+
3778
private fun tokenizeKanji(char: String, original: String): Word? {
3879
if (char.isEmpty() || char[0].code !in 0x4E00..0x9FFF) return null
3980

src/test/kotlin/MockValues.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const val englishDictionaryJsonString =
22
"[{\"parent\":\"run\",\"word\":\"run\",\"derivations\":[{\"parent\":\"running\",\"word\":\"running\"},{\"parent\":\"ran\",\"word\":\"ran\"}]}]"
33
const val kanjiDictionaryJsonString =
4-
"[{\"parent\":\"run\",\"word\":\"\",\"derivations\":[{\"parent\":\"run\",\"word\":\"走る\"},{\"parent\":\"ran\",\"word\":\"走ってた\"},{\"parent\":\"running\",\"word\":\"走っています\"}]},{\"parent\":\"rain\",\"word\":\"\",\"representable\":true,\"pronunciation\":\"ame\"},{\"parent\":\"i\",\"word\":\"\"}]"
4+
"[{\"parent\":\"run\",\"word\":\"走\",\"derivations\":[{\"parent\":\"run\",\"word\":\"走る\"},{\"parent\":\"ran\",\"word\":\"走ってた\"},{\"parent\":\"running\",\"word\":\"走っています\"}]},{\"parent\":\"rain\",\"word\":\"雨\",\"representable\":true,\"pronunciation\":\"ame\"},{\"parent\":\"i\",\"word\":\"私\"},{\"parent\":\"\",\"word\":\"変\",\"representable\":false,\"derivations\":[{\"parent\":\"wont_change\",\"word\":\"変わらない\"}]},{\"parent\":\"\",\"word\":\"近\",\"representable\":false,\"derivations\":[{\"parent\":\"if_get_close\",\"word\":\"近付いたら\",\"representable\":true}]},{\"parent\":\"sad\",\"word\":\"悲\",\"representable\":true,\"derivations\":[{\"parent\":\"sad\",\"translation_parent\":\"sadness\",\"word\":\"悲しみ\",\"representable\":true}]},{\"parent\":\"\",\"word\":\"ア\",\"derivations\":[{\"parent\":\"hideout\",\"word\":\"アジト\"}]},{\"parent\":\"\",\"word\":\"パ\",\"derivations\":[{\"parent\":\"computer\",\"word\":\"パソコン\"}]},{\"parent\":\"\",\"word\":\"お\",\"derivations\":[{\"parent\":\"please\",\"word\":\"お願い\"},{\"parent\":\"congratulations\",\"word\":\"おめでとう\"}]}]"
55
const val portugueseDictionaryJsonString =
66
"[{\"parent\":\"run\",\"word\":\"correr\",\"derivations\":[{\"parent\":\"ran\",\"word\":\"corri\"},{\"parent\":\"running\",\"word\":\"correndo\"},{\"parent\":\"run\",\"word\":\"corra\"}]},{\"parent\":\"umbrella\",\"word\":\"guarda-chuva\",\"representable\":true}]"
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import cc.wordview.gengolex.Language
2+
import cc.wordview.gengolex.Parser
3+
import kotlin.test.Ignore
4+
import kotlin.test.Test
5+
import kotlin.time.Duration
6+
import kotlin.time.measureTimedValue
7+
8+
@Ignore("Run the benchmark separately from the tests suites")
9+
class ParserBenchmarkTest {
10+
// Benchmark only japanese because it is the most complex one at the moment
11+
@Test
12+
fun parseJapanese8chars() {
13+
val time = calculateAverageExecutionTime {
14+
val parser = Parser(Language.JAPANESE)
15+
parser.addDictionary("kanji", kanjiDictionaryJsonString)
16+
parser.findWords("僕は走っています")
17+
}
18+
19+
printRes("parseJapanese8chars", time)
20+
}
21+
22+
@Test
23+
fun parseJapanese19chars() {
24+
val time = calculateAverageExecutionTime {
25+
val parser = Parser(Language.JAPANESE)
26+
parser.addDictionary("kanji", kanjiDictionaryJsonString)
27+
parser.findWords("昨日の朝、僕は友達と公園で走っています")
28+
}
29+
30+
printRes("parseJapanese19chars", time)
31+
}
32+
33+
@Test
34+
fun parseJapanese56chars() {
35+
val time = calculateAverageExecutionTime {
36+
val parser = Parser(Language.JAPANESE)
37+
parser.addDictionary("kanji", kanjiDictionaryJsonString)
38+
parser.findWords("昨日の夕方、雨が少し降っていたけれど、僕は友達と公園を何周も走っていますし、その後も音楽を聴きながら走っています")
39+
}
40+
41+
printRes("parseJapanese56chars", time)
42+
}
43+
44+
@Test
45+
fun parseJapanese233chars() {
46+
val time = calculateAverageExecutionTime {
47+
val parser = Parser(Language.JAPANESE)
48+
parser.addDictionary("kanji", kanjiDictionaryJsonString)
49+
parser.findWords(
50+
"昨日の朝早く起きて、朝ご飯を食べてから公園に行き、友達と一緒に準備運動をして、空が少し曇っていたけれど雨は降らなくて、風が気持ちよかったので、僕たちは話しながらゆっくり走り始め、少しずつスピードを上げていき、汗をかきながら笑顔で走っていますが、その途中で犬の散歩をしている人に挨拶をしたり、小さな子供たちが遊んでいるのを見たりして、さらに川沿いの道まで足を伸ばして景色を楽しみながら走っていますし、最後にはベンチに座って水を飲み、今日もいい運動ができたと感じています"
51+
)
52+
}
53+
54+
printRes("parseJapanese233chars", time)
55+
}
56+
57+
@Test
58+
fun parseJapanese512chars() {
59+
val time = calculateAverageExecutionTime {
60+
val parser = Parser(Language.JAPANESE)
61+
parser.addDictionary("kanji", kanjiDictionaryJsonString)
62+
parser.findWords(
63+
"静かな夜の街を抜け出して、まだ眠らない夢を探していると、遠くの灯りが揺れて、心の奥に隠していた記憶が蘇り、笑った日も泣いた日もすべて繋がって、僕の背中を押してくれる。君と出会った奇跡を思い出しながら、失いたくないものを胸に抱いて、僕は走っています。たとえ暗闇に包まれても、必ず朝はやってきて、雲の隙間から差し込む光を信じれば、もう一度立ち上がれる。繰り返す日々の中で迷ったとしても、君の声が聞こえるたびに、未来への道を選べると信じて、走っています。 雨が頬を濡らしても、涙と混ざり合っても、心の奥で燃えている小さな炎は決して消えなくて、その炎が僕を導いていく。誰かに笑われても構わない、夢を追いかけるために、僕は今日も走っています。果てしない空の下で、流れる雲を追い越して、希望の歌を胸に響かせながら、走っています。 そして、疲れ果てて立ち止まりそうなときも、君の笑顔を思い出せば、また一歩を踏み出せる。遠く離れていても、心は確かに繋がっていて、見えない絆が強く結んでいるから、僕は明日も走っています。どこまでも続く道の上で、夢と現実の狭間を超えて、傷だらけになりながらも、それでも信じる心を失わず、僕はずっと走っています。"
64+
)
65+
}
66+
67+
printRes("parseJapanese512chars", time)
68+
}
69+
70+
@Test
71+
fun parseJapanese1250chars() {
72+
val time = calculateAverageExecutionTime {
73+
val parser = Parser(Language.JAPANESE)
74+
parser.addDictionary("kanji", kanjiDictionaryJsonString)
75+
parser.findWords(
76+
"朝日が昇るころ、街は少しずつ目を覚まし、人々が家を出て仕事や学校へ向かって歩いていく。子供たちは元気に笑いながら走り回り、大人たちは忙しそうに足を速め、信号が青に変わるたびに流れのように動き出す。その中で僕は静かに深呼吸をして、ゆっくりと準備を整え、走るための靴紐を結び直す。体がまだ眠っているように重いと感じても、一歩を踏み出せば次の一歩は自然とついてきて、やがてリズムが生まれる。そうして気付けば僕は走っています。 公園を抜けて川沿いの道に出ると、木々の間から差し込む光が眩しく、風が頬を撫で、鳥の声が遠くから聞こえてくる。走っているとき、頭の中には色々な考えが浮かんでは消えていく。昨日の出来事、これからの予定、叶えたい夢や避けられない不安。けれども足を動かし続けていると、それらは次第に薄れていき、代わりに「今ここにいる」という感覚だけが残る。そうして僕は走っています。汗が背中を流れ、呼吸が荒くなっても、走り続けることがすべてを洗い流してくれるように感じる。 道の途中で犬を散歩している人に出会い、軽く会釈を交わす。自転車に乗った学生たちが僕を追い越していき、遠くからは工事の音や車のクラクションが聞こえる。世界は絶えず動いていて、立ち止まれば置いていかれそうになる。それでも僕は自分の歩幅で、いや、走るリズムで進んでいく。小さな子供が「頑張って」と声をかけてくれると、思わず笑顔になり、さらにスピードを上げる。こうして僕は走っています。 長い直線の道を抜けると、街の雑踏が遠ざかり、代わりに静かな田舎の風景が広がる。稲穂が風に揺れ、遠くの山並みが青く霞み、雲がゆっくりと流れている。都会の喧騒を離れることで、心は不思議と落ち着いていき、自然の中に身を置くことで余計なことは考えなくなる。僕はただ呼吸を感じ、足の動きを感じ、そして「生きている」という実感を覚える。走っています。誰に見せるわけでもなく、記録を残すわけでもなく、ただ自分のために走っている。 それでも時々、立ち止まりたくなる瞬間がある。膝が痛んだり、呼吸が苦しくなったり、心が弱音を吐こうとしたり。それでも足を止めずに進むのは、きっとその先に見える景色を知りたいからだろう。知らない道を抜けたときに出会う景色、予想していなかった瞬間、そういう小さな発見が僕を前へと動かす力になる。だから今日も僕は走っています。どんなに疲れていても、立ち止まる理由よりも、進み続ける理由の方が強い。 太陽が高くなり、汗が滴り落ち、喉が渇いても、走り続けることでしか得られない感覚がある。それは達成感かもしれないし、ただの自己満足かもしれない。けれども、足を動かすたびに確かに「自分は生きている」と思える瞬間があるのだ。だから僕はこれからも走っています。"
77+
)
78+
}
79+
80+
printRes("parseJapanese1250chars", time)
81+
}
82+
83+
@Test
84+
@Ignore("This is only a stress test it's very unlikely that we will have to deal with 10k+ inputs")
85+
fun parseJapaneseStressTest10kChars() {
86+
// Generate a large text by repeating a block multiple times
87+
val block = """
88+
朝日が昇るころ、街は少しずつ目を覚まし、人々が家を出て仕事や学校へ向かって歩いていく。
89+
子供たちは元気に笑いながら走り回り、大人たちは忙しそうに足を速め、信号が青に変わるたびに流れのように動き出す。
90+
その中で僕は静かに深呼吸をして、ゆっくりと準備を整え、走るための靴紐を結び直す。体がまだ眠っているように重いと感じても、一歩を踏み出せば次の一歩は自然とついてきて、やがてリズムが生まれる。
91+
そうして気付けば僕は走っています。
92+
""".trimIndent()
93+
94+
// Repeat the block enough times to reach ~10,000 characters
95+
val repetitions = 50
96+
val largeText = block.repeat(repetitions)
97+
98+
val time = calculateAverageExecutionTime {
99+
val parser = Parser(Language.JAPANESE)
100+
parser.addDictionary("kanji", kanjiDictionaryJsonString)
101+
parser.findWords(largeText)
102+
}
103+
104+
printRes("parseJapaneseStressTest", time)
105+
}
106+
107+
private fun <T> calculateAverageExecutionTime(block: () -> T): Duration {
108+
val (_, time1) = measureTimedValue(block)
109+
val (_, time2) = measureTimedValue(block)
110+
val (_, time3) = measureTimedValue(block)
111+
val (_, time4) = measureTimedValue(block)
112+
val (_, time5) = measureTimedValue(block)
113+
114+
return (time1+time2+time3+time4+time5) / 5
115+
}
116+
117+
private fun printRes(name: String, duration: Duration) {
118+
print("$name: Average execution time is ${duration.inWholeMilliseconds}ms\n")
119+
}
120+
}

src/test/kotlin/ParserTest.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ class ParserTest : GengolexTest() {
5151

5252
words = parser.findWords("雨が降る")
5353
assertEquals(1, words.size)
54+
55+
words = parser.findWords("お誕生日おめでとう")
56+
assertEquals(1, words.size)
57+
58+
words = parser.findWords("早く行けお願い")
59+
assertEquals(1, words.size)
5460
}
5561

5662
@Test
@@ -145,6 +151,20 @@ class ParserTest : GengolexTest() {
145151

146152
words = parser.findWords("走る、走ってた、僕は走っています。")
147153
assertEquals(3, words.size)
154+
155+
// hiragana
156+
words = parser.findWords("お誕生日おめでとう")
157+
assertEquals(1, words.size)
158+
159+
words = parser.findWords("早く行けお願い")
160+
assertEquals(1, words.size)
161+
162+
// katakana
163+
words = parser.findWords("アジトに行こうぜ")
164+
assertEquals(1, words.size)
165+
166+
words = parser.findWords("俺のパソコンが遅い")
167+
assertEquals(1, words.size)
148168
}
149169

150170
@Test

src/test/kotlin/parser/WordFieldTest.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,29 @@ class WordFieldTest : GengolexTest() {
1515

1616
testWord("", "", Language.JAPANESE)
1717
testWord("僕は走っています", "走っています", Language.JAPANESE)
18+
testWord("彼が走っています", "走っています", Language.JAPANESE)
19+
testWord("道で走っています", "走っています", Language.JAPANESE)
1820
testWord("僕は走ってた", "走ってた", Language.JAPANESE)
21+
testWord("昨日も走ってた", "走ってた", Language.JAPANESE)
22+
testWord("友達と走ってた", "走ってた", Language.JAPANESE)
1923
testWord("僕は走る", "走る", Language.JAPANESE)
24+
testWord("明日走るつもりだ", "走る", Language.JAPANESE)
25+
testWord("毎日走るよ", "走る", Language.JAPANESE)
2026
testWord("私は", "", Language.JAPANESE)
27+
testWord("私の家はここです", "", Language.JAPANESE)
28+
testWord("私だけが知っている", "", Language.JAPANESE)
2129
testWord("雨が降る", "", Language.JAPANESE)
30+
testWord("雨が降っている", "", Language.JAPANESE)
31+
testWord("強い雨が降った", "", Language.JAPANESE)
2232
testWord("変わらないよ", "変わらない", Language.JAPANESE)
33+
testWord("変わらない景色", "変わらない", Language.JAPANESE)
34+
testWord("心は変わらない", "変わらない", Language.JAPANESE)
2335
testWord("近付いたら壊れてしまいそう", "近付いたら", Language.JAPANESE)
36+
testWord("君に近付いたら嬉しい", "近付いたら", Language.JAPANESE)
37+
testWord("犬に近付いたら噛まれた", "近付いたら", Language.JAPANESE)
38+
testWord("お願いが叶うなら", "お願い", Language.JAPANESE)
39+
testWord("お願いを聞いてくれますか", "お願い", Language.JAPANESE)
40+
testWord("小さなお願いがあるんだ", "お願い", Language.JAPANESE)
2441
}
2542

2643
@Test
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[
2+
{
3+
"parent": "",
4+
"word": "",
5+
"derivations": [
6+
{
7+
"parent": "please",
8+
"word": "お願い"
9+
},
10+
{
11+
"parent": "congratulations",
12+
"word": "おめでとう"
13+
}
14+
]
15+
}
16+
]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[
2+
{
3+
"parent": "",
4+
"word": "",
5+
"derivations": [
6+
{
7+
"parent": "hideout",
8+
"word": "アジト"
9+
}
10+
]
11+
},
12+
{
13+
"parent": "",
14+
"word": "",
15+
"derivations": [
16+
{
17+
"parent": "computer",
18+
"word": "パソコン"
19+
}
20+
]
21+
}
22+
]

0 commit comments

Comments
 (0)