whatlanguage 2.1.0 → 3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +46 -11
- data/lib/whatlanguage/languages.rb +14 -10
- data/lib/whatlanguage/version.rb +1 -1
- data/lib/whatlanguage.rb +153 -28
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 93967e970b5e62eb58b9cb835866d4abe885f98761f7a339d0817dad8414eac8
|
|
4
|
+
data.tar.gz: b7e983bcd0a56f7b6f77675fdedd6d7724da71b42e26e3229014a92a3c32166b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f7c259a244e6c0fe0aa9084037fb29381bc96c9b5a4e549382b660b95943d82bf651d5e706fc4290cb93ff2bc820b1c15495f70bec025c5b98bfa3fb0ff91d6a
|
|
7
|
+
data.tar.gz: 6b2d67686a98ec148d4a3f3314cd270beee7ad3b3437411b8a516d69d6078d3a1a3bd230a4d7e06a5904f7b286339e8be1b2cfef29ba9df73e0da76efb0044a3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.0.0 / 2026-10-20
|
|
4
|
+
|
|
5
|
+
### Breaking change: nine ISO output codes
|
|
6
|
+
|
|
7
|
+
- Normalized nine individual-language ISO outputs to CLDR macrolanguage aliases
|
|
8
|
+
(#51): `swh → sw`, `ayr → ay`, `knc → kr`, `quz → qu`, `khk → mn`,
|
|
9
|
+
`kng → kg`, `src → sc`, `uzn → uz`, and `als → sq`. This changes
|
|
10
|
+
`Result#iso`, instance/class `language_iso`, and CLI output; callers comparing
|
|
11
|
+
the previous codes must update those comparisons. Language names and detection
|
|
12
|
+
scores are unchanged. Northern Uzbek and Uzbek now both return `uz`.
|
|
13
|
+
|
|
14
|
+
### Improvements
|
|
15
|
+
|
|
16
|
+
- Preserved the individual codes `quy`, `qug`, `koi`, and `min`, and documented
|
|
17
|
+
the selected CLDR aliases and historical overrides used for ISO output.
|
|
18
|
+
- Added shared trigram indexes to speed up repeated detection over the full
|
|
19
|
+
language set, without changing scores or rankings. First calls and restricted
|
|
20
|
+
selections retain the original scan.
|
|
21
|
+
- Reduced text-processing allocations with rolling trigram windows, a combined
|
|
22
|
+
script/letter-count pass, and an ASCII fast path. Cached restricted candidates
|
|
23
|
+
and avoided sorting when only the winning language is requested.
|
|
24
|
+
- Added equivalence tests and reproducible benchmarks for
|
|
25
|
+
[indexed scoring](benchmark/README.md) and
|
|
26
|
+
[text processing](benchmark/long-inputs.md), including allocation and memory costs.
|
|
27
|
+
|
|
3
28
|
## 2.1.0 / 2026-09-05
|
|
4
29
|
|
|
5
30
|
- Added a fixed short-text preference for English, Chinese, Hindi, Spanish, and
|
data/README.md
CHANGED
|
@@ -17,21 +17,22 @@ WhatLanguage.language("Que linguagem é essa? É uma pergunta sobre a língua po
|
|
|
17
17
|
- **Best on sentence-length text or longer.** Short texts can have ambiguous results.
|
|
18
18
|
|
|
19
19
|
> [!IMPORTANT]
|
|
20
|
-
>
|
|
20
|
+
> Version 3.0 changes nine ISO output codes, speeds up detection, and reduces
|
|
21
|
+
> allocations. See [Upgrading to 3.0](#upgrading-to-30) for the code changes.
|
|
21
22
|
|
|
22
23
|
## How it works
|
|
23
24
|
|
|
24
|
-
> [!NOTE]
|
|
25
|
-
> Version 2.1 improves Unicode and mixed-script Japanese handling and adds a fixed
|
|
26
|
-
> preference for five widely spoken languages on short texts. The public API and
|
|
27
|
-
> default 10-letter minimum for statistical detection are unchanged.
|
|
28
|
-
|
|
29
25
|
Detection is in two stages. First, the dominant Unicode script is detected from letters; scripts used by a single supported language (Greek, Korean, Thai, Japanese using Hiragana/Katakana) resolve immediately. When kana is present, Han, Hiragana, and Katakana count together as Japanese, while a dominant unrelated script can still win. For scripts shared by several languages (e.g. Latin, Cyrillic, Arabic, Hebrew) trigrams are ranked by frequency and compared against candidate language profiles.
|
|
30
26
|
|
|
31
27
|
Digits, punctuation, and emoji do not count toward the minimum text length and are treated as separators when extracting trigrams. Combining marks are preserved in trigrams but do not count as additional letters. Unicode script coverage follows the Unicode version supported by your Ruby runtime.
|
|
32
28
|
|
|
33
29
|
The trigram profiles are vendored from [whatlang](https://github.com/greyblake/whatlang-rs), a port of [Franc](https://github.com/wooorm/franc), whose models are built from the public-domain UDHR corpus (see Credits). The model is a ~220 KB JSON file.
|
|
34
30
|
|
|
31
|
+
Repeated calls over the full language set use a shared trigram index. A rolling
|
|
32
|
+
window counts input trigrams without building a character array. Scores and
|
|
33
|
+
rankings are unchanged. See the [indexing benchmarks](benchmark/README.md) and
|
|
34
|
+
[long-input benchmarks](benchmark/long-inputs.md) for timings, allocations, and
|
|
35
|
+
memory costs.
|
|
35
36
|
|
|
36
37
|
## Usage
|
|
37
38
|
|
|
@@ -47,6 +48,16 @@ result.score # => 79018
|
|
|
47
48
|
result.ranked # => [[:german, 79018], [:dutch, 77631], ... ]
|
|
48
49
|
```
|
|
49
50
|
|
|
51
|
+
`result.iso` and `language_iso` return ISO 639-1 symbols with an ISO 639-3
|
|
52
|
+
fallback. Selected individual languages use CLDR's macrolanguage aliases, such
|
|
53
|
+
as Swahili `swh → sw`, Mandarin `cmn → zh`, and Western Farsi `pes → fa`;
|
|
54
|
+
historical code mappings are also preserved. These aliases are a normalization
|
|
55
|
+
policy, not a BCP 47 requirement: `swh` remains a valid individual-language
|
|
56
|
+
subtag. Languages without a selected alias retain their individual codes.
|
|
57
|
+
The CLI uses the same mapping. Codes are not necessarily unique: both
|
|
58
|
+
`:northern_uzbek` and `:uzbek` return `:uz`. Use `result.language` to distinguish
|
|
59
|
+
the language names.
|
|
60
|
+
|
|
50
61
|
Return ranked scores, or the score hash:
|
|
51
62
|
|
|
52
63
|
```ruby
|
|
@@ -76,6 +87,30 @@ Short shared-script fragments are ignored by default because there is not enough
|
|
|
76
87
|
wl = WhatLanguage.new(min_chars: 0)
|
|
77
88
|
```
|
|
78
89
|
|
|
90
|
+
## Upgrading to 3.0
|
|
91
|
+
|
|
92
|
+
Update comparisons against `result.iso`, `language_iso`, and CLI output:
|
|
93
|
+
|
|
94
|
+
| Language | Previous code | 3.0 code |
|
|
95
|
+
| --- | --- | --- |
|
|
96
|
+
| Swahili | `swh` | `sw` |
|
|
97
|
+
| Central Aymara | `ayr` | `ay` |
|
|
98
|
+
| Central Kanuri | `knc` | `kr` |
|
|
99
|
+
| Cusco Quechua | `quz` | `qu` |
|
|
100
|
+
| Halh Mongolian | `khk` | `mn` |
|
|
101
|
+
| Koongo | `kng` | `kg` |
|
|
102
|
+
| Logudorese Sardinian | `src` | `sc` |
|
|
103
|
+
| Northern Uzbek | `uzn` | `uz` |
|
|
104
|
+
| Tosk Albanian | `als` | `sq` |
|
|
105
|
+
|
|
106
|
+
Both Northern Uzbek and Uzbek now return `uz`; use `result.language` to
|
|
107
|
+
distinguish them. Language-name symbols, detection scores, rankings, options,
|
|
108
|
+
and return types are unchanged from 2.1.
|
|
109
|
+
|
|
110
|
+
If upgrading from 1.x, see the [2.0 changelog](CHANGELOG.md#200--2026-06-10) too.
|
|
111
|
+
Version 2.0 replaced the Bloom-filter engine with trigram profiles and removed
|
|
112
|
+
the `String#language` monkeypatch.
|
|
113
|
+
|
|
79
114
|
## Command line
|
|
80
115
|
|
|
81
116
|
Installing the gem also installs a `whatlanguage` executable that reads from files given as arguments (or stdin) and prints the ISO 639 code of the detected language, or `und` if undetermined:
|
|
@@ -89,11 +124,11 @@ de
|
|
|
89
124
|
|
|
90
125
|
## Known limitations
|
|
91
126
|
|
|
92
|
-
- Short
|
|
93
|
-
- Scores
|
|
94
|
-
-
|
|
95
|
-
-
|
|
96
|
-
- Romanized text is
|
|
127
|
+
- Short texts can be ambiguous. Built-in preferences help some phrases but can bias others toward preferred languages. Statistical detection returns `nil` below 10 letters by default.
|
|
128
|
+
- Scores indicate relative rank, not probability or confidence. Use `#ranked` to inspect alternatives.
|
|
129
|
+
- Similar written languages, such as Norwegian Bokmål/Danish and Hebrew/Yiddish, can be confused.
|
|
130
|
+
- Japanese without kana is classified as Chinese when Han is the dominant script.
|
|
131
|
+
- Romanized text is compared with Latin-script profiles, so it may not resolve to its original language.
|
|
97
132
|
|
|
98
133
|
## Credits
|
|
99
134
|
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# Generated from the ISO 639-3 registry + whatlang dataset codes, with overrides.
|
|
4
4
|
# Maps ISO 639-3 code => [language name symbol, ISO 639-1 (or 639-3 fallback) symbol].
|
|
5
5
|
# Original gem languages keep their historical symbols and 2-letter codes.
|
|
6
|
+
# Selected individual languages use CLDR's 2-letter macrolanguage aliases:
|
|
7
|
+
# als, ayr, cmn, khk, knc, kng, pes, quz, src, swh, uzn. Preserve these overrides
|
|
8
|
+
# when regenerating; do not map every macrolanguage member to its parent code.
|
|
9
|
+
# Source: https://github.com/unicode-org/cldr/blob/main/common/supplemental/supplementalMetadata.xml
|
|
6
10
|
class WhatLanguage
|
|
7
11
|
CODE_INFO = {
|
|
8
12
|
"ace" => [:achinese, :ace],
|
|
@@ -30,16 +34,16 @@ class WhatLanguage
|
|
|
30
34
|
"cat" => [:catalan, :ca],
|
|
31
35
|
"ceb" => [:cebuano, :ceb],
|
|
32
36
|
"tzm" => [:central_atlas_tamazight, :tzm],
|
|
33
|
-
"ayr" => [:central_aymara, :
|
|
37
|
+
"ayr" => [:central_aymara, :ay],
|
|
34
38
|
"bcl" => [:central_bikol, :bcl],
|
|
35
|
-
"knc" => [:central_kanuri, :
|
|
39
|
+
"knc" => [:central_kanuri, :kr],
|
|
36
40
|
"nhn" => [:central_nahuatl, :nhn],
|
|
37
41
|
"nya" => [:chichewa, :ny],
|
|
38
42
|
"qug" => [:chimborazo_highland_quichua, :qug],
|
|
39
43
|
"cmn" => [:chinese, :zh],
|
|
40
44
|
"cjk" => [:chokwe, :cjk],
|
|
41
45
|
"hrv" => [:croatian, :hr],
|
|
42
|
-
"quz" => [:cusco_quechua, :
|
|
46
|
+
"quz" => [:cusco_quechua, :qu],
|
|
43
47
|
"ces" => [:czech, :cs],
|
|
44
48
|
"dan" => [:danish, :da],
|
|
45
49
|
"nld" => [:dutch, :nl],
|
|
@@ -61,7 +65,7 @@ class WhatLanguage
|
|
|
61
65
|
"ell" => [:greek, :el],
|
|
62
66
|
"guj" => [:gujarati, :gu],
|
|
63
67
|
"hat" => [:haitian, :ht],
|
|
64
|
-
"khk" => [:halh_mongolian, :
|
|
68
|
+
"khk" => [:halh_mongolian, :mn],
|
|
65
69
|
"hau" => [:hausa, :ha],
|
|
66
70
|
"heb" => [:hebrew, :he],
|
|
67
71
|
"hil" => [:hiligaynon, :hil],
|
|
@@ -82,14 +86,14 @@ class WhatLanguage
|
|
|
82
86
|
"kin" => [:kinyarwanda, :rw],
|
|
83
87
|
"kir" => [:kirghiz, :ky],
|
|
84
88
|
"koi" => [:komi_permyak, :koi],
|
|
85
|
-
"kng" => [:koongo, :
|
|
89
|
+
"kng" => [:koongo, :kg],
|
|
86
90
|
"kor" => [:korean, :ko],
|
|
87
91
|
"kur" => [:kurdish, :ku],
|
|
88
92
|
"lat" => [:latin, :la],
|
|
89
93
|
"lav" => [:latvian, :lv],
|
|
90
94
|
"lin" => [:lingala, :ln],
|
|
91
95
|
"lit" => [:lithuanian, :lt],
|
|
92
|
-
"src" => [:logudorese_sardinian, :
|
|
96
|
+
"src" => [:logudorese_sardinian, :sc],
|
|
93
97
|
"nds" => [:low_german, :nds],
|
|
94
98
|
"lua" => [:luba_lulua, :lua],
|
|
95
99
|
"lun" => [:lunda, :lun],
|
|
@@ -108,7 +112,7 @@ class WhatLanguage
|
|
|
108
112
|
"ndo" => [:ndonga, :ng],
|
|
109
113
|
"nep" => [:nepali, :ne],
|
|
110
114
|
"dip" => [:northeastern_dinka, :dip],
|
|
111
|
-
"uzn" => [:northern_uzbek, :
|
|
115
|
+
"uzn" => [:northern_uzbek, :uz],
|
|
112
116
|
"nob" => [:norwegian, :no],
|
|
113
117
|
"nno" => [:norwegian_nynorsk, :nn],
|
|
114
118
|
"nyn" => [:nyankole, :nyn],
|
|
@@ -137,7 +141,7 @@ class WhatLanguage
|
|
|
137
141
|
"spa" => [:spanish, :es],
|
|
138
142
|
"suk" => [:sukuma, :suk],
|
|
139
143
|
"sun" => [:sundanese, :su],
|
|
140
|
-
"swh" => [:swahili, :
|
|
144
|
+
"swh" => [:swahili, :sw],
|
|
141
145
|
"ssw" => [:swati, :ss],
|
|
142
146
|
"swe" => [:swedish, :sv],
|
|
143
147
|
"tgl" => [:tagalog, :tl],
|
|
@@ -151,7 +155,7 @@ class WhatLanguage
|
|
|
151
155
|
"tiv" => [:tiv, :tiv],
|
|
152
156
|
"tpi" => [:tok_pisin, :tpi],
|
|
153
157
|
"toi" => [:tonga, :toi],
|
|
154
|
-
"als" => [:tosk_albanian, :
|
|
158
|
+
"als" => [:tosk_albanian, :sq],
|
|
155
159
|
"tso" => [:tsonga, :ts],
|
|
156
160
|
"tsn" => [:tswana, :tn],
|
|
157
161
|
"tur" => [:turkish, :tr],
|
data/lib/whatlanguage/version.rb
CHANGED
data/lib/whatlanguage.rb
CHANGED
|
@@ -92,6 +92,30 @@ class WhatLanguage
|
|
|
92
92
|
def default_detector
|
|
93
93
|
@default_detector ||= new
|
|
94
94
|
end
|
|
95
|
+
|
|
96
|
+
# Shared index for a script: trigram => flat [candidate, rank, ...].
|
|
97
|
+
# Use the direct scan on the first call: one-shot CLI use should not pay
|
|
98
|
+
# to build an index. Flat arrays avoid an object for every posting.
|
|
99
|
+
def profile_index(script)
|
|
100
|
+
@profile_indexes ||= {}
|
|
101
|
+
unless @profile_indexes.key?(script)
|
|
102
|
+
@profile_indexes[script] = nil
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
@profile_indexes[script] ||= begin
|
|
107
|
+
candidates = profiles.fetch(script)
|
|
108
|
+
postings = {}
|
|
109
|
+
candidates.each_with_index do |(_code, trigrams), candidate|
|
|
110
|
+
trigrams.each_with_index do |trigram, rank|
|
|
111
|
+
(postings[trigram] ||= []).push(candidate, rank)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
postings.each_value(&:freeze)
|
|
115
|
+
missing = candidates.map { |_code, trigrams| trigrams.size * MAX_TRIGRAM_DISTANCE }
|
|
116
|
+
[missing.freeze, postings.freeze].freeze
|
|
117
|
+
end
|
|
118
|
+
end
|
|
95
119
|
end
|
|
96
120
|
|
|
97
121
|
def initialize(*selection, only: nil, min_chars: DEFAULT_MIN_CHARS)
|
|
@@ -119,7 +143,7 @@ class WhatLanguage
|
|
|
119
143
|
def score_hash(text)
|
|
120
144
|
results = Hash.new(0)
|
|
121
145
|
text = normalize_text(text)
|
|
122
|
-
script =
|
|
146
|
+
script, char_count = script_and_letter_count(text)
|
|
123
147
|
return results unless script
|
|
124
148
|
|
|
125
149
|
if (code = DETERMINISTIC[script])
|
|
@@ -128,19 +152,31 @@ class WhatLanguage
|
|
|
128
152
|
return results
|
|
129
153
|
end
|
|
130
154
|
|
|
131
|
-
candidates =
|
|
155
|
+
candidates = candidates_for(script)
|
|
132
156
|
return results unless candidates
|
|
133
|
-
char_count = significant_char_count(text)
|
|
134
157
|
return results if char_count < @min_chars
|
|
135
158
|
|
|
136
159
|
bonus = short_text_bonus(char_count)
|
|
137
160
|
positions = trigram_positions(text)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
161
|
+
# Small selections retain the direct scan rather than visiting postings for
|
|
162
|
+
# languages they cannot return.
|
|
163
|
+
distances = indexed_distances(script, positions) if @selection.include?(:all)
|
|
164
|
+
if distances
|
|
165
|
+
candidates.each_with_index do |(code, _trigrams), i|
|
|
166
|
+
name = CODE_INFO[code].first
|
|
167
|
+
next unless allowed?(name)
|
|
168
|
+
|
|
169
|
+
results[name] = MAX_TOTAL_DISTANCE - distances[i]
|
|
170
|
+
results[name] += bonus if SHORT_TEXT_PREFERENCES.include?(name)
|
|
171
|
+
end
|
|
172
|
+
else
|
|
173
|
+
candidates.each do |code, trigrams|
|
|
174
|
+
name = CODE_INFO[code].first
|
|
175
|
+
next unless allowed?(name)
|
|
141
176
|
|
|
142
|
-
|
|
143
|
-
|
|
177
|
+
results[name] = MAX_TOTAL_DISTANCE - distance(trigrams, positions)
|
|
178
|
+
results[name] += bonus if SHORT_TEXT_PREFERENCES.include?(name)
|
|
179
|
+
end
|
|
144
180
|
end
|
|
145
181
|
results
|
|
146
182
|
end
|
|
@@ -165,12 +201,14 @@ class WhatLanguage
|
|
|
165
201
|
|
|
166
202
|
# Most likely language as a name symbol, or nil when no language is detected.
|
|
167
203
|
def language(text)
|
|
168
|
-
|
|
204
|
+
score_hash(text).max_by { |_name, score| score }&.first
|
|
169
205
|
end
|
|
170
206
|
|
|
171
207
|
# Most likely language as an ISO 639-1 symbol (639-3 fallback), or nil.
|
|
208
|
+
# Includes selected CLDR macrolanguage aliases and historical code overrides;
|
|
209
|
+
# see whatlanguage/languages.rb. Codes need not uniquely identify a language.
|
|
172
210
|
def language_iso(text)
|
|
173
|
-
|
|
211
|
+
ISO_CODES[language(text)]
|
|
174
212
|
end
|
|
175
213
|
|
|
176
214
|
private
|
|
@@ -191,6 +229,20 @@ class WhatLanguage
|
|
|
191
229
|
@selection.include?(:all) || languages.include?(name)
|
|
192
230
|
end
|
|
193
231
|
|
|
232
|
+
def candidates_for(script)
|
|
233
|
+
candidates = self.class.profiles[script]
|
|
234
|
+
return candidates unless candidates && !@selection.include?(:all)
|
|
235
|
+
|
|
236
|
+
# languages returns a mutable array. Invalidate the cached selection when
|
|
237
|
+
# callers change it, preserving the existing selection behavior.
|
|
238
|
+
wanted = languages
|
|
239
|
+
if @selected_languages != wanted
|
|
240
|
+
@selected_languages = wanted.dup
|
|
241
|
+
@selected_profiles = {}
|
|
242
|
+
end
|
|
243
|
+
@selected_profiles[script] ||= candidates.select { |code, _trigrams| allowed?(CODE_INFO[code].first) }
|
|
244
|
+
end
|
|
245
|
+
|
|
194
246
|
def validate_selection!
|
|
195
247
|
requested = @selection.reject { |name| name == :all }
|
|
196
248
|
unknown = requested.reject { |name| self.class.languages.include?(NAME_ALIASES.fetch(name, name)) }
|
|
@@ -199,24 +251,30 @@ class WhatLanguage
|
|
|
199
251
|
raise ArgumentError, "Unknown language selection: #{unknown.map(&:inspect).join(', ')}"
|
|
200
252
|
end
|
|
201
253
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
254
|
+
# Count all letters, including those outside the recognized scripts.
|
|
255
|
+
def script_and_letter_count(text)
|
|
256
|
+
if text.ascii_only?
|
|
257
|
+
letter_count = text.count('A-Za-z')
|
|
258
|
+
return [letter_count.zero? ? nil : 'Latin', letter_count]
|
|
259
|
+
end
|
|
205
260
|
|
|
206
|
-
# Dominant Unicode script of the text, or nil if it has no script characters.
|
|
207
|
-
def detect_script(text)
|
|
208
261
|
counts = Hash.new(0)
|
|
262
|
+
letter_count = 0
|
|
209
263
|
text.each_char do |ch|
|
|
210
264
|
next unless /\p{L}/.match?(ch)
|
|
265
|
+
letter_count += 1
|
|
211
266
|
|
|
212
|
-
|
|
267
|
+
i = 0
|
|
268
|
+
while i < SCRIPT_PATTERNS.size
|
|
269
|
+
name, pattern = SCRIPT_PATTERNS[i]
|
|
213
270
|
if pattern.match?(ch)
|
|
214
271
|
counts[name] += 1
|
|
215
272
|
break
|
|
216
273
|
end
|
|
274
|
+
i += 1
|
|
217
275
|
end
|
|
218
276
|
end
|
|
219
|
-
return nil if counts.empty?
|
|
277
|
+
return [nil, letter_count] if counts.empty?
|
|
220
278
|
|
|
221
279
|
# Japanese uses Han, Hiragana, and Katakana together. Combine their votes
|
|
222
280
|
# when kana is present, but still let a dominant unrelated script win.
|
|
@@ -227,33 +285,100 @@ class WhatLanguage
|
|
|
227
285
|
counts['Hiragana'] = japanese
|
|
228
286
|
end
|
|
229
287
|
|
|
230
|
-
counts.max_by { |_name, n| n }.first
|
|
288
|
+
[counts.max_by { |_name, n| n }.first, letter_count]
|
|
231
289
|
end
|
|
232
290
|
|
|
233
291
|
# Text trigrams ranked by descending frequency, mapped to their rank index.
|
|
234
292
|
# Non-letter/mark characters become spaces. Preserve combining marks used
|
|
235
293
|
# by the profiles; bound the stream by spaces and ignore repeated spaces.
|
|
236
294
|
def trigram_positions(text)
|
|
237
|
-
|
|
238
|
-
|
|
295
|
+
text = text.downcase
|
|
296
|
+
occurrences = text.ascii_only? ? ascii_trigram_counts(text) : unicode_trigram_counts(text)
|
|
297
|
+
ranked = occurrences.to_a.sort! do |a, b|
|
|
298
|
+
order = b[1] <=> a[1]
|
|
299
|
+
order.zero? ? b[0] <=> a[0] : order
|
|
300
|
+
end.first(TEXT_TRIGRAMS_SIZE)
|
|
301
|
+
positions = {}
|
|
302
|
+
ranked.each_with_index { |(trigram, _count), i| positions[trigram] = i }
|
|
303
|
+
positions
|
|
304
|
+
end
|
|
239
305
|
|
|
306
|
+
def unicode_trigram_counts(text)
|
|
240
307
|
occurrences = Hash.new(0)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
308
|
+
trigram = +''
|
|
309
|
+
c1 = nil
|
|
310
|
+
c2 = ' '
|
|
311
|
+
text.each_char do |character|
|
|
312
|
+
c3 = /[\p{L}\p{M}]/.match?(character) ? character : ' '
|
|
313
|
+
if c1 && !(c2 == ' ' && (c1 == ' ' || c3 == ' '))
|
|
314
|
+
# Hash copies new String keys, so this buffer can be reused for every
|
|
315
|
+
# window without changing already-counted trigrams.
|
|
316
|
+
trigram.replace(c1) << c2 << c3
|
|
317
|
+
occurrences[trigram] += 1
|
|
318
|
+
end
|
|
245
319
|
c1 = c2
|
|
246
320
|
c2 = c3
|
|
247
321
|
end
|
|
322
|
+
if c1 && c2 != ' '
|
|
323
|
+
trigram.replace(c1) << c2 << ' '
|
|
324
|
+
occurrences[trigram] += 1
|
|
325
|
+
end
|
|
248
326
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
327
|
+
occurrences
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Lowercased ASCII has only a-z letters and no combining marks. Use byte
|
|
331
|
+
# values and a reusable three-byte key to avoid allocating character strings.
|
|
332
|
+
def ascii_trigram_counts(text)
|
|
333
|
+
occurrences = Hash.new(0)
|
|
334
|
+
trigram = +"\0\0\0"
|
|
335
|
+
c1 = nil
|
|
336
|
+
c2 = 32
|
|
337
|
+
text.each_byte do |byte|
|
|
338
|
+
c3 = byte.between?(97, 122) ? byte : 32
|
|
339
|
+
if c1 && !(c2 == 32 && (c1 == 32 || c3 == 32))
|
|
340
|
+
trigram.setbyte(0, c1)
|
|
341
|
+
trigram.setbyte(1, c2)
|
|
342
|
+
trigram.setbyte(2, c3)
|
|
343
|
+
occurrences[trigram] += 1
|
|
344
|
+
end
|
|
345
|
+
c1 = c2
|
|
346
|
+
c2 = c3
|
|
347
|
+
end
|
|
348
|
+
if c1 && c2 != 32
|
|
349
|
+
trigram.setbyte(0, c1)
|
|
350
|
+
trigram.setbyte(1, c2)
|
|
351
|
+
trigram.setbyte(2, 32)
|
|
352
|
+
occurrences[trigram] += 1
|
|
353
|
+
end
|
|
354
|
+
occurrences
|
|
253
355
|
end
|
|
254
356
|
|
|
255
357
|
# Out-of-place distance between a language's ordered trigram profile and the
|
|
256
358
|
# text's ranked trigrams. Lower means a closer match.
|
|
359
|
+
def indexed_distances(script, positions)
|
|
360
|
+
index = self.class.send(:profile_index, script)
|
|
361
|
+
return unless index
|
|
362
|
+
|
|
363
|
+
missing, postings = index
|
|
364
|
+
totals = missing.dup
|
|
365
|
+
positions.each do |trigram, position|
|
|
366
|
+
matches = postings[trigram]
|
|
367
|
+
next unless matches
|
|
368
|
+
|
|
369
|
+
i = 0
|
|
370
|
+
while i < matches.size
|
|
371
|
+
# Replace the missing-trigram penalty with the exact rank distance.
|
|
372
|
+
# This correction can be positive when the text rank is above 300.
|
|
373
|
+
totals[matches[i]] += (position - matches[i + 1]).abs - MAX_TRIGRAM_DISTANCE
|
|
374
|
+
i += 2
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
adjustment = [MAX_TRIGRAM_DISTANCE - positions.size, 0].max * MAX_TRIGRAM_DISTANCE
|
|
379
|
+
totals.map! { |total| (total - adjustment).clamp(0, MAX_TOTAL_DISTANCE) }
|
|
380
|
+
end
|
|
381
|
+
|
|
257
382
|
def distance(profile, positions)
|
|
258
383
|
total = 0
|
|
259
384
|
profile.each_with_index do |trigram, i|
|