whatlanguage 2.0.1 → 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 +39 -0
- data/README.md +61 -9
- data/lib/whatlanguage/languages.rb +14 -10
- data/lib/whatlanguage/version.rb +1 -1
- data/lib/whatlanguage.rb +190 -77
- 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,44 @@
|
|
|
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
|
+
|
|
28
|
+
## 2.1.0 / 2026-09-05
|
|
29
|
+
|
|
30
|
+
- Added a fixed short-text preference for English, Chinese, Hindi, Spanish, and
|
|
31
|
+
French: a 600-point trigram bonus through 10 letters, fading to zero at 50.
|
|
32
|
+
Script routing, candidate restrictions, and minimum-length rules still apply.
|
|
33
|
+
- Replaced hard-coded ranges with Unicode script properties, recognizing
|
|
34
|
+
uppercase Georgian, extended Greek, and supplementary Han letters.
|
|
35
|
+
- Combined Han and kana when detecting Japanese instead of misclassifying
|
|
36
|
+
Han-heavy Japanese text as Chinese.
|
|
37
|
+
- Excluded digits, punctuation, and emoji from script votes and minimum-length
|
|
38
|
+
counting, and treated them as trigram separators while preserving combining marks.
|
|
39
|
+
- Added regression coverage for short texts and Unicode handling. Short-text
|
|
40
|
+
preferences are a heuristic; ambiguous fragments can still be misclassified.
|
|
41
|
+
|
|
3
42
|
## 2.0.1 / 2026-07-11
|
|
4
43
|
|
|
5
44
|
- Added a `whatlanguage` command-line executable that reads from files given as
|
data/README.md
CHANGED
|
@@ -17,14 +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
|
-
Detection is in two stages. First, the dominant Unicode script is detected; scripts used by a single language (Greek, Korean, Thai, Japanese using Hiragana/Katakana) resolve immediately. For scripts shared by several languages (e.g. Latin, Cyrillic, Arabic, Hebrew) trigrams are ranked by frequency and compared against candidate language profiles.
|
|
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.
|
|
26
|
+
|
|
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.
|
|
25
28
|
|
|
26
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.
|
|
27
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.
|
|
28
36
|
|
|
29
37
|
## Usage
|
|
30
38
|
|
|
@@ -40,7 +48,17 @@ result.score # => 79018
|
|
|
40
48
|
result.ranked # => [[:german, 79018], [:dutch, 77631], ... ]
|
|
41
49
|
```
|
|
42
50
|
|
|
43
|
-
|
|
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
|
+
|
|
61
|
+
Return ranked scores, or the score hash:
|
|
44
62
|
|
|
45
63
|
```ruby
|
|
46
64
|
wl.ranked(text) # => [[:german, 79018], [:dutch, 77631], ... ]
|
|
@@ -53,12 +71,46 @@ Restrict candidate languages:
|
|
|
53
71
|
wl = WhatLanguage.new(only: [:english, :german, :french])
|
|
54
72
|
```
|
|
55
73
|
|
|
56
|
-
|
|
74
|
+
For short texts, English, Chinese (Mandarin), Hindi, Spanish, and French receive
|
|
75
|
+
a built-in preference. These five were selected using the
|
|
76
|
+
[2025 total-speaker ranking](https://www.visualcapitalist.com/ranked-the-worlds-most-spoken-languages-in-2025/)
|
|
77
|
+
(including second-language speakers). Eligible trigram candidates receive the
|
|
78
|
+
same bonus: 600 ranking points through 10 letters, decreasing linearly to zero
|
|
79
|
+
at 50 letters. Longer texts keep their original scores. The preference is not
|
|
80
|
+
configurable; it respects `only:` and script detection. Chinese already resolves
|
|
81
|
+
from its script, so it does not need a statistical bonus. Scores exposed by the
|
|
82
|
+
API include the bonus and remain ranking values, not probabilities.
|
|
83
|
+
|
|
84
|
+
Short shared-script fragments are ignored by default because there is not enough signal to rank their languages reliably. The threshold applies to the statistical trigram stage; scripts that identify a single supported language, such as Greek, Korean, or Thai, can still resolve from shorter text. The threshold can be adjusted:
|
|
57
85
|
|
|
58
86
|
```ruby
|
|
59
87
|
wl = WhatLanguage.new(min_chars: 0)
|
|
60
88
|
```
|
|
61
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
|
+
|
|
62
114
|
## Command line
|
|
63
115
|
|
|
64
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:
|
|
@@ -72,11 +124,11 @@ de
|
|
|
72
124
|
|
|
73
125
|
## Known limitations
|
|
74
126
|
|
|
75
|
-
- Short
|
|
76
|
-
- Scores
|
|
77
|
-
-
|
|
78
|
-
-
|
|
79
|
-
- 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.
|
|
80
132
|
|
|
81
133
|
## Credits
|
|
82
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
|
@@ -9,12 +9,15 @@ class WhatLanguage
|
|
|
9
9
|
MAX_TOTAL_DISTANCE = MAX_TRIGRAM_DISTANCE * MAX_TRIGRAM_DISTANCE # 90_000
|
|
10
10
|
TEXT_TRIGRAMS_SIZE = 600
|
|
11
11
|
DEFAULT_MIN_CHARS = 10
|
|
12
|
+
SHORT_TEXT_PREFERENCES = %i[english chinese hindi spanish french].freeze
|
|
13
|
+
SHORT_TEXT_BONUS = 600
|
|
14
|
+
SHORT_TEXT_FADE_CHARS = 50
|
|
12
15
|
|
|
13
16
|
Result = Struct.new(:language, :iso, :score, :ranked, keyword_init: true) do
|
|
14
17
|
alias scores ranked
|
|
15
18
|
end
|
|
16
19
|
|
|
17
|
-
# Scripts that resolve to a single language
|
|
20
|
+
# Scripts that resolve to a single supported language from their letters.
|
|
18
21
|
# (Hiragana and Katakana both indicate Japanese.) Scripts NOT listed here but
|
|
19
22
|
# present in the trigram dataset are disambiguated statistically instead.
|
|
20
23
|
DETERMINISTIC = {
|
|
@@ -27,43 +30,13 @@ class WhatLanguage
|
|
|
27
30
|
'Katakana' => 'jpn'
|
|
28
31
|
}.freeze
|
|
29
32
|
|
|
30
|
-
# Unicode
|
|
31
|
-
#
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
['Cyrillic', [[0x400,0x484],[0x487,0x52F],[0x2DE0,0x2DFF],[0xA640,0xA69D],
|
|
38
|
-
[0x1D2B,0x1D2B],[0x1D78,0x1D78],[0xA69F,0xA69F]]],
|
|
39
|
-
['Arabic', [[0x600,0x6FF],[0x750,0x7FF],[0x8A0,0x8FF],[0xFB50,0xFDFF],
|
|
40
|
-
[0xFE70,0xFEFF],[0x10E60,0x10E7F],[0x1EE00,0x1EEFF]]],
|
|
41
|
-
['Mandarin', [[0x2E80,0x2E99],[0x2E9B,0x2EF3],[0x2F00,0x2FD5],[0x3005,0x3005],
|
|
42
|
-
[0x3007,0x3007],[0x3021,0x3029],[0x3038,0x303B],[0x3400,0x4DB5],
|
|
43
|
-
[0x4E00,0x9FCC],[0xF900,0xFA6D],[0xFA70,0xFAD9]]],
|
|
44
|
-
['Devanagari', [[0x900,0x97F],[0xA8E0,0xA8FF],[0x1CD0,0x1CFF]]],
|
|
45
|
-
['Hebrew', [[0x590,0x5FF]]],
|
|
46
|
-
['Ethiopic', [[0x1200,0x139F],[0x2D80,0x2DDF],[0xAB00,0xAB2F]]],
|
|
47
|
-
['Georgian', [[0x10A0,0x10FF]]],
|
|
48
|
-
['Bengali', [[0x980,0x9FF]]],
|
|
49
|
-
['Hangul', [[0xAC00,0xD7AF],[0x1100,0x11FF],[0x3130,0x318F],[0x3200,0x32FF],
|
|
50
|
-
[0xA960,0xA97F],[0xD7B0,0xD7FF]]],
|
|
51
|
-
['Hiragana', [[0x3040,0x309F]]],
|
|
52
|
-
['Katakana', [[0x30A0,0x30FF]]],
|
|
53
|
-
['Greek', [[0x370,0x3FF]]],
|
|
54
|
-
['Kannada', [[0xC80,0xCFF]]],
|
|
55
|
-
['Tamil', [[0xB80,0xBFF]]],
|
|
56
|
-
['Thai', [[0xE00,0xE7F]]],
|
|
57
|
-
['Gujarati', [[0xA80,0xAFF]]],
|
|
58
|
-
['Gurmukhi', [[0xA00,0xA7F]]],
|
|
59
|
-
['Telugu', [[0xC00,0xC7F]]],
|
|
60
|
-
['Malayalam', [[0xD00,0xD7F]]],
|
|
61
|
-
['Oriya', [[0xB00,0xB7F]]],
|
|
62
|
-
['Myanmar', [[0x1000,0x109F]]],
|
|
63
|
-
['Sinhala', [[0xD80,0xDFF]]],
|
|
64
|
-
['Khmer', [[0x1780,0x17FF],[0x19E0,0x19FF]]],
|
|
65
|
-
['Armenian', [[0x530,0x58F],[0xFB13,0xFB17]]]
|
|
66
|
-
].freeze
|
|
33
|
+
# Unicode script properties include letters outside the original BMP blocks.
|
|
34
|
+
# Keep the model's historical "Mandarin" key for the Han script.
|
|
35
|
+
SCRIPT_PATTERNS = %w[
|
|
36
|
+
Latin Cyrillic Arabic Han Devanagari Hebrew Ethiopic Georgian Bengali
|
|
37
|
+
Hangul Hiragana Katakana Greek Kannada Tamil Thai Gujarati Gurmukhi
|
|
38
|
+
Telugu Malayalam Oriya Myanmar Sinhala Khmer Armenian
|
|
39
|
+
].map { |script| [script == 'Han' ? 'Mandarin' : script, Regexp.new("\\p{#{script}}")] }.freeze
|
|
67
40
|
|
|
68
41
|
# ISO 639-1 (with 639-3 fallback) lookup by language-name symbol, plus the
|
|
69
42
|
# historical nil => nil entry. Internal; kept for backward compatibility.
|
|
@@ -76,8 +49,9 @@ class WhatLanguage
|
|
|
76
49
|
end.freeze
|
|
77
50
|
|
|
78
51
|
private_constant :MAX_TRIGRAM_DISTANCE, :MAX_TOTAL_DISTANCE, :TEXT_TRIGRAMS_SIZE,
|
|
79
|
-
:DEFAULT_MIN_CHARS, :DETERMINISTIC, :
|
|
80
|
-
:NAME_TO_CODE
|
|
52
|
+
:DEFAULT_MIN_CHARS, :DETERMINISTIC, :SCRIPT_PATTERNS, :ISO_CODES,
|
|
53
|
+
:NAME_TO_CODE, :SHORT_TEXT_PREFERENCES, :SHORT_TEXT_BONUS,
|
|
54
|
+
:SHORT_TEXT_FADE_CHARS
|
|
81
55
|
|
|
82
56
|
class << self
|
|
83
57
|
def detect(text)
|
|
@@ -118,6 +92,30 @@ class WhatLanguage
|
|
|
118
92
|
def default_detector
|
|
119
93
|
@default_detector ||= new
|
|
120
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
|
|
121
119
|
end
|
|
122
120
|
|
|
123
121
|
def initialize(*selection, only: nil, min_chars: DEFAULT_MIN_CHARS)
|
|
@@ -145,7 +143,7 @@ class WhatLanguage
|
|
|
145
143
|
def score_hash(text)
|
|
146
144
|
results = Hash.new(0)
|
|
147
145
|
text = normalize_text(text)
|
|
148
|
-
script =
|
|
146
|
+
script, char_count = script_and_letter_count(text)
|
|
149
147
|
return results unless script
|
|
150
148
|
|
|
151
149
|
if (code = DETERMINISTIC[script])
|
|
@@ -154,16 +152,31 @@ class WhatLanguage
|
|
|
154
152
|
return results
|
|
155
153
|
end
|
|
156
154
|
|
|
157
|
-
candidates =
|
|
155
|
+
candidates = candidates_for(script)
|
|
158
156
|
return results unless candidates
|
|
159
|
-
return results if
|
|
157
|
+
return results if char_count < @min_chars
|
|
160
158
|
|
|
159
|
+
bonus = short_text_bonus(char_count)
|
|
161
160
|
positions = trigram_positions(text)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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)
|
|
165
176
|
|
|
166
|
-
|
|
177
|
+
results[name] = MAX_TOTAL_DISTANCE - distance(trigrams, positions)
|
|
178
|
+
results[name] += bonus if SHORT_TEXT_PREFERENCES.include?(name)
|
|
179
|
+
end
|
|
167
180
|
end
|
|
168
181
|
results
|
|
169
182
|
end
|
|
@@ -188,16 +201,26 @@ class WhatLanguage
|
|
|
188
201
|
|
|
189
202
|
# Most likely language as a name symbol, or nil when no language is detected.
|
|
190
203
|
def language(text)
|
|
191
|
-
|
|
204
|
+
score_hash(text).max_by { |_name, score| score }&.first
|
|
192
205
|
end
|
|
193
206
|
|
|
194
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.
|
|
195
210
|
def language_iso(text)
|
|
196
|
-
|
|
211
|
+
ISO_CODES[language(text)]
|
|
197
212
|
end
|
|
198
213
|
|
|
199
214
|
private
|
|
200
215
|
|
|
216
|
+
# A small fixed prior for widely spoken languages: full strength through ten
|
|
217
|
+
# letters, fading linearly to zero at fifty. These are ranking points, not
|
|
218
|
+
# probabilities. Script routing and the caller's candidate selection still win.
|
|
219
|
+
def short_text_bonus(char_count)
|
|
220
|
+
remaining = (SHORT_TEXT_FADE_CHARS - char_count).clamp(0, SHORT_TEXT_FADE_CHARS - DEFAULT_MIN_CHARS)
|
|
221
|
+
SHORT_TEXT_BONUS * remaining / (SHORT_TEXT_FADE_CHARS - DEFAULT_MIN_CHARS)
|
|
222
|
+
end
|
|
223
|
+
|
|
201
224
|
def normalize_text(text)
|
|
202
225
|
text.to_s.unicode_normalize(:nfkc)
|
|
203
226
|
end
|
|
@@ -206,6 +229,20 @@ class WhatLanguage
|
|
|
206
229
|
@selection.include?(:all) || languages.include?(name)
|
|
207
230
|
end
|
|
208
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
|
+
|
|
209
246
|
def validate_selection!
|
|
210
247
|
requested = @selection.reject { |name| name == :all }
|
|
211
248
|
unknown = requested.reject { |name| self.class.languages.include?(NAME_ALIASES.fetch(name, name)) }
|
|
@@ -214,53 +251,134 @@ class WhatLanguage
|
|
|
214
251
|
raise ArgumentError, "Unknown language selection: #{unknown.map(&:inspect).join(', ')}"
|
|
215
252
|
end
|
|
216
253
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
220
260
|
|
|
221
|
-
# Dominant Unicode script of the text, or nil if it has no script characters.
|
|
222
|
-
def detect_script(text)
|
|
223
261
|
counts = Hash.new(0)
|
|
262
|
+
letter_count = 0
|
|
224
263
|
text.each_char do |ch|
|
|
225
|
-
|
|
226
|
-
|
|
264
|
+
next unless /\p{L}/.match?(ch)
|
|
265
|
+
letter_count += 1
|
|
227
266
|
|
|
228
|
-
|
|
229
|
-
|
|
267
|
+
i = 0
|
|
268
|
+
while i < SCRIPT_PATTERNS.size
|
|
269
|
+
name, pattern = SCRIPT_PATTERNS[i]
|
|
270
|
+
if pattern.match?(ch)
|
|
230
271
|
counts[name] += 1
|
|
231
272
|
break
|
|
232
273
|
end
|
|
274
|
+
i += 1
|
|
233
275
|
end
|
|
234
276
|
end
|
|
235
|
-
return nil if counts.empty?
|
|
277
|
+
return [nil, letter_count] if counts.empty?
|
|
278
|
+
|
|
279
|
+
# Japanese uses Han, Hiragana, and Katakana together. Combine their votes
|
|
280
|
+
# when kana is present, but still let a dominant unrelated script win.
|
|
281
|
+
if counts['Hiragana'] + counts['Katakana'] > 0
|
|
282
|
+
japanese = counts['Mandarin'] + counts['Hiragana'] + counts['Katakana']
|
|
283
|
+
counts.delete('Mandarin')
|
|
284
|
+
counts.delete('Katakana')
|
|
285
|
+
counts['Hiragana'] = japanese
|
|
286
|
+
end
|
|
236
287
|
|
|
237
|
-
counts.max_by { |_name, n| n }.first
|
|
288
|
+
[counts.max_by { |_name, n| n }.first, letter_count]
|
|
238
289
|
end
|
|
239
290
|
|
|
240
291
|
# Text trigrams ranked by descending frequency, mapped to their rank index.
|
|
241
|
-
#
|
|
242
|
-
# the
|
|
292
|
+
# Non-letter/mark characters become spaces. Preserve combining marks used
|
|
293
|
+
# by the profiles; bound the stream by spaces and ignore repeated spaces.
|
|
243
294
|
def trigram_positions(text)
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
246
305
|
|
|
306
|
+
def unicode_trigram_counts(text)
|
|
247
307
|
occurrences = Hash.new(0)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
252
319
|
c1 = c2
|
|
253
320
|
c2 = c3
|
|
254
321
|
end
|
|
322
|
+
if c1 && c2 != ' '
|
|
323
|
+
trigram.replace(c1) << c2 << ' '
|
|
324
|
+
occurrences[trigram] += 1
|
|
325
|
+
end
|
|
255
326
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
260
355
|
end
|
|
261
356
|
|
|
262
357
|
# Out-of-place distance between a language's ordered trigram profile and the
|
|
263
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
|
+
|
|
264
382
|
def distance(profile, positions)
|
|
265
383
|
total = 0
|
|
266
384
|
profile.each_with_index do |trigram, i|
|
|
@@ -272,9 +390,4 @@ class WhatLanguage
|
|
|
272
390
|
total -= (MAX_TRIGRAM_DISTANCE - count) * MAX_TRIGRAM_DISTANCE if MAX_TRIGRAM_DISTANCE > count
|
|
273
391
|
total.clamp(0, MAX_TOTAL_DISTANCE)
|
|
274
392
|
end
|
|
275
|
-
|
|
276
|
-
# Space, ASCII punctuation, or digit: no value for script/language detection.
|
|
277
|
-
def stop_char?(codepoint)
|
|
278
|
-
codepoint <= 0x40 || (codepoint >= 0x5B && codepoint <= 0x60) || (codepoint >= 0x7B && codepoint <= 0x7E)
|
|
279
|
-
end
|
|
280
393
|
end
|