jlpt 1.0.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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.ja.md +246 -0
  4. data/README.md +224 -0
  5. data/assets/logo.png +0 -0
  6. data/bin/jlpt +7 -0
  7. data/lib/jlpt/active_model/validator.rb +43 -0
  8. data/lib/jlpt/analyzers/comparer.rb +44 -0
  9. data/lib/jlpt/analyzers/conjugation.rb +60 -0
  10. data/lib/jlpt/analyzers/corpus_stats.rb +38 -0
  11. data/lib/jlpt/analyzers/dokkai.rb +53 -0
  12. data/lib/jlpt/analyzers/exam_readiness.rb +52 -0
  13. data/lib/jlpt/analyzers/grammar.rb +50 -0
  14. data/lib/jlpt/analyzers/jukugo.rb +46 -0
  15. data/lib/jlpt/analyzers/loanwords.rb +54 -0
  16. data/lib/jlpt/analyzers/onomatopeia.rb +29 -0
  17. data/lib/jlpt/analyzers/pitch_accent.rb +51 -0
  18. data/lib/jlpt/analyzers/pos_profiler.rb +47 -0
  19. data/lib/jlpt/analyzers/simplifier.rb +48 -0
  20. data/lib/jlpt/analyzers/style_profiler.rb +62 -0
  21. data/lib/jlpt/analyzers/syntax.rb +43 -0
  22. data/lib/jlpt/cli.rb +121 -0
  23. data/lib/jlpt/core/analysis_result.rb +45 -0
  24. data/lib/jlpt/core/analyzer.rb +58 -0
  25. data/lib/jlpt/core/cache_manager.rb +90 -0
  26. data/lib/jlpt/core/config.rb +45 -0
  27. data/lib/jlpt/core/dictionary.rb +113 -0
  28. data/lib/jlpt/core/engine.rb +142 -0
  29. data/lib/jlpt/core/kanji.rb +54 -0
  30. data/lib/jlpt/core/kanji_details.rb +60 -0
  31. data/lib/jlpt/core/preprocessor.rb +48 -0
  32. data/lib/jlpt/core/tokenizer.rb +64 -0
  33. data/lib/jlpt/core/vocab_classifier.rb +48 -0
  34. data/lib/jlpt/data/kanji.json +2213 -0
  35. data/lib/jlpt/data/vocab.json +50298 -0
  36. data/lib/jlpt/formatters/batch_processor.rb +41 -0
  37. data/lib/jlpt/formatters/exporter.rb +55 -0
  38. data/lib/jlpt/formatters/furigana.rb +69 -0
  39. data/lib/jlpt/formatters/reporter.rb +72 -0
  40. data/lib/jlpt/version.rb +5 -0
  41. data/lib/jlpt.rb +50 -0
  42. metadata +184 -0
data/lib/jlpt/cli.rb ADDED
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'json'
5
+
6
+ module JLPT
7
+ # Command Line Interface runner for JLPT gem.
8
+ module CLI
9
+ VALID_COMMANDS = %w[analyze furigana export batch kanji conjugate simplify stats].freeze
10
+
11
+ class << self
12
+ def run(args = ARGV)
13
+ options = { format: :text, target_level: :n4 }
14
+ parser = build_option_parser(options)
15
+ parser.parse!(args)
16
+ return 0 if options[:exit_early]
17
+
18
+ command = args.shift || 'analyze'
19
+ return warn_unknown(command) unless VALID_COMMANDS.include?(command.to_s.downcase)
20
+
21
+ execute_command(command, read_input(args), options)
22
+ end
23
+
24
+ private
25
+
26
+ def build_option_parser(opts_hash)
27
+ OptionParser.new do |opts|
28
+ opts.banner = 'Usage: jlpt [command] [options] <text or filepath>'
29
+ opts.on('-f', '--format FMT', %i[text json html markdown]) { |f| opts_hash[:format] = f }
30
+ opts.on('-t', '--target LVL', %i[n5 n4 n3 n2 n1]) { |l| opts_hash[:target_level] = l }
31
+ add_meta_flags(opts, opts_hash)
32
+ end
33
+ end
34
+
35
+ def add_meta_flags(opts, opts_hash)
36
+ opts.on('-v', '--version') do
37
+ puts "jlpt version #{JLPT::VERSION}"
38
+ opts_hash[:exit_early] = true
39
+ end
40
+ opts.on('-h', '--help') do
41
+ puts opts
42
+ opts_hash[:exit_early] = true
43
+ end
44
+ end
45
+
46
+ def warn_unknown(command)
47
+ warn "Unknown command: #{command}"
48
+ 1
49
+ end
50
+
51
+ def execute_command(command, input, options)
52
+ cmd = command.to_s.downcase
53
+ if %w[analyze furigana export].include?(cmd)
54
+ execute_core_command(cmd, input, options)
55
+ else
56
+ execute_module_command(cmd, input, options)
57
+ end
58
+ end
59
+
60
+ def execute_core_command(cmd, input, options)
61
+ case cmd
62
+ when 'analyze' then output_analysis(JLPT.analyze(input), options[:format])
63
+ when 'furigana' then puts Furigana.render(input, format: options[:format] == :html ? :html : :markdown)
64
+ when 'export' then puts Exporter.to_anki_tsv(input, target_level: options[:target_level])
65
+ end
66
+ 0
67
+ end
68
+
69
+ def execute_module_command(cmd, input, options)
70
+ data = parse_module_data(cmd, input)
71
+ options[:format] == :json ? puts(JSON.pretty_generate(data)) : puts(data.inspect)
72
+ 0
73
+ end
74
+
75
+ def parse_module_data(cmd, input)
76
+ case cmd
77
+ when 'batch' then parse_batch_input(input)
78
+ when 'kanji' then KanjiDetails.details_for_text(input)
79
+ when 'conjugate' then Conjugation.analyze(input)
80
+ when 'simplify' then Simplifier.simplify(input)
81
+ when 'stats' then CorpusStats.analyze(input)
82
+ end
83
+ end
84
+
85
+ def parse_batch_input(input)
86
+ parsed = begin
87
+ JSON.parse(input)
88
+ rescue StandardError
89
+ input
90
+ end
91
+ BatchProcessor.process(parsed)
92
+ end
93
+
94
+ def read_input(args)
95
+ return '' if args.empty? && $stdin.tty?
96
+ return $stdin.read if args.empty?
97
+
98
+ File.exist?(args.first) ? File.read(args.first) : args.join(' ')
99
+ rescue StandardError
100
+ ''
101
+ end
102
+
103
+ def output_analysis(result, format)
104
+ case format
105
+ when :json then puts JSON.pretty_generate(result.to_h)
106
+ when :html then puts Reporter.render_html(result)
107
+ when :markdown then puts Reporter.render_markdown(result)
108
+ else print_analysis_summary(result)
109
+ end
110
+ end
111
+
112
+ def print_analysis_summary(result)
113
+ puts "Recommended Level: #{result.recommended_level.to_s.upcase}"
114
+ puts "Difficulty Score: #{result.score} / 100"
115
+ puts "Word Count: #{result.word_count}"
116
+ puts "Sentence Count: #{result.sentence_count}"
117
+ puts "Kanji Density: #{(result.kanji_density * 100).round(1)}%"
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JLPT
4
+ # Value Object encapsulating JLPT text analysis results.
5
+ #
6
+ # Supports both method dot-notation and Hash subscripting access.
7
+ #
8
+ class AnalysisResult
9
+ attr_reader :text, :recommended_level, :score, :kanji_density,
10
+ :vocab_distribution, :kanji_breakdown,
11
+ :sentence_count, :word_count
12
+
13
+ def initialize(attrs = {})
14
+ @text = attrs.fetch(:text, '')
15
+ @recommended_level = attrs.fetch(:recommended_level, :n5)
16
+ @score = attrs.fetch(:score, 0.0)
17
+ @kanji_density = attrs.fetch(:kanji_density, 0.0)
18
+ @vocab_distribution = attrs.fetch(:vocab_distribution, {})
19
+ @kanji_breakdown = attrs.fetch(:kanji_breakdown, {})
20
+ @sentence_count = attrs.fetch(:sentence_count, 0)
21
+ @word_count = attrs.fetch(:word_count, 0)
22
+ end
23
+
24
+ def [](key)
25
+ public_send(key) if respond_to?(key)
26
+ end
27
+
28
+ def to_h
29
+ {
30
+ text: @text,
31
+ recommended_level: @recommended_level,
32
+ score: @score,
33
+ kanji_density: @kanji_density,
34
+ vocab_distribution: @vocab_distribution,
35
+ kanji_breakdown: @kanji_breakdown,
36
+ sentence_count: @sentence_count,
37
+ word_count: @word_count
38
+ }
39
+ end
40
+
41
+ def inspect
42
+ "#<JLPT::AnalysisResult level=#{@recommended_level.to_s.upcase} score=#{@score}>"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JLPT
4
+ # Core Analyzer Engine computing overall text difficulty and JLPT level recommendation.
5
+ #
6
+ module Analyzer
7
+ LEVEL_WEIGHTS = { n5: 1.0, n4: 2.0, n3: 3.0, n2: 4.0, n1: 5.0, non_jlpt: 2.5, out_of_jlpt: 3.5 }.freeze
8
+
9
+ class << self
10
+ # Analyze Japanese text and return structured AnalysisResult
11
+ #
12
+ # @param text [String] Japanese text
13
+ # @return [JLPT::AnalysisResult] result object
14
+ def analyze(text)
15
+ cleaned = Preprocessor.clean(text)
16
+ return AnalysisResult.new(text: text.to_s) if cleaned.empty?
17
+
18
+ CacheManager.fetch(cleaned) { build_analysis_result(cleaned) }
19
+ end
20
+
21
+ private
22
+
23
+ def build_analysis_result(cleaned)
24
+ sents = Preprocessor.sentences(cleaned)
25
+ tokens = Tokenizer.tokenize(cleaned)
26
+ vocab_dist = VocabClassifier.distribution(cleaned)
27
+ kanji_bd = Kanji.breakdown(cleaned)
28
+ score = calculate_score(vocab_dist, kanji_bd)
29
+
30
+ AnalysisResult.new(text: cleaned, recommended_level: score_to_level(score), score: score,
31
+ kanji_density: Kanji.density(cleaned), vocab_distribution: vocab_dist,
32
+ kanji_breakdown: kanji_bd, sentence_count: sents.length, word_count: tokens.length)
33
+ end
34
+
35
+ def calculate_score(v_dist, k_bd)
36
+ v_score = v_dist.sum { |l, p| (p / 100.0) * (LEVEL_WEIGHTS[l] || 1.0) }
37
+ k_score = k_bd.values.sum(&:length).positive? ? compute_kanji_score(k_bd) : 1.0
38
+
39
+ ((((v_score * 0.6) + (k_score * 0.4)) - 1.0) / 4.0 * 100.0).clamp(0.0, 100.0).round(2)
40
+ end
41
+
42
+ def compute_kanji_score(kanji_bd)
43
+ total = kanji_bd.values.sum(&:length)
44
+ kanji_bd.sum { |l, c| (c.length.to_f / total) * (LEVEL_WEIGHTS[l] || 1.0) }
45
+ end
46
+
47
+ def score_to_level(score)
48
+ case score
49
+ when 0.0..25.0 then :n5
50
+ when 25.01..35.0 then :n4
51
+ when 35.01..41.0 then :n3
52
+ when 41.01..46.0 then :n2
53
+ else :n1
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JLPT
4
+ # Thread-safe LRU Cache Manager for JLPT Text Analysis.
5
+ #
6
+ # Caches analysis results to prevent redundant processing for identical texts.
7
+ #
8
+ module CacheManager
9
+ MUTEX = Mutex.new
10
+ @enabled = true
11
+ DEFAULT_CAPACITY = 1000
12
+
13
+ class << self
14
+ # Fetch cached result or compute via block
15
+ #
16
+ # @param key [String] cache key (text)
17
+ # @yield Compute result if not cached
18
+ # @return [Object] cached or computed value
19
+ def fetch(key, &block)
20
+ return yield unless enabled? && key.is_a?(String) && !key.empty?
21
+
22
+ MUTEX.synchronize { read_or_write_cache(key, &block) }
23
+ end
24
+
25
+ # Clear the cache store
26
+ def clear
27
+ MUTEX.synchronize { store.clear }
28
+ end
29
+
30
+ # Current cache size
31
+ #
32
+ # @return [Integer]
33
+ def size
34
+ MUTEX.synchronize { store.length }
35
+ end
36
+
37
+ # Check if cache is enabled
38
+ #
39
+ # @return [Boolean]
40
+ def enabled?
41
+ @enabled = true if @enabled.nil?
42
+ @enabled
43
+ end
44
+
45
+ # Enable caching
46
+ def enable!
47
+ @enabled = true
48
+ end
49
+
50
+ # Disable caching
51
+ def disable!
52
+ @enabled = false
53
+ end
54
+
55
+ # Maximum capacity
56
+ #
57
+ # @return [Integer]
58
+ def capacity
59
+ @capacity ||= DEFAULT_CAPACITY
60
+ end
61
+
62
+ # Set capacity
63
+ def capacity=(cap)
64
+ MUTEX.synchronize do
65
+ @capacity = cap
66
+ store.shift while store.length > cap
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def store
73
+ @store ||= {}
74
+ end
75
+
76
+ def read_or_write_cache(key)
77
+ if store.key?(key)
78
+ val = store.delete(key)
79
+ store[key] = val
80
+ return val
81
+ end
82
+
83
+ val = yield
84
+ store[key] = val
85
+ store.shift if store.length > capacity
86
+ val
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Main entry point for the JLPT Japanese text analysis library.
4
+ module JLPT
5
+ # Global Configuration Engine.
6
+ #
7
+ # Manages default options such as furigana format, cache capacity,
8
+ # and fallback behaviors.
9
+ #
10
+ class Config
11
+ attr_accessor :default_furigana_format, :cache_capacity, :mecab_fallback
12
+
13
+ def initialize
14
+ @default_furigana_format = :html
15
+ @cache_capacity = 1000
16
+ @mecab_fallback = true
17
+ end
18
+
19
+ # Reset configuration to default values
20
+ def reset!
21
+ initialize
22
+ end
23
+ end
24
+
25
+ class << self
26
+ # Return global config instance
27
+ #
28
+ # @return [JLPT::Config]
29
+ def config
30
+ @config ||= Config.new
31
+ end
32
+
33
+ # Configure global JLPT library settings
34
+ #
35
+ # @yield [JLPT::Config]
36
+ def configure
37
+ yield(config) if block_given?
38
+ end
39
+
40
+ # Reset configuration to defaults
41
+ def reset_config!
42
+ config.reset!
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module JLPT
6
+ # Thread-safe In-Memory Dictionary Manager for JLPT Vocabulary and Kanji Datasets.
7
+ #
8
+ # Loaded from Bluskyo JLPT_Vocabulary datasets (N5 to N1).
9
+ #
10
+ module Dictionary
11
+ DATA_DIR = File.expand_path('../data', __dir__)
12
+ MUTEX = Mutex.new
13
+ @loaded = false
14
+
15
+ class << self
16
+ # Look up the JLPT level of a vocabulary word (dictionary form or lemma)
17
+ #
18
+ # @param word [String] vocabulary word
19
+ # @return [Symbol, nil] :n5, :n4, :n3, :n2, :n1 or nil if unknown
20
+ def vocab_level(word)
21
+ ensure_loaded!
22
+ return nil if word.nil? || word.to_s.strip.empty?
23
+
24
+ @vocab_index[word.to_s.strip]
25
+ end
26
+
27
+ # Look up reading (furigana) for a vocabulary word
28
+ #
29
+ # @param word [String]
30
+ # @return [String, nil]
31
+ def reading_for(word)
32
+ ensure_loaded!
33
+ return nil if word.nil? || word.to_s.strip.empty?
34
+
35
+ @readings_index[word.to_s.strip]
36
+ end
37
+
38
+ # Look up the JLPT level of a single Kanji character
39
+ #
40
+ # @param char [String] single Kanji character
41
+ # @return [Symbol, nil] :n5, :n4, :n3, :n2, :n1 or nil if non-kanji / unknown
42
+ def kanji_level(char)
43
+ ensure_loaded!
44
+ return nil if char.nil? || char.to_s.strip.empty?
45
+
46
+ kanji_char = char.to_s.strip[0]
47
+ @kanji_index[kanji_char]
48
+ end
49
+
50
+ # Check if a word exists in the JLPT vocabulary dataset
51
+ #
52
+ # @param word [String]
53
+ # @return [Boolean]
54
+ def vocab_exists?(word)
55
+ !vocab_level(word).nil?
56
+ end
57
+
58
+ # Check if a kanji exists in the JLPT kanji dataset
59
+ #
60
+ # @param char [String]
61
+ # @return [Boolean]
62
+ def kanji_exists?(char)
63
+ !kanji_level(char).nil?
64
+ end
65
+
66
+ private
67
+
68
+ def ensure_loaded!
69
+ return if @loaded
70
+
71
+ MUTEX.synchronize do
72
+ return if @loaded
73
+
74
+ load_vocab_dataset!
75
+ load_kanji_dataset!
76
+ @loaded = true
77
+ end
78
+ end
79
+
80
+ def load_vocab_dataset!
81
+ @vocab_index = {}
82
+ @readings_index = {}
83
+ vocab_path = File.join(DATA_DIR, 'vocab.json')
84
+ return unless File.exist?(vocab_path)
85
+
86
+ data = JSON.parse(File.read(vocab_path))
87
+ data.each { |word, entries| index_vocab_entry(word, entries) }
88
+ end
89
+
90
+ def index_vocab_entry(word, entries)
91
+ return unless entries.is_a?(Array) && (best = select_best_entry(entries))
92
+
93
+ @vocab_index[word] = :"n#{best['level'].to_i}"
94
+ @readings_index[word] = best['reading'] if best['reading']
95
+ end
96
+
97
+ def select_best_entry(entries)
98
+ return nil if entries.empty?
99
+
100
+ entries.find { |e| e['level'].to_i == 5 } || entries.min_by { |e| e['level'].to_i }
101
+ end
102
+
103
+ def load_kanji_dataset!
104
+ @kanji_index = {}
105
+ kanji_path = File.join(DATA_DIR, 'kanji.json')
106
+ return unless File.exist?(kanji_path)
107
+
108
+ data = JSON.parse(File.read(kanji_path))
109
+ data.each { |char, lvl_num| @kanji_index[char] = :"n#{lvl_num}" }
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JLPT
4
+ # Morphological Analysis Engine Wrapper for JLPT.
5
+ #
6
+ # Uses MeCab (`natto` gem) if installed on system, with automatic fallback
7
+ # to a pure-Ruby morphological segmentation engine.
8
+ #
9
+ module Engine
10
+ @mecab_available = nil
11
+
12
+ class << self
13
+ # Check if MeCab is available on host system
14
+ #
15
+ # @return [Boolean]
16
+ def mecab_available?
17
+ return @mecab_available unless @mecab_available.nil?
18
+
19
+ @mecab_available = begin
20
+ require 'natto'
21
+ !Natto::MeCab.new.nil?
22
+ rescue LoadError, StandardError
23
+ false
24
+ end
25
+ end
26
+
27
+ # Parse Japanese text into structured token node hashes
28
+ #
29
+ # @param text [String] Japanese input text
30
+ # @return [Array<Hash>] token nodes
31
+ def parse(text)
32
+ return [] if text.nil? || text.to_s.strip.empty?
33
+
34
+ cleaned = Preprocessor.clean(text)
35
+ return [] if cleaned.empty?
36
+
37
+ if mecab_available?
38
+ parse_with_mecab(Natto::MeCab.new, cleaned)
39
+ else
40
+ parse_with_fallback(cleaned)
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def parse_with_mecab(mecab, text)
47
+ nodes = []
48
+ mecab.parse(text) do |n|
49
+ next if n.surface.empty? || n.feature.nil?
50
+
51
+ nodes << build_mecab_node(n)
52
+ end
53
+ nodes
54
+ end
55
+
56
+ def build_mecab_node(node)
57
+ f = (node.feature || '').split(',')
58
+ s = node.surface
59
+ { surface: s }.merge(fetch_pos_features(f), fetch_form_features(f, s))
60
+ end
61
+
62
+ def fetch_pos_features(feat)
63
+ { pos: feat[0] || '*', pos_sub1: feat[1] || '*', pos_sub2: feat[2] || '*', pos_sub3: feat[3] || '*' }
64
+ end
65
+
66
+ def fetch_form_features(feat, surface)
67
+ reading = feat[7] || surface
68
+ { inflection_type: feat[4] || '*', inflection_form: feat[5] || '*',
69
+ dictionary_form: feat[6] || surface, reading: reading, pronunciation: feat[8] || reading }
70
+ end
71
+
72
+ def parse_with_fallback(text)
73
+ PureRubyFallback.parse(text)
74
+ end
75
+ end
76
+
77
+ # Pure Ruby Fallback Engine for environments without MeCab installed
78
+ module PureRubyFallback
79
+ KANJI_KATAKANA_REGEX = /[\u4E00-\u9FFF\u30A0-\u30FF]/.freeze
80
+ HIRAGANA_REGEX = /[\u3040-\u309F]/.freeze
81
+ WORD_SCAN_REGEX = /[\u4E00-\u9FFF]+|[\u3040-\u309F]+|[\u30A0-\u30FF]+|[a-zA-Z0-9]+|\S/.freeze
82
+
83
+ class << self
84
+ def parse(text)
85
+ return [] if text.nil? || text.to_s.strip.empty?
86
+
87
+ text.scan(WORD_SCAN_REGEX).flat_map { |token| process_fallback_token(token) }
88
+ end
89
+
90
+ private
91
+
92
+ def process_fallback_token(token)
93
+ if token.match?(/^[\u4E00-\u9FFF]{4,}$/) && !Dictionary.vocab_exists?(token)
94
+ split_kanji_compound(token).map { |sub| build_fallback_node(sub) }
95
+ else
96
+ [build_fallback_node(token)]
97
+ end
98
+ end
99
+
100
+ def split_kanji_compound(token)
101
+ chunks = []
102
+ i = 0
103
+ while i < token.length
104
+ chunk, step = find_next_chunk(token, i)
105
+ chunks << chunk
106
+ i += step
107
+ end
108
+ chunks
109
+ end
110
+
111
+ def find_next_chunk(token, idx)
112
+ s3 = token[idx, 3]
113
+ return [s3, 3] if s3.length == 3 && Dictionary.vocab_exists?(s3)
114
+
115
+ s2 = token[idx, 2]
116
+ return [s2, 2] if s2.length == 2 && Dictionary.vocab_exists?(s2)
117
+
118
+ len = s2.length >= 2 ? 2 : 1
119
+ [token[idx, len], len]
120
+ end
121
+
122
+ def build_fallback_node(token)
123
+ {
124
+ surface: token, pos: detect_pos(token), pos_sub1: '*', pos_sub2: '*',
125
+ pos_sub3: '*', inflection_type: '*', inflection_form: '*',
126
+ dictionary_form: token, reading: token, pronunciation: token
127
+ }
128
+ end
129
+
130
+ def detect_pos(token)
131
+ if token.match?(KANJI_KATAKANA_REGEX)
132
+ '名詞'
133
+ elsif token.match?(HIRAGANA_REGEX)
134
+ '助詞'
135
+ else
136
+ '記号'
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JLPT
4
+ # Dedicated Kanji Character Analyzer and Extractor.
5
+ #
6
+ # Identifies Kanji characters in Japanese text, maps them to JLPT levels (N5-N1),
7
+ # calculates kanji density, and extracts unique kanji filtered by level.
8
+ #
9
+ module Kanji
10
+ KANJI_REGEX = /[\u4E00-\u9FFF]/.freeze
11
+
12
+ class << self
13
+ # Extract all Kanji characters from text
14
+ #
15
+ # @param text [String] Japanese text
16
+ # @param level [Symbol, String, nil] optional JLPT level filter (:n5, :n4, :n3, :n2, :n1)
17
+ # @return [Array<String>] array of unique Kanji characters
18
+ def extract(text, level: nil)
19
+ return [] if text.nil? || text.to_s.strip.empty?
20
+
21
+ kanjis = Preprocessor.clean(text).scan(KANJI_REGEX).uniq
22
+ return kanjis if level.nil?
23
+
24
+ target_level = level.to_s.downcase.to_sym
25
+ kanjis.select { |k| Dictionary.kanji_level(k) == target_level }
26
+ end
27
+
28
+ # Calculate Kanji density ratio (Kanji characters / Total characters)
29
+ #
30
+ # @param text [String] Japanese text
31
+ # @return [Float] density ratio between 0.0 and 1.0
32
+ def density(text)
33
+ cleaned = Preprocessor.clean(text)
34
+ return 0.0 if cleaned.empty?
35
+
36
+ kanji_count = cleaned.scan(KANJI_REGEX).length
37
+ (kanji_count.to_f / cleaned.length).round(4)
38
+ end
39
+
40
+ # Detailed breakdown of Kanji by JLPT level
41
+ #
42
+ # @param text [String] Japanese text
43
+ # @return [Hash{Symbol => Array<String>}] breakdown hash mapping levels to kanji arrays
44
+ def breakdown(text)
45
+ res = { n5: [], n4: [], n3: [], n2: [], n1: [], out_of_jlpt: [] }
46
+ extract(text).each do |k|
47
+ lvl = Dictionary.kanji_level(k)
48
+ res.key?(lvl) ? res[lvl] << k : res[:out_of_jlpt] << k
49
+ end
50
+ res
51
+ end
52
+ end
53
+ end
54
+ end