@@ -16,6 +16,12 @@ defmodule Unicode.String.Dictionary do
1616 they are under an open source license and also for consistency with
1717 [ICU](https://icu.unicode.org).
1818
19+ A dictionary is applied only to runs of text written in the script(s) it covers -
20+ Han, Hiragana and Katakana for Chinese and Japanese, and the corresponding script
21+ for Thai, Lao, Khmer and Burmese. Text in any other script, such as Latin words or
22+ digits embedded in Japanese text, is broken by the standard
23+ [Unicode Segmentation](https://unicode.org/reports/tr29/) rules.
24+
1925 Note that these dictionaries need to be downloaded with
2026 `mix unicode.string.download.dictionaries` prior to use. Each dictionary
2127 will be parsed and loaded into [persistent_term](https://www.erlang.org/doc/man/persistent_term)
@@ -34,6 +40,8 @@ defmodule Unicode.String.Dictionary do
3440
3541 alias Unicode.String.Trie
3642
43+ require Unicode.Set
44+
3745 @ app_name :unicode_string
3846 @ dictionary_dir "dictionaries/"
3947
@@ -108,19 +116,97 @@ defmodule Unicode.String.Dictionary do
108116 :persistent_term . get ( { @ app_name , locale } , nil )
109117 end
110118
119+ # The characters each dictionary is able to segment. These sets mirror the
120+ # ones used by the ICU dictionary break engines so that a dictionary is
121+ # applied only to text written in the script(s) it actually covers. Any other
122+ # text - Latin words, digits, punctuation - is segmented by the standard
123+ # Unicode word break rules.
124+ #
125+ # See https://github.com/unicode-org/icu/blob/main/icu4c/source/common/dictbe.cpp
126+
127+ @ doc false
128+ def dictionary_script? ( codepoint , dictionary )
129+
130+ # ー and ー are the prolonged sound marks and ゙ and ゚ the halfwidth voiced
131+ # sound marks. All four are Script=Common but only occur in Japanese text.
132+ def dictionary_script? ( codepoint , :zh )
133+ when Unicode.Set . match? (
134+ codepoint ,
135+ "[[:sc=Han:][:sc=Hiragana:][:sc=Katakana:]\\ u30FC\\ uFF70\\ uFF9E\\ uFF9F]"
136+ ) do
137+ true
138+ end
139+
140+ def dictionary_script? ( codepoint , :th )
141+ when Unicode.Set . match? ( codepoint , "[[:sc=Thai:]&[:lb=SA:]]" ) do
142+ true
143+ end
144+
145+ def dictionary_script? ( codepoint , :lo )
146+ when Unicode.Set . match? ( codepoint , "[[:sc=Lao:]&[:lb=SA:]]" ) do
147+ true
148+ end
149+
150+ def dictionary_script? ( codepoint , :km )
151+ when Unicode.Set . match? ( codepoint , "[[:sc=Khmer:]&[:lb=SA:]]" ) do
152+ true
153+ end
154+
155+ def dictionary_script? ( codepoint , :my )
156+ when Unicode.Set . match? ( codepoint , "[[:sc=Myanmar:]&[:lb=SA:]]" ) do
157+ true
158+ end
159+
160+ def dictionary_script? ( codepoint , _dictionary ) when is_integer ( codepoint ) do
161+ false
162+ end
163+
164+ # Splits `string` at the start of the first run of dictionary script,
165+ # returning `{text_before_the_run, run_and_everything_after_it}`. When there
166+ # is no dictionary script in `string` the second element is `""`.
167+
168+ @ doc false
169+ def split_at_dictionary_run ( string , dictionary ) do
170+ bytes = bytes_before_dictionary_run ( string , dictionary , 0 )
171+ << before_run :: binary - size ( ^ bytes ) , from_run :: binary >> = string
172+ { before_run , from_run }
173+ end
174+
175+ defp bytes_before_dictionary_run ( << codepoint :: utf8 , rest :: binary >> = string , dictionary , bytes ) do
176+ if dictionary_script? ( codepoint , dictionary ) do
177+ bytes
178+ else
179+ bytes_before_dictionary_run ( rest , dictionary , bytes + byte_size ( string ) - byte_size ( rest ) )
180+ end
181+ end
182+
183+ # Either the end of the string or not valid UTF-8. Either way there is no
184+ # dictionary script run left to split at.
185+ defp bytes_before_dictionary_run ( string , _dictionary , bytes ) do
186+ bytes + byte_size ( string )
187+ end
188+
189+ # These are called for every character of a dictionary script run so they
190+ # take the dictionary directly from :persistent_term. A dictionary that was
191+ # never downloaded is absent rather than empty, hence the explicit default.
192+
111193 @ doc false
112194 def has_key ( string , locale ) do
113195 with { :ok , locale } <- dictionary_locale ( locale ) do
114- dictionary = :persistent_term . get ( { @ app_name , locale } )
115- Trie . has_key ( string , dictionary )
196+ case :persistent_term . get ( { @ app_name , locale } , nil ) do
197+ nil -> false
198+ dictionary -> Trie . has_key ( string , dictionary )
199+ end
116200 end
117201 end
118202
119203 @ doc false
120204 def find_prefix ( string , locale ) do
121205 with { :ok , locale } <- dictionary_locale ( locale ) do
122- dictionary = :persistent_term . get ( { @ app_name , locale } )
123- Trie . find_prefix ( string , dictionary )
206+ case :persistent_term . get ( { @ app_name , locale } , nil ) do
207+ nil -> :error
208+ dictionary -> Trie . find_prefix ( string , dictionary )
209+ end
124210 end
125211 end
126212
@@ -138,30 +224,45 @@ defmodule Unicode.String.Dictionary do
138224 defp load_dictionary ( locale , file_name ) do
139225 require Logger
140226
141- trie =
142- file_name
143- |> read_dictionary ( )
144- |> String . split ( "\n " )
145- |> Enum . reject ( & ( String . starts_with? ( & 1 , @ comment_marker ) or String . length ( & 1 ) == 0 ) )
146- |> Enum . map ( fn line ->
147- case String . split ( line , "\t " ) do
148- [ word ] -> word
149- [ word , value ] -> { word , String . to_integer ( value ) }
150- end
151- end )
152- |> Trie . new ( )
227+ with { :ok , contents } <- read_dictionary ( file_name ) do
228+ trie = contents |> dictionary_entries ( ) |> Trie . new ( )
229+ :ok = :persistent_term . put ( { @ app_name , locale } , trie )
230+ trie = :persistent_term . get ( { @ app_name , locale } )
153231
154- :ok = :persistent_term . put ( { @ app_name , locale } , trie )
155- trie = :persistent_term . get ( { @ app_name , locale } )
232+ # Logger.debug("[unicode_string] Loaded word break dictionary for locale #{inspect locale}")
233+ { :ok , trie }
234+ end
235+ end
156236
157- # Logger.debug("[unicode_string] Loaded word break dictionary for locale #{inspect locale}")
158- { :ok , trie }
237+ defp dictionary_entries ( contents ) do
238+ contents
239+ |> String . split ( "\n " )
240+ |> Enum . reject ( & ( String . starts_with? ( & 1 , @ comment_marker ) or String . length ( & 1 ) == 0 ) )
241+ |> Enum . map ( & dictionary_entry / 1 )
242+ end
243+
244+ defp dictionary_entry ( line ) do
245+ case String . split ( line , "\t " ) do
246+ [ word ] -> word
247+ [ word , value ] -> { word , String . to_integer ( value ) }
248+ end
159249 end
160250
251+ # A dictionary that has not been downloaded is not an error the caller
252+ # should have to rescue - word breaking falls back to the standard
253+ # Unicode rules - so the read returns an error rather than raising.
254+
161255 defp read_dictionary ( file_name ) do
162256 priv_dir = :code . priv_dir ( @ app_name ) |> to_string
163257 path = Path . join ( priv_dir , [ @ dictionary_dir , file_name ] )
164- File . read! ( path )
258+
259+ case File . read ( path ) do
260+ { :ok , contents } ->
261+ { :ok , contents }
262+
263+ { :error , reason } ->
264+ { :error , "Could not read #{ inspect ( path ) } : #{ :file . format_error ( reason ) } " }
265+ end
165266 end
166267
167268 @ doc false
0 commit comments