yanagi 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +50 -0
- data/LICENSE +21 -0
- data/README.md +119 -0
- data/data/combinatorial.yml +59 -0
- data/data/exceptions.yml +15 -0
- data/data/exonyms.yml +14 -0
- data/data/mora.yml +790 -0
- data/exe/yanagi +7 -0
- data/lib/yanagi/audit.rb +301 -0
- data/lib/yanagi/cli.rb +345 -0
- data/lib/yanagi/cyrillic.rb +310 -0
- data/lib/yanagi/doc_sync.rb +194 -0
- data/lib/yanagi/lexicon.rb +211 -0
- data/lib/yanagi/mora.rb +81 -0
- data/lib/yanagi/normalize.rb +26 -0
- data/lib/yanagi/romaji.rb +55 -0
- data/lib/yanagi/rules.rb +89 -0
- data/lib/yanagi/version.rb +5 -0
- data/lib/yanagi.rb +15 -0
- data/yanagi.gemspec +34 -0
- metadata +94 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "normalize"
|
|
4
|
+
require_relative "rules"
|
|
5
|
+
require_relative "mora"
|
|
6
|
+
|
|
7
|
+
module Yanagi
|
|
8
|
+
Result = Struct.new(:text, :source, :confidence, :notes, keyword_init: true) do
|
|
9
|
+
def to_s
|
|
10
|
+
text.to_s
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_str
|
|
14
|
+
text.to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def ==(other)
|
|
18
|
+
if other.is_a?(String)
|
|
19
|
+
text == other
|
|
20
|
+
else
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def inspect
|
|
26
|
+
"#<Yanagi::Result text=#{text.inspect} source=#{source.inspect} confidence=#{confidence.inspect}>"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
module Cyrillic
|
|
31
|
+
O_COLUMN = %w[
|
|
32
|
+
お こ そ と の ほ も よ ろ を
|
|
33
|
+
ご ぞ ど ぼ ぽ
|
|
34
|
+
きょ しょ ちょ にょ ひょ みょ りょ ぎょ じょ びょ ぴょ
|
|
35
|
+
ふぉ ゔぉ
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
U_COLUMN = %w[
|
|
39
|
+
う く す つ ぬ ふ む ゆ る
|
|
40
|
+
ぐ ず づ ぶ ぷ
|
|
41
|
+
しゅ ちゅ じゅ きゅ ぎゅ にゅ ひゅ みゅ りゅ びゅ ぴゅ
|
|
42
|
+
ゔ どぅ とぅ
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
A_COLUMN = %w[
|
|
46
|
+
あ か さ た な は ま や ら わ
|
|
47
|
+
が ざ だ ば ぱ
|
|
48
|
+
きゃ しゃ ちゃ にゃ ひゃ みゃ りゃ ぎゃ じゃ びゃ ぴゃ
|
|
49
|
+
ふぁ ゔぁ
|
|
50
|
+
].freeze
|
|
51
|
+
|
|
52
|
+
E_COLUMN = %w[
|
|
53
|
+
え け せ て ね へ め れ ゑ
|
|
54
|
+
げ ぜ で べ ぺ
|
|
55
|
+
しぇ ちぇ つぇ ふぇ うぇ ゔぇ
|
|
56
|
+
].freeze
|
|
57
|
+
|
|
58
|
+
I_COLUMN = %w[
|
|
59
|
+
い き し ち に ひ み り ゐ
|
|
60
|
+
ぎ じ ぢ び ぴ
|
|
61
|
+
ふぃ ゔぃ てぃ でぃ
|
|
62
|
+
].freeze
|
|
63
|
+
|
|
64
|
+
def self.call(input = nil, kanji: nil, reading: nil)
|
|
65
|
+
raw_str = (reading || input).to_s.strip
|
|
66
|
+
kanji_str = kanji.to_s.strip
|
|
67
|
+
|
|
68
|
+
# 1. Whole-word exonym check
|
|
69
|
+
exonyms = Rules.exonyms
|
|
70
|
+
norm_key = Normalize.to_hiragana(Normalize.nfkc(raw_str))
|
|
71
|
+
if exonyms.key?(norm_key.to_sym)
|
|
72
|
+
entry = exonyms[norm_key.to_sym]
|
|
73
|
+
return Result.new(
|
|
74
|
+
text: entry[:cyrillic],
|
|
75
|
+
source: :exonym,
|
|
76
|
+
confidence: 1.0,
|
|
77
|
+
notes: entry[:note] || "Exonym"
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# 2. Check exceptions table
|
|
82
|
+
exceptions = Rules.exceptions
|
|
83
|
+
if exceptions.is_a?(Array)
|
|
84
|
+
match = exceptions.find do |e|
|
|
85
|
+
(!kanji_str.empty? && e[:kanji] == kanji_str) ||
|
|
86
|
+
e[:term] == raw_str ||
|
|
87
|
+
e[:expected] == raw_str
|
|
88
|
+
end
|
|
89
|
+
if match
|
|
90
|
+
return Result.new(
|
|
91
|
+
text: match[:canonical] || match[:expected],
|
|
92
|
+
source: :exception_table,
|
|
93
|
+
confidence: 1.0,
|
|
94
|
+
notes: match[:reason]
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# 3. Check lexicon (if present)
|
|
100
|
+
lexicon = Rules.lexicon
|
|
101
|
+
if lexicon.is_a?(Hash) && !kanji_str.empty? && lexicon.key?(kanji_str.to_sym)
|
|
102
|
+
entry = lexicon[kanji_str.to_sym]
|
|
103
|
+
return Result.new(
|
|
104
|
+
text: entry[:cyrillic] || entry[:reading_cyrillic],
|
|
105
|
+
source: :lexicon,
|
|
106
|
+
confidence: 1.0,
|
|
107
|
+
notes: entry[:gloss]
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# 4. If input is romaji (only ASCII letters and apostrophes/hyphens/spaces)
|
|
112
|
+
if raw_str.match?(/\A[a-zA-Z'\- ]+\z/)
|
|
113
|
+
converted_kana = romaji_to_hiragana(raw_str)
|
|
114
|
+
return render_kana(converted_kana)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# 5. Derived Cyrillic transliteration from kana
|
|
118
|
+
render_kana(raw_str)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.render_kana(kana_input)
|
|
122
|
+
moras = Tokenizer.tokenize(kana_input)
|
|
123
|
+
mora_map = Rules.mora_map
|
|
124
|
+
|
|
125
|
+
# Step 1: Render each mora into candidate text (handling long vowel collapse and i diphthongs)
|
|
126
|
+
rendered_segments = []
|
|
127
|
+
|
|
128
|
+
moras.each_with_index do |mora, idx|
|
|
129
|
+
prev_mora = idx > 0 ? moras[idx - 1] : nil
|
|
130
|
+
|
|
131
|
+
case mora.kind
|
|
132
|
+
when :passthrough
|
|
133
|
+
rendered_segments << { mora: mora, text: mora.kana.to_s, kind: :passthrough }
|
|
134
|
+
|
|
135
|
+
when :chouonpu
|
|
136
|
+
# Long vowel mark collapses under policy
|
|
137
|
+
rendered_segments << { mora: mora, text: "", kind: :chouonpu }
|
|
138
|
+
|
|
139
|
+
when :sokuon
|
|
140
|
+
# Sokuon is resolved in Step 2 with lookahead
|
|
141
|
+
rendered_segments << { mora: mora, text: nil, kind: :sokuon }
|
|
142
|
+
|
|
143
|
+
when :moraic_n
|
|
144
|
+
# Moraic n is resolved in Step 2 with lookahead
|
|
145
|
+
rendered_segments << { mora: mora, text: nil, kind: :moraic_n }
|
|
146
|
+
|
|
147
|
+
when :syllable
|
|
148
|
+
kana = mora.kana
|
|
149
|
+
base_cyr = mora_map.dig(kana.to_sym, :cyrillic)&.to_s || kana
|
|
150
|
+
|
|
151
|
+
# Long vowel collapse rules:
|
|
152
|
+
# - う after O-column or U-column collapses
|
|
153
|
+
if kana == "う" && prev_mora && prev_mora.syllable?
|
|
154
|
+
if O_COLUMN.include?(prev_mora.kana) || U_COLUMN.include?(prev_mora.kana)
|
|
155
|
+
rendered_segments << { mora: mora, text: "", kind: :collapsed_long_vowel }
|
|
156
|
+
next
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# - お after O-column collapses (e.g. おおたけ -> Отаке)
|
|
161
|
+
if kana == "お" && prev_mora && prev_mora.syllable? && O_COLUMN.include?(prev_mora.kana)
|
|
162
|
+
rendered_segments << { mora: mora, text: "", kind: :collapsed_long_vowel }
|
|
163
|
+
next
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# - あ after A-column collapses
|
|
167
|
+
if kana == "あ" && prev_mora && prev_mora.syllable? && A_COLUMN.include?(prev_mora.kana)
|
|
168
|
+
rendered_segments << { mora: mora, text: "", kind: :collapsed_long_vowel }
|
|
169
|
+
next
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# - え after E-column collapses
|
|
173
|
+
if kana == "え" && prev_mora && prev_mora.syllable? && E_COLUMN.include?(prev_mora.kana)
|
|
174
|
+
rendered_segments << { mora: mora, text: "", kind: :collapsed_long_vowel }
|
|
175
|
+
next
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# - い diphthong vs vowel:
|
|
179
|
+
# い after E-column -> 'й' (e.g. めい -> мей, せんせい -> сенсей)
|
|
180
|
+
# い after A-column -> 'й' (e.g. しゅうさい -> шюсай, たい -> тай)
|
|
181
|
+
# い after U-column -> 'і' (e.g. かるいざわ -> каруідзава, ぬいもん -> нуімон)
|
|
182
|
+
# い after O-column -> 'і' (e.g. ごい -> ґоі, どい -> доі)
|
|
183
|
+
# い after I-column -> 'і' (e.g. きいん -> кіін, いいだ -> ііда, りいち -> ріічі)
|
|
184
|
+
if kana == "い" && prev_mora && prev_mora.syllable?
|
|
185
|
+
if E_COLUMN.include?(prev_mora.kana) || A_COLUMN.include?(prev_mora.kana)
|
|
186
|
+
rendered_segments << { mora: mora, text: "й", kind: :syllable }
|
|
187
|
+
next
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
rendered_segments << { mora: mora, text: base_cyr, kind: :syllable }
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Step 2: Resolve sokuon (っ) and moraic n (ん) based on next rendered segment
|
|
196
|
+
final_parts = []
|
|
197
|
+
rendered_segments.each_with_index do |seg, idx|
|
|
198
|
+
if seg[:kind] == :sokuon
|
|
199
|
+
# Find next non-empty rendered segment
|
|
200
|
+
next_seg = rendered_segments[(idx + 1)..].find { |s| s[:text] && !s[:text].empty? }
|
|
201
|
+
if next_seg
|
|
202
|
+
next_text = next_seg[:text]
|
|
203
|
+
geminated = geminate_consonant(next_text)
|
|
204
|
+
final_parts << geminated if geminated
|
|
205
|
+
end
|
|
206
|
+
elsif seg[:kind] == :moraic_n
|
|
207
|
+
# Find next non-empty rendered segment
|
|
208
|
+
next_seg = rendered_segments[(idx + 1)..].find { |s| s[:text] && !s[:text].empty? }
|
|
209
|
+
if next_seg && starts_with_labial?(next_seg[:text])
|
|
210
|
+
final_parts << "м"
|
|
211
|
+
else
|
|
212
|
+
final_parts << "н"
|
|
213
|
+
end
|
|
214
|
+
else
|
|
215
|
+
final_parts << seg[:text]
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
Result.new(
|
|
220
|
+
text: final_parts.join,
|
|
221
|
+
source: :derived,
|
|
222
|
+
confidence: 1.0,
|
|
223
|
+
notes: nil
|
|
224
|
+
)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Sokuon doubles only before the plosives «п» and «к», which Ukrainian
|
|
228
|
+
# carries comfortably (Іппекі, кеппекі, Ніккай, Хоккекьо). Before the
|
|
229
|
+
# sibilants and affricates it is not rendered: шш, чч and ддж read as
|
|
230
|
+
# foreign, so шічяку, Тешю, доджін-дзаші.
|
|
231
|
+
GEMINATING_CONSONANTS = %w[п к].freeze
|
|
232
|
+
|
|
233
|
+
def self.geminate_consonant(next_rendered_text)
|
|
234
|
+
return "" if next_rendered_text.nil? || next_rendered_text.empty?
|
|
235
|
+
|
|
236
|
+
first_char = next_rendered_text[0]
|
|
237
|
+
GEMINATING_CONSONANTS.include?(first_char) ? first_char : ""
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def self.starts_with_labial?(text)
|
|
241
|
+
return false if text.nil? || text.empty?
|
|
242
|
+
# Labials: 'п', 'б'
|
|
243
|
+
text.start_with?("п", "б")
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Simple romaji to hiragana conversion for Mode A romaji input
|
|
247
|
+
def self.romaji_to_hiragana(str)
|
|
248
|
+
norm = str.downcase.gsub("'", "'")
|
|
249
|
+
mora_map = Rules.mora_map
|
|
250
|
+
|
|
251
|
+
# Build inverted map from romaji -> hiragana kana
|
|
252
|
+
@romaji_to_hira_table ||= begin
|
|
253
|
+
table = {}
|
|
254
|
+
mora_map.each do |kana_sym, data|
|
|
255
|
+
rom = data[:romaji]&.to_s
|
|
256
|
+
table[rom] = kana_sym.to_s if rom
|
|
257
|
+
end
|
|
258
|
+
# Sort by romaji length descending to match longest substrings first
|
|
259
|
+
table.sort_by { |k, _v| -k.length }.to_h
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
res = []
|
|
263
|
+
i = 0
|
|
264
|
+
len = norm.length
|
|
265
|
+
while i < len
|
|
266
|
+
matched = false
|
|
267
|
+
|
|
268
|
+
# Check sokuon in romaji (e.g. kk, pp, tt, ss, tch, ssh, etc.)
|
|
269
|
+
c = norm[i]
|
|
270
|
+
c_next = norm[i + 1]
|
|
271
|
+
c3 = norm[i, 3]
|
|
272
|
+
|
|
273
|
+
if c3 == "tch"
|
|
274
|
+
res << "っ"
|
|
275
|
+
i += 1
|
|
276
|
+
next
|
|
277
|
+
elsif c == c_next && c =~ /[bcdfghjklmpqrstvwxyz]/ && c != "n"
|
|
278
|
+
res << "っ"
|
|
279
|
+
i += 1
|
|
280
|
+
next
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Match table
|
|
284
|
+
@romaji_to_hira_table.each do |rom, kana|
|
|
285
|
+
if norm[i..].start_with?(rom)
|
|
286
|
+
res << kana
|
|
287
|
+
i += rom.length
|
|
288
|
+
matched = true
|
|
289
|
+
break
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
unless matched
|
|
294
|
+
res << norm[i]
|
|
295
|
+
i += 1
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
res.join
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def self.cyrillic(input = nil, kanji: nil, reading: nil)
|
|
304
|
+
Cyrillic.call(input, kanji: kanji, reading: reading)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def self.from_romaji(romaji_str)
|
|
308
|
+
Cyrillic.call(romaji_str)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rules"
|
|
4
|
+
|
|
5
|
+
module Yanagi
|
|
6
|
+
class DocSync
|
|
7
|
+
attr_reader :path
|
|
8
|
+
|
|
9
|
+
def self.default_path
|
|
10
|
+
candidates = [
|
|
11
|
+
File.expand_path("~/projects/meijin/shared/transliteration.md"),
|
|
12
|
+
File.expand_path("shared/transliteration.md", Dir.pwd),
|
|
13
|
+
File.expand_path("../../shared/transliteration.md", __dir__)
|
|
14
|
+
]
|
|
15
|
+
candidates.find { |p| File.exist?(p) } || candidates.first
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(path: nil)
|
|
19
|
+
@path = path || self.class.default_path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def diff(rules = nil)
|
|
23
|
+
rules ||= Rules.mora_map
|
|
24
|
+
return [{ kind: :file_not_found, path: @path }] unless File.exist?(@path)
|
|
25
|
+
|
|
26
|
+
content = File.read(@path, encoding: "UTF-8")
|
|
27
|
+
differences = []
|
|
28
|
+
|
|
29
|
+
# 1. Parse §1 table
|
|
30
|
+
table_1_entries = parse_section_1_table(content)
|
|
31
|
+
table_1_entries.each do |entry|
|
|
32
|
+
entry[:kanas].each do |kana|
|
|
33
|
+
rule_data = rules[kana.to_sym]
|
|
34
|
+
next unless rule_data
|
|
35
|
+
|
|
36
|
+
expected_cyr = rule_data[:cyrillic]
|
|
37
|
+
expected_forb = rule_data[:forbidden] || []
|
|
38
|
+
|
|
39
|
+
# Check if doc Cyrillic matches expected (either exact mora, base consonant prefix, or included in listed items)
|
|
40
|
+
matched_cyr = entry[:cyrillic_items].include?(expected_cyr) ||
|
|
41
|
+
(entry[:base_cyrillic] && expected_cyr.start_with?(entry[:base_cyrillic]))
|
|
42
|
+
|
|
43
|
+
unless matched_cyr
|
|
44
|
+
differences << {
|
|
45
|
+
kind: :mora_mismatch,
|
|
46
|
+
section: 1,
|
|
47
|
+
kana: kana,
|
|
48
|
+
doc: entry[:base_cyrillic] || entry[:cyrillic_items].join(", "),
|
|
49
|
+
rules: expected_cyr
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Check forbidden variants
|
|
54
|
+
if entry[:forbidden_items].any?
|
|
55
|
+
# At least one forbidden form or base prefix in doc should match rule's forbidden
|
|
56
|
+
matched_forb = entry[:forbidden_items].any? do |df|
|
|
57
|
+
expected_forb.any? { |rf| rf == df || rf.start_with?(df) }
|
|
58
|
+
end || (entry[:base_forbidden] && expected_forb.any? { |rf| rf.start_with?(entry[:base_forbidden]) })
|
|
59
|
+
|
|
60
|
+
unless matched_forb
|
|
61
|
+
differences << {
|
|
62
|
+
kind: :forbidden_mismatch,
|
|
63
|
+
section: 1,
|
|
64
|
+
kana: kana,
|
|
65
|
+
doc_forbidden: entry[:forbidden_items].join(", "),
|
|
66
|
+
rules_forbidden: expected_forb
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# 2. Parse §2 bullet points (Yōon)
|
|
74
|
+
yoon_entries = parse_section_2_yoon(content)
|
|
75
|
+
yoon_entries.each do |entry|
|
|
76
|
+
entry[:kanas].each_with_index do |kana, idx|
|
|
77
|
+
expected_cyr = rules.dig(kana.to_sym, :cyrillic)
|
|
78
|
+
next unless expected_cyr
|
|
79
|
+
|
|
80
|
+
doc_cyr = entry[:cyrillics][idx] || entry[:cyrillics].first
|
|
81
|
+
if doc_cyr && doc_cyr != expected_cyr
|
|
82
|
+
differences << {
|
|
83
|
+
kind: :mora_mismatch,
|
|
84
|
+
section: 2,
|
|
85
|
+
kana: kana,
|
|
86
|
+
doc: doc_cyr,
|
|
87
|
+
rules: expected_cyr
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
differences
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def synced?(rules = nil)
|
|
97
|
+
diff(rules).empty?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def clean_markdown_markup(str)
|
|
103
|
+
str.to_s.gsub(/\*\*/, "").gsub(/__/, "").gsub(/`/, "").strip
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def parse_section_1_table(content)
|
|
107
|
+
entries = []
|
|
108
|
+
in_sec_1 = false
|
|
109
|
+
|
|
110
|
+
content.each_line do |line|
|
|
111
|
+
if line =~ /^##\s+1\.\s+/
|
|
112
|
+
in_sec_1 = true
|
|
113
|
+
next
|
|
114
|
+
elsif line =~ /^##\s+2\.\s+/
|
|
115
|
+
break
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
next unless in_sec_1
|
|
119
|
+
next unless line.start_with?("|")
|
|
120
|
+
parts = line.split("|").map(&:strip).reject(&:empty?)
|
|
121
|
+
next if parts.empty? || parts[0].start_with?("-") || parts[0] =~ /Японська/i
|
|
122
|
+
|
|
123
|
+
kana_col = parts[0]
|
|
124
|
+
cyr_col = parts[1]
|
|
125
|
+
forb_col = parts[2]
|
|
126
|
+
|
|
127
|
+
kanas = extract_kanas(kana_col)
|
|
128
|
+
|
|
129
|
+
clean_cyr = clean_markdown_markup(cyr_col)
|
|
130
|
+
clean_forb = clean_markdown_markup(forb_col)
|
|
131
|
+
|
|
132
|
+
base_cyr = clean_cyr.split(/[\s(]/).first
|
|
133
|
+
base_forb = clean_forb.split(/[\s(]/).first
|
|
134
|
+
|
|
135
|
+
cyr_items = if clean_cyr =~ /\((.+?)\)/
|
|
136
|
+
$1.split(",").flat_map { |s| s.split("/") }.map(&:strip).reject(&:empty?)
|
|
137
|
+
else
|
|
138
|
+
[base_cyr]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
forb_items = if clean_forb =~ /\((.+?)\)/
|
|
142
|
+
$1.split(",").flat_map { |s| s.split("/") }.map(&:strip).reject(&:empty?)
|
|
143
|
+
else
|
|
144
|
+
clean_forb.split(",").flat_map { |s| s.split("/") }.map(&:strip).reject(&:empty?)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
entries << {
|
|
148
|
+
kanas: kanas,
|
|
149
|
+
base_cyrillic: base_cyr,
|
|
150
|
+
cyrillic_items: cyr_items,
|
|
151
|
+
base_forbidden: base_forb,
|
|
152
|
+
forbidden_items: forb_items
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
entries
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def parse_section_2_yoon(content)
|
|
160
|
+
entries = []
|
|
161
|
+
in_sec_2 = false
|
|
162
|
+
|
|
163
|
+
content.each_line do |line|
|
|
164
|
+
if line =~ /^##\s+2\.\s+/
|
|
165
|
+
in_sec_2 = true
|
|
166
|
+
next
|
|
167
|
+
elsif line =~ /^##\s+3\.\s+/
|
|
168
|
+
break
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
next unless in_sec_2
|
|
172
|
+
if line =~ /^\s*-\s+(.+?)\s*→\s*(.+?)(?:\(|$)/
|
|
173
|
+
left = $1.strip
|
|
174
|
+
right = $2.strip
|
|
175
|
+
|
|
176
|
+
kanas = extract_kanas(left)
|
|
177
|
+
cyrillics = clean_markdown_markup(right).split(/[\s\/]+/).map(&:strip).reject(&:empty?)
|
|
178
|
+
|
|
179
|
+
entries << { kanas: kanas, cyrillics: cyrillics }
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
entries
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def extract_kanas(str)
|
|
187
|
+
str.scan(/[\u3040-\u309F]+/)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def self.doc_sync(path: nil)
|
|
192
|
+
DocSync.new(path: path)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "rules"
|
|
5
|
+
require_relative "normalize"
|
|
6
|
+
require_relative "cyrillic"
|
|
7
|
+
|
|
8
|
+
module Yanagi
|
|
9
|
+
module Lexicon
|
|
10
|
+
UKRAINIAN_SUFFIXES = %w[
|
|
11
|
+
ами ями ах ях ів їв ою ею єю
|
|
12
|
+
ом ем єм
|
|
13
|
+
а е и і о у ю я ї й
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
MIN_STEM_LENGTH = 4
|
|
17
|
+
|
|
18
|
+
def self.load_entries
|
|
19
|
+
Rules.lexicon || {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Terms of non-Japanese origin (Chinese works, place names) are transliterated
|
|
23
|
+
# by their own language's conventions, so Japanese mora rules must not apply.
|
|
24
|
+
# Detected from the glossary note column, which states the provenance.
|
|
25
|
+
NON_JP_NOTE_MARKERS = [
|
|
26
|
+
"Китай", "китайськ", "конфуціанськ", "Даоськ", "даоськ", "Янцзи"
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
def self.detect_origin(note)
|
|
30
|
+
text = note.to_s
|
|
31
|
+
return "zh" if NON_JP_NOTE_MARKERS.any? { |m| text.include?(m) }
|
|
32
|
+
|
|
33
|
+
"ja"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.build_from_glossary(glossary_path, out_path: nil)
|
|
37
|
+
content = File.read(glossary_path, encoding: "UTF-8")
|
|
38
|
+
lexicon = {}
|
|
39
|
+
|
|
40
|
+
content.each_line do |line|
|
|
41
|
+
next unless line.start_with?("|")
|
|
42
|
+
parts = line.split("|").map(&:strip).reject(&:empty?)
|
|
43
|
+
next if parts.empty? || parts[0].start_with?("-") || parts[0] =~ /Японське/i
|
|
44
|
+
|
|
45
|
+
col0 = parts[0]
|
|
46
|
+
col1 = parts[1]
|
|
47
|
+
col2 = parts[2]
|
|
48
|
+
|
|
49
|
+
kanji = nil
|
|
50
|
+
reading = nil
|
|
51
|
+
if col0 =~ /^(.+?)\s*\((.+?)\)$/
|
|
52
|
+
kanji = Regexp.last_match(1).strip
|
|
53
|
+
reading = Regexp.last_match(2).strip
|
|
54
|
+
else
|
|
55
|
+
kanji = col0.strip
|
|
56
|
+
reading = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
uk_clean = col1.gsub(/^[«"'\`]|["'\`»]$/, "").strip
|
|
60
|
+
key = kanji.gsub(/^[«"'\`]|["'\`»]$/, "").strip
|
|
61
|
+
next if key.empty?
|
|
62
|
+
|
|
63
|
+
cyr = reading || uk_clean
|
|
64
|
+
|
|
65
|
+
lexicon[key] = {
|
|
66
|
+
"kanji" => kanji,
|
|
67
|
+
"reading" => reading,
|
|
68
|
+
"cyrillic" => cyr,
|
|
69
|
+
"ukrainian" => col1,
|
|
70
|
+
"note" => col2,
|
|
71
|
+
"origin" => detect_origin(col2)
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
out_file = out_path || Rules.path_for("lexicon.yml")
|
|
76
|
+
File.write(out_file, YAML.dump(lexicon))
|
|
77
|
+
reload!
|
|
78
|
+
Rules.reload!
|
|
79
|
+
lexicon
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.merge_proposal(proposal_path, out_path: nil)
|
|
83
|
+
proposals = YAML.safe_load_file(proposal_path, permitted_classes: [Symbol, Date], symbolize_names: true) || []
|
|
84
|
+
proposals = [proposals] unless proposals.is_a?(Array)
|
|
85
|
+
|
|
86
|
+
current_lexicon = Rules.lexicon ? Rules.lexicon.transform_keys(&:to_s) : {}
|
|
87
|
+
new_lexicon = current_lexicon.dup
|
|
88
|
+
|
|
89
|
+
errors = []
|
|
90
|
+
|
|
91
|
+
proposals.each do |prop|
|
|
92
|
+
status = prop[:status]&.to_s
|
|
93
|
+
unless status == "accepted"
|
|
94
|
+
errors << "Proposal #{prop[:term] || prop[:kanji]} status is '#{status}', must be 'accepted' to merge"
|
|
95
|
+
next
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
kanji = prop[:kanji]&.to_s
|
|
99
|
+
reading = prop[:reading]&.to_s
|
|
100
|
+
cyrillic = prop[:cyrillic]&.to_s
|
|
101
|
+
|
|
102
|
+
if reading && !reading.empty? && cyrillic && !cyrillic.empty?
|
|
103
|
+
derived_cyr = Yanagi.cyrillic(reading).text
|
|
104
|
+
if derived_cyr != cyrillic && prop[:override] != true
|
|
105
|
+
errors << "Proposal #{kanji || reading} Cyrillic '#{cyrillic}' does not match engine derived '#{derived_cyr}'"
|
|
106
|
+
next
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
key = (kanji && !kanji.empty?) ? kanji : cyrillic
|
|
111
|
+
new_lexicon[key] = {
|
|
112
|
+
"kanji" => kanji,
|
|
113
|
+
"reading" => reading,
|
|
114
|
+
"cyrillic" => cyrillic,
|
|
115
|
+
"ukrainian" => prop[:ukrainian] || cyrillic,
|
|
116
|
+
"note" => prop[:note]
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
unless errors.empty?
|
|
121
|
+
raise Error, "Cannot merge proposals:\n- #{errors.join("\n- ")}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
out_file = out_path || Rules.path_for("lexicon.yml")
|
|
125
|
+
File.write(out_file, YAML.dump(new_lexicon))
|
|
126
|
+
reload!
|
|
127
|
+
Rules.reload!
|
|
128
|
+
new_lexicon
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.strip_inflection(token)
|
|
132
|
+
w = token.downcase.gsub(/^[«"'\`\(\[\{]|["'\`\)\]\}\.,;:!?—–]+$/, "")
|
|
133
|
+
return [w, ""] if w.length <= MIN_STEM_LENGTH
|
|
134
|
+
|
|
135
|
+
UKRAINIAN_SUFFIXES.each do |suffix|
|
|
136
|
+
if w.end_with?(suffix) && (w.length - suffix.length) >= MIN_STEM_LENGTH
|
|
137
|
+
stem = w[0...(w.length - suffix.length)]
|
|
138
|
+
return [stem, suffix]
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
[w, ""]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.reload!
|
|
146
|
+
@exact_index = nil
|
|
147
|
+
@stem_index = nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.exact_index
|
|
151
|
+
@exact_index ||= begin
|
|
152
|
+
idx = {}
|
|
153
|
+
load_entries.each do |k, entry|
|
|
154
|
+
cyr = (entry[:cyrillic] || entry["cyrillic"])&.to_s&.downcase
|
|
155
|
+
next unless cyr
|
|
156
|
+
|
|
157
|
+
# Index full term
|
|
158
|
+
idx[cyr] = { key: k.to_s, entry: entry, stem: cyr, suffix: "", exact: true }
|
|
159
|
+
|
|
160
|
+
# Also index individual words of multi-word phrases (e.g. 'хонінбо', 'шюсай')
|
|
161
|
+
words = cyr.split(/[\s-]+/).map(&:strip).reject(&:empty?)
|
|
162
|
+
if words.length > 1
|
|
163
|
+
words.each do |w|
|
|
164
|
+
idx[w] ||= { key: k.to_s, entry: entry, stem: w, suffix: "", exact: true } if w.length >= MIN_STEM_LENGTH
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
idx
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.stem_index
|
|
173
|
+
@stem_index ||= begin
|
|
174
|
+
idx = {}
|
|
175
|
+
load_entries.each do |k, entry|
|
|
176
|
+
cyr = (entry[:cyrillic] || entry["cyrillic"])&.to_s&.downcase
|
|
177
|
+
next unless cyr
|
|
178
|
+
|
|
179
|
+
words = cyr.split(/[\s-]+/).map(&:strip).reject(&:empty?)
|
|
180
|
+
words.each do |w|
|
|
181
|
+
stem, _ = strip_inflection(w)
|
|
182
|
+
idx[stem] ||= { key: k.to_s, entry: entry, stem: stem } if stem.length >= MIN_STEM_LENGTH
|
|
183
|
+
idx[w] ||= { key: k.to_s, entry: entry, stem: w } if w.length >= MIN_STEM_LENGTH
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
idx
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def self.find_by_stem(token)
|
|
191
|
+
clean = token.downcase.gsub(/^[«"'\`\(\[\{]|["'\`\)\]\}\.,;:!?—–]+$/, "")
|
|
192
|
+
return exact_index[clean] if exact_index.key?(clean)
|
|
193
|
+
|
|
194
|
+
stem, suffix = strip_inflection(clean)
|
|
195
|
+
return nil if stem.length < MIN_STEM_LENGTH
|
|
196
|
+
|
|
197
|
+
if stem_index.key?(stem)
|
|
198
|
+
info = stem_index[stem]
|
|
199
|
+
return {
|
|
200
|
+
key: info[:key],
|
|
201
|
+
entry: info[:entry],
|
|
202
|
+
stem: stem,
|
|
203
|
+
suffix: suffix,
|
|
204
|
+
exact: false
|
|
205
|
+
}
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|