whatlanguage 2.0.0 → 2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0939c1109ab44fd32e9e611c20696ee315a980877c0f04d5990fe42f51c17201'
4
- data.tar.gz: e5002ebde89b8c6acfa313355d2aa588b31cbb7db27870690bbeae761ecaeb2c
3
+ metadata.gz: 4707fce379ca4e085886aef33b8bd45f3480c79bd93c1a7d040c068aff16a94c
4
+ data.tar.gz: e4b0156f7d3ee2dda4efb77cd2cd8c6abd6f96eac8c27d821166f927fe7421e6
5
5
  SHA512:
6
- metadata.gz: 2a06fa18163cee6de9278291856c1b04c633880be1a2816476ce4b47b03c374242c9eb0ea012e1fb04322de753d45492a9ca96856ec70582ea1287fb19034c2e
7
- data.tar.gz: e57243d9fccc9b5c43e5da591db26085d2eedd7776a4bc3e6dfd9b8ddcfcc59112de6309f63a41f0b2f38bc171bce2d80e638b2c3a8645230657aeb643f852ef
6
+ metadata.gz: fa04498461b8e07de91debf3ddf5ebece5d701a2006fe45a08a0baa7f1d8e7947a1c90fa726b175cbe08a3201ac510041f3f952f4b89abfd61ba0d42de0e5c77
7
+ data.tar.gz: 7f2ba53f9248b9f8559c06758205874b61a624c485a1e6bad02f5d192a5a93e09ae38018c73a5ac46bc93dc9e742a648757b6e00fc75ed12698588530f0124a9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.0 / 2026-09-05
4
+
5
+ - Added a fixed short-text preference for English, Chinese, Hindi, Spanish, and
6
+ French: a 600-point trigram bonus through 10 letters, fading to zero at 50.
7
+ Script routing, candidate restrictions, and minimum-length rules still apply.
8
+ - Replaced hard-coded ranges with Unicode script properties, recognizing
9
+ uppercase Georgian, extended Greek, and supplementary Han letters.
10
+ - Combined Han and kana when detecting Japanese instead of misclassifying
11
+ Han-heavy Japanese text as Chinese.
12
+ - Excluded digits, punctuation, and emoji from script votes and minimum-length
13
+ counting, and treated them as trigram separators while preserving combining marks.
14
+ - Added regression coverage for short texts and Unicode handling. Short-text
15
+ preferences are a heuristic; ambiguous fragments can still be misclassified.
16
+
17
+ ## 2.0.1 / 2026-07-11
18
+
19
+ - Added a `whatlanguage` command-line executable that reads from files given as
20
+ arguments (or stdin) and prints the ISO 639 code of the detected language, or
21
+ `und` if undetermined. (#50, suggested by Keith Bennett)
22
+
3
23
  ## 2.0.0 / 2026-06-10
4
24
 
5
25
  - Rewrote the detection engine from scratch. Bloom-filter dictionary lookups are
data/README.md CHANGED
@@ -13,17 +13,25 @@ WhatLanguage.language("Que linguagem é essa? É uma pergunta sobre a língua po
13
13
  - No runtime dependencies.
14
14
  - Supports 20+ writing systems.
15
15
  - Ships a compact ~220 KB trigram model.
16
- - Requires Ruby 3.0+
17
- - Best on sentence-length text or longer.
16
+ - Requires Ruby 3.0+ (JRuby and TruffleRuby also good)
17
+ - **Best on sentence-length text or longer.** Short texts can have ambiguous results.
18
+
19
+ > [!IMPORTANT]
20
+ > v2.0 has many breaking changes as the entire library has been reimplemented, though the core `WhatLanguage.language` API remains similar. Versions 1.0.6 and earlier (so the 2007-2025 run of the library) used a Bloom-filter technique and had 5MB of binary files to handle ~20 languages. Version 2.0 is more accurate, faster, and supports more languages from a single 220KB JSON file :-)
18
21
 
19
22
  ## How it works
20
23
 
21
- 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.
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
+ 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
+
31
+ 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.
22
32
 
23
33
  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.
24
34
 
25
- > [!IMPORTANT]
26
- > v2.0 has many breaking changes as the entire library has been rewritten, though the core `WhatLanguage.language` API remains similar. Versions 1.0.6 and earlier (so the 2007-2025 run of the library) used a Bloom-filter technique and had 5MB of binary files to handle ~20 languages. Version 2.0 is more accurate, faster, and supports more languages from a single 220KB JSON file :-)
27
35
 
28
36
  ## Usage
29
37
 
@@ -39,7 +47,7 @@ result.score # => 79018
39
47
  result.ranked # => [[:german, 79018], [:dutch, 77631], ... ]
40
48
  ```
41
49
 
42
- Return ranked scores, or the raw score hash:
50
+ Return ranked scores, or the score hash:
43
51
 
44
52
  ```ruby
45
53
  wl.ranked(text) # => [[:german, 79018], [:dutch, 77631], ... ]
@@ -52,15 +60,36 @@ Restrict candidate languages:
52
60
  wl = WhatLanguage.new(only: [:english, :german, :french])
53
61
  ```
54
62
 
55
- Short Latin-script fragments are ignored by default because there is not enough signal to rank shared-script 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:
63
+ For short texts, English, Chinese (Mandarin), Hindi, Spanish, and French receive
64
+ a built-in preference. These five were selected using the
65
+ [2025 total-speaker ranking](https://www.visualcapitalist.com/ranked-the-worlds-most-spoken-languages-in-2025/)
66
+ (including second-language speakers). Eligible trigram candidates receive the
67
+ same bonus: 600 ranking points through 10 letters, decreasing linearly to zero
68
+ at 50 letters. Longer texts keep their original scores. The preference is not
69
+ configurable; it respects `only:` and script detection. Chinese already resolves
70
+ from its script, so it does not need a statistical bonus. Scores exposed by the
71
+ API include the bonus and remain ranking values, not probabilities.
72
+
73
+ 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:
56
74
 
57
75
  ```ruby
58
76
  wl = WhatLanguage.new(min_chars: 0)
59
77
  ```
60
78
 
79
+ ## Command line
80
+
81
+ 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:
82
+
83
+ ```bash
84
+ $ whatlanguage README.md
85
+ en
86
+ $ echo "Wie geht es dir heute?" | whatlanguage
87
+ de
88
+ ```
89
+
61
90
  ## Known limitations
62
91
 
63
- - Short fragments are unreliable. For languages resolved by statistical comparison, fewer than 10 significant characters returns `nil` by default.
92
+ - Short fragments remain unreliable. For languages resolved by statistical comparison, fewer than 10 letters returns `nil` by default. The built-in preference helps some common phrases, but can also favor a preferred language over a less widely spoken one when evidence is weak. It does not lower the minimum length.
64
93
  - Scores are relative ranking values, not probabilities. Use `#ranked` or `#detect.ranked` when close runners-up matter.
65
94
  - Closely related written languages can be hard to separate, especially Norwegian Bokmål/Danish, Hebrew/Yiddish, and similar language pairs.
66
95
  - Kanji-only Japanese text can classify as Chinese because Han characters alone do not identify the language.
@@ -68,6 +97,6 @@ wl = WhatLanguage.new(min_chars: 0)
68
97
 
69
98
  ## Credits
70
99
 
71
- Contributions from Konrad Reiche, Salimane Adjao Moustapha, Andrew Cone, Lasse Skindstad Ebert, Henrik Nyh, Daniel Sandbecker, Michael Hartl, Pedro Lambert, Tobias Preuss, Pepijn Looije, and others appreciated.
100
+ Contributions from Konrad Reiche, Salimane Adjao Moustapha, Andrew Cone, Lasse Skindstad Ebert, Henrik Nyh, Daniel Sandbecker, Michael Hartl, Pedro Lambert, Tobias Preuss, Pepijn Looije, Keith Bennett, and others appreciated.
72
101
 
73
102
  The trigram language profiles in `lib/whatlanguage/trigrams.json` are taken from [whatlang](https://github.com/greyblake/whatlang-rs) (MIT, © Sergey Potapov), itself a derivative of [Franc](https://github.com/wooorm/franc) (MIT, © Titus Wormer). Those profiles are derived from the public-domain Universal Declaration of Human Rights translations.
data/exe/whatlanguage ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'whatlanguage'
5
+
6
+ # Reads from files given as arguments, or stdin otherwise. Prints the ISO 639
7
+ # code of the detected language, or "und" (undetermined) if detection fails.
8
+ puts WhatLanguage.language_iso(ARGF.read) || 'und'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class WhatLanguage
4
- VERSION = '2.0.0'
4
+ VERSION = '2.1.0'
5
5
  end
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 by their Unicode block alone.
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 ranges per script, in detection priority order (mirrors whatlang's
31
- # scripts/detect.rs). The first script whose range contains a character claims
32
- # that character; the script with the most characters wins.
33
- SCRIPT_RANGES = [
34
- ['Latin', [[0x61,0x7A],[0x41,0x5A],[0x80,0xFF],[0x100,0x17F],[0x180,0x24F],
35
- [0x250,0x2AF],[0x1D00,0x1D7F],[0x1D80,0x1DBF],[0x1E00,0x1EFF],
36
- [0x2100,0x214F],[0x2C60,0x2C7F],[0xA720,0xA7FF],[0xAB30,0xAB6F]]],
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, :SCRIPT_RANGES, :ISO_CODES,
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)
@@ -156,14 +130,17 @@ class WhatLanguage
156
130
 
157
131
  candidates = self.class.profiles[script]
158
132
  return results unless candidates
159
- return results if significant_char_count(text) < @min_chars
133
+ char_count = significant_char_count(text)
134
+ return results if char_count < @min_chars
160
135
 
136
+ bonus = short_text_bonus(char_count)
161
137
  positions = trigram_positions(text)
162
138
  candidates.each do |code, trigrams|
163
139
  name = CODE_INFO[code].first
164
140
  next unless allowed?(name)
165
141
 
166
142
  results[name] = MAX_TOTAL_DISTANCE - distance(trigrams, positions)
143
+ results[name] += bonus if SHORT_TEXT_PREFERENCES.include?(name)
167
144
  end
168
145
  results
169
146
  end
@@ -198,6 +175,14 @@ class WhatLanguage
198
175
 
199
176
  private
200
177
 
178
+ # A small fixed prior for widely spoken languages: full strength through ten
179
+ # letters, fading linearly to zero at fifty. These are ranking points, not
180
+ # probabilities. Script routing and the caller's candidate selection still win.
181
+ def short_text_bonus(char_count)
182
+ remaining = (SHORT_TEXT_FADE_CHARS - char_count).clamp(0, SHORT_TEXT_FADE_CHARS - DEFAULT_MIN_CHARS)
183
+ SHORT_TEXT_BONUS * remaining / (SHORT_TEXT_FADE_CHARS - DEFAULT_MIN_CHARS)
184
+ end
185
+
201
186
  def normalize_text(text)
202
187
  text.to_s.unicode_normalize(:nfkc)
203
188
  end
@@ -215,18 +200,17 @@ class WhatLanguage
215
200
  end
216
201
 
217
202
  def significant_char_count(text)
218
- text.each_char.count { |ch| !stop_char?(ch.ord) }
203
+ text.each_char.count { |ch| /\p{L}/.match?(ch) }
219
204
  end
220
205
 
221
206
  # Dominant Unicode script of the text, or nil if it has no script characters.
222
207
  def detect_script(text)
223
208
  counts = Hash.new(0)
224
209
  text.each_char do |ch|
225
- cp = ch.ord
226
- next if stop_char?(cp)
210
+ next unless /\p{L}/.match?(ch)
227
211
 
228
- SCRIPT_RANGES.each do |name, ranges|
229
- if ranges.any? { |lo, hi| cp >= lo && cp <= hi }
212
+ SCRIPT_PATTERNS.each do |name, pattern|
213
+ if pattern.match?(ch)
230
214
  counts[name] += 1
231
215
  break
232
216
  end
@@ -234,14 +218,23 @@ class WhatLanguage
234
218
  end
235
219
  return nil if counts.empty?
236
220
 
221
+ # Japanese uses Han, Hiragana, and Katakana together. Combine their votes
222
+ # when kana is present, but still let a dominant unrelated script win.
223
+ if counts['Hiragana'] + counts['Katakana'] > 0
224
+ japanese = counts['Mandarin'] + counts['Hiragana'] + counts['Katakana']
225
+ counts.delete('Mandarin')
226
+ counts.delete('Katakana')
227
+ counts['Hiragana'] = japanese
228
+ end
229
+
237
230
  counts.max_by { |_name, n| n }.first
238
231
  end
239
232
 
240
233
  # Text trigrams ranked by descending frequency, mapped to their rank index.
241
- # Mirrors whatlang's trigram extraction: punctuation/digits become spaces,
242
- # the stream is bounded by spaces, and runs of spaces are collapsed.
234
+ # Non-letter/mark characters become spaces. Preserve combining marks used
235
+ # by the profiles; bound the stream by spaces and ignore repeated spaces.
243
236
  def trigram_positions(text)
244
- chars = text.downcase.each_char.map { |c| stop_char?(c.ord) ? ' ' : c }
237
+ chars = text.downcase.each_char.map { |c| /[\p{L}\p{M}]/.match?(c) ? c : ' ' }
245
238
  return {} if chars.empty?
246
239
 
247
240
  occurrences = Hash.new(0)
@@ -272,9 +265,4 @@ class WhatLanguage
272
265
  total -= (MAX_TRIGRAM_DISTANCE - count) * MAX_TRIGRAM_DISTANCE if MAX_TRIGRAM_DISTANCE > count
273
266
  total.clamp(0, MAX_TOTAL_DISTANCE)
274
267
  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
268
  end
data/whatlanguage.gemspec CHANGED
@@ -16,6 +16,7 @@ Gem::Specification.new do |gem|
16
16
  gem.required_ruby_version = '>= 3.0'
17
17
 
18
18
  gem.files = Dir['lib/**/*'] + [
19
+ 'exe/whatlanguage',
19
20
  'README.md',
20
21
  'CHANGELOG.md',
21
22
  'LICENSE.txt',
@@ -23,6 +24,8 @@ Gem::Specification.new do |gem|
23
24
  'Rakefile',
24
25
  'whatlanguage.gemspec'
25
26
  ]
27
+ gem.bindir = 'exe'
28
+ gem.executables = ['whatlanguage']
26
29
  gem.require_paths = ['lib']
27
30
 
28
31
  gem.add_development_dependency 'minitest', '~> 5.0'
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: whatlanguage
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter Cooper
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -40,7 +40,8 @@ dependencies:
40
40
  description: WhatLanguage rapidly detects the language of a sample of text
41
41
  email:
42
42
  - git@peterc.org
43
- executables: []
43
+ executables:
44
+ - whatlanguage
44
45
  extensions: []
45
46
  extra_rdoc_files: []
46
47
  files:
@@ -49,6 +50,7 @@ files:
49
50
  - LICENSE.txt
50
51
  - README.md
51
52
  - Rakefile
53
+ - exe/whatlanguage
52
54
  - lib/whatlanguage.rb
53
55
  - lib/whatlanguage/languages.rb
54
56
  - lib/whatlanguage/trigrams.json