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.
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "normalize"
4
+ require_relative "rules"
5
+
6
+ module Yanagi
7
+ Mora = Struct.new(:kana, :kind, :onset, :nucleus, keyword_init: true) do
8
+ def sokuon?
9
+ kind == :sokuon
10
+ end
11
+
12
+ def chouonpu?
13
+ kind == :chouonpu
14
+ end
15
+
16
+ def moraic_n?
17
+ kind == :moraic_n
18
+ end
19
+
20
+ def syllable?
21
+ kind == :syllable
22
+ end
23
+
24
+ def passthrough?
25
+ kind == :passthrough
26
+ end
27
+ end
28
+
29
+ module Tokenizer
30
+ def self.tokenize(input)
31
+ hira = Normalize.to_hiragana(Normalize.nfkc(input.to_s))
32
+ moras = []
33
+ mora_map = Rules.mora_map
34
+
35
+ i = 0
36
+ len = hira.length
37
+ while i < len
38
+ c1 = hira[i]
39
+ c2 = hira[i, 2]
40
+
41
+ if c1 == "っ"
42
+ moras << Mora.new(kana: "っ", kind: :sokuon, onset: nil, nucleus: nil)
43
+ i += 1
44
+ elsif c1 == "ー"
45
+ moras << Mora.new(kana: "ー", kind: :chouonpu, onset: nil, nucleus: nil)
46
+ i += 1
47
+ elsif c1 == "ん"
48
+ moras << Mora.new(kana: "ん", kind: :moraic_n, onset: "n", nucleus: "n")
49
+ i += 1
50
+ elsif c2 && mora_map.key?(c2.to_sym)
51
+ info = mora_map[c2.to_sym]
52
+ moras << Mora.new(
53
+ kana: c2,
54
+ kind: :syllable,
55
+ onset: info[:onset]&.to_s || "",
56
+ nucleus: info[:nucleus]&.to_s || ""
57
+ )
58
+ i += 2
59
+ elsif mora_map.key?(c1.to_sym)
60
+ info = mora_map[c1.to_sym]
61
+ moras << Mora.new(
62
+ kana: c1,
63
+ kind: :syllable,
64
+ onset: info[:onset]&.to_s || "",
65
+ nucleus: info[:nucleus]&.to_s || ""
66
+ )
67
+ i += 1
68
+ else
69
+ moras << Mora.new(kana: c1, kind: :passthrough, onset: nil, nucleus: nil)
70
+ i += 1
71
+ end
72
+ end
73
+
74
+ moras
75
+ end
76
+ end
77
+
78
+ def self.tokenize(input)
79
+ Tokenizer.tokenize(input)
80
+ end
81
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yanagi
4
+ module Normalize
5
+ KATA_START = 0x30A1
6
+ KATA_END = 0x30F6
7
+ KATA_SHIFT = 0x60
8
+
9
+ # NFKC-normalise a string (compatibility decomposition then canonical composition).
10
+ # Input may arrive tagged ASCII-8BIT (e.g. from ARGV), which unicode_normalize
11
+ # rejects, so coerce to UTF-8 first.
12
+ def self.nfkc(str)
13
+ s = str.to_s
14
+ s = s.dup.force_encoding(Encoding::UTF_8) unless s.encoding == Encoding::UTF_8
15
+ s.unicode_normalize(:nfkc)
16
+ end
17
+
18
+ # Convert a katakana string to hiragana (passthrough for everything else).
19
+ def self.to_hiragana(str)
20
+ str.to_s.chars.map do |ch|
21
+ cp = ch.ord
22
+ (cp >= KATA_START && cp <= KATA_END) ? (cp - KATA_SHIFT).chr(Encoding::UTF_8) : ch
23
+ end.join
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mora"
4
+ require_relative "rules"
5
+
6
+ module Yanagi
7
+ module Romaji
8
+ def self.call(input)
9
+ moras = input.is_a?(Array) ? input : Tokenizer.tokenize(input)
10
+ mora_map = Rules.mora_map
11
+ res = []
12
+
13
+ moras.each_with_index do |mora, idx|
14
+ case mora.kind
15
+ when :sokuon
16
+ # Find next non-sokuon/chouonpu mora
17
+ next_mora = moras[(idx + 1)..].find { |m| m.syllable? || m.moraic_n? }
18
+ if next_mora
19
+ next_rom = mora_map.dig(next_mora.kana.to_sym, :romaji)&.to_s || next_mora.kana.to_s
20
+ consonant = next_rom.start_with?("ch") ? "t" : next_rom[0]
21
+ res << consonant if consonant
22
+ end
23
+ when :chouonpu
24
+ prev_char = res.last&.chars&.last
25
+ res << prev_char if prev_char
26
+ when :moraic_n
27
+ res << "n"
28
+ when :syllable
29
+ rom = mora_map.dig(mora.kana.to_sym, :romaji)&.to_s || mora.kana.to_s
30
+ res << rom
31
+ else
32
+ res << mora.kana.to_s
33
+ end
34
+ end
35
+
36
+ res.join
37
+ end
38
+ def self.matches?(reading, romaji)
39
+ expected = call(reading)
40
+ return true if romaji == expected
41
+
42
+ norm_actual = romaji.to_s.gsub(/\d+$/, "").gsub(/([aeiou])-/, '\1\1').gsub("oo", "ou").gsub("dewa", "deha")
43
+ norm_exp = expected.gsub("oo", "ou")
44
+ norm_actual == norm_exp
45
+ end
46
+ end
47
+
48
+ def self.romaji(input)
49
+ Romaji.call(input)
50
+ end
51
+
52
+ def self.romaji_matches?(reading, romaji)
53
+ Romaji.matches?(reading, romaji)
54
+ end
55
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Yanagi
6
+ module Rules
7
+ DATA_DIR = File.expand_path("../../data", __dir__).freeze
8
+
9
+ @mutex = Mutex.new
10
+
11
+ # Policy rules ship with the gem. Corpus-derived data (the lexicon and the
12
+ # native-Ukrainian allowlist) is generated from a specific translation
13
+ # project and is NOT distributed: point YANAGI_DATA_DIR at a directory
14
+ # holding those files, or build them with `yanagi lexicon build`.
15
+ CORPUS_FILES = %w[lexicon.yml native_ua_allowlist.yml].freeze
16
+
17
+ def self.data_dir
18
+ DATA_DIR
19
+ end
20
+
21
+ # Where corpus-derived files are read from. Defaults to the gem's data
22
+ # directory so a local checkout keeps working without configuration.
23
+ def self.corpus_dir
24
+ ENV.fetch("YANAGI_DATA_DIR", DATA_DIR)
25
+ end
26
+
27
+ def self.path_for(filename)
28
+ dir = CORPUS_FILES.include?(filename) ? corpus_dir : DATA_DIR
29
+ File.join(dir, filename)
30
+ end
31
+
32
+ def self.load_yaml(filename)
33
+ path = path_for(filename)
34
+ return {}.freeze unless File.exist?(path)
35
+
36
+ data = YAML.safe_load_file(path, permitted_classes: [Symbol, Date], symbolize_names: true) || {}
37
+ deep_freeze(data)
38
+ end
39
+
40
+ def self.mora_map
41
+ @mora_map ||= load_yaml("mora.yml")
42
+ end
43
+
44
+ def self.combinatorial
45
+ @combinatorial ||= load_yaml("combinatorial.yml")
46
+ end
47
+
48
+ def self.exonyms
49
+ @exonyms ||= load_yaml("exonyms.yml")
50
+ end
51
+
52
+ def self.lexicon
53
+ @lexicon ||= load_yaml("lexicon.yml")
54
+ end
55
+
56
+ def self.native_ua_allowlist
57
+ @native_ua_allowlist ||= load_yaml("native_ua_allowlist.yml")
58
+ end
59
+
60
+ def self.exceptions
61
+ @exceptions ||= load_yaml("exceptions.yml")
62
+ end
63
+
64
+ def self.reload!
65
+ @mutex.synchronize do
66
+ @mora_map = nil
67
+ @combinatorial = nil
68
+ @exonyms = nil
69
+ @lexicon = nil
70
+ @native_ua_allowlist = nil
71
+ @exceptions = nil
72
+ end
73
+ end
74
+
75
+ def self.deep_freeze(obj)
76
+ case obj
77
+ when Hash
78
+ obj.transform_keys(&:freeze).transform_values { |v| deep_freeze(v) }.freeze
79
+ when Array
80
+ obj.map { |v| deep_freeze(v) }.freeze
81
+ when String
82
+ obj.dup.freeze
83
+ else
84
+ obj.freeze
85
+ end
86
+ end
87
+ private_class_method :deep_freeze
88
+ end
89
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yanagi
4
+ VERSION = "0.1.0"
5
+ end
data/lib/yanagi.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "yanagi/version"
4
+ require_relative "yanagi/normalize"
5
+ require_relative "yanagi/rules"
6
+ require_relative "yanagi/mora"
7
+ require_relative "yanagi/romaji"
8
+ require_relative "yanagi/cyrillic"
9
+ require_relative "yanagi/doc_sync"
10
+ require_relative "yanagi/lexicon"
11
+ require_relative "yanagi/audit"
12
+
13
+ module Yanagi
14
+ class Error < StandardError; end
15
+ end
data/yanagi.gemspec ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/yanagi/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "yanagi"
7
+ spec.version = Yanagi::VERSION
8
+ spec.authors = ["shogi-dojo"]
9
+ spec.email = ["play@shogi-dojo.com"]
10
+
11
+ spec.summary = "Deterministic Japanese to Ukrainian transliteration engine"
12
+ spec.description = "Zero-dependency Japanese to Ukrainian transliteration and policy enforcement gem."
13
+ spec.homepage = "https://github.com/shogi-dojo/yanagi"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.0.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = "https://github.com/shogi-dojo/yanagi/tree/main"
19
+ spec.metadata["bug_tracker_uri"] = "https://github.com/shogi-dojo/yanagi/issues"
20
+ spec.metadata["changelog_uri"] = "https://github.com/shogi-dojo/yanagi/blob/main/CHANGELOG.md"
21
+
22
+ spec.files = Dir.chdir(__dir__) do
23
+ # Corpus-derived data (lexicon, native-Ukrainian allowlist) is generated
24
+ # from a specific translation project and is not distributed.
25
+ Dir["{lib,data,exe}/**/*", "LICENSE*", "README*", "CHANGELOG*", "yanagi.gemspec"] -
26
+ Dir["data/{lexicon,native_ua_allowlist}.yml"]
27
+ end
28
+ spec.bindir = "exe"
29
+ spec.executables = ["yanagi"]
30
+ spec.require_paths = ["lib"]
31
+
32
+ spec.add_development_dependency "minitest", ">= 5.16"
33
+ spec.add_development_dependency "rake", "~> 13.0"
34
+ end
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: yanagi
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - shogi-dojo
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '5.16'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '5.16'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ description: Zero-dependency Japanese to Ukrainian transliteration and policy enforcement
41
+ gem.
42
+ email:
43
+ - play@shogi-dojo.com
44
+ executables:
45
+ - yanagi
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE
51
+ - README.md
52
+ - data/combinatorial.yml
53
+ - data/exceptions.yml
54
+ - data/exonyms.yml
55
+ - data/mora.yml
56
+ - exe/yanagi
57
+ - lib/yanagi.rb
58
+ - lib/yanagi/audit.rb
59
+ - lib/yanagi/cli.rb
60
+ - lib/yanagi/cyrillic.rb
61
+ - lib/yanagi/doc_sync.rb
62
+ - lib/yanagi/lexicon.rb
63
+ - lib/yanagi/mora.rb
64
+ - lib/yanagi/normalize.rb
65
+ - lib/yanagi/romaji.rb
66
+ - lib/yanagi/rules.rb
67
+ - lib/yanagi/version.rb
68
+ - yanagi.gemspec
69
+ homepage: https://github.com/shogi-dojo/yanagi
70
+ licenses:
71
+ - MIT
72
+ metadata:
73
+ homepage_uri: https://github.com/shogi-dojo/yanagi
74
+ source_code_uri: https://github.com/shogi-dojo/yanagi/tree/main
75
+ bug_tracker_uri: https://github.com/shogi-dojo/yanagi/issues
76
+ changelog_uri: https://github.com/shogi-dojo/yanagi/blob/main/CHANGELOG.md
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: 3.0.0
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubygems_version: 4.0.16
92
+ specification_version: 4
93
+ summary: Deterministic Japanese to Ukrainian transliteration engine
94
+ test_files: []