Skip to content

Commit 2ff85fa

Browse files
committed
Apply dictionary word break only to runs of dictionary script
1 parent e29bfb3 commit 2ff85fa

7 files changed

Lines changed: 403 additions & 48 deletions

File tree

.github/workflows/ci.yml

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ jobs:
5858
# never share BEAMs compiled by a different toolchain.
5959
- name: Cache deps
6060
id: cache-deps
61-
uses: actions/cache@v4
61+
uses: actions/cache@v5
6262
env:
6363
cache-name: cache-elixir-deps
6464
with:
@@ -72,7 +72,7 @@ jobs:
7272
# project's downloaded deps every run.
7373
- name: Cache compiled build
7474
id: cache-build
75-
uses: actions/cache@v4
75+
uses: actions/cache@v5
7676
env:
7777
cache-name: cache-compiled-build
7878
with:
@@ -114,9 +114,24 @@ jobs:
114114
if: ${{ matrix.lint }}
115115
run: mix test --cover
116116

117+
# Step: Cache the dialyzer PLT. Keyed on the toolchain as well as the
118+
# lockfile since a PLT is tagged with the BEAM that built it. Without
119+
# this the PLT is rebuilt from scratch on every run, which takes several
120+
# minutes.
121+
- name: Restore PLT cache
122+
if: ${{ matrix.lint }}
123+
uses: actions/cache@v5
124+
with:
125+
path: _build/dev/dialyxir_*
126+
key: plt-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('mix.lock') }}
127+
restore-keys: |
128+
plt-${{ matrix.elixir }}-${{ matrix.otp }}-
129+
117130
# Step: Execute dialyzer. Runs on the lint entry only; a single
118131
# analysis per change is sufficient and avoids rebuilding a PLT for
119132
# every toolchain in the matrix.
120133
- name: Run dialyzer
121134
if: ${{ matrix.lint }}
135+
env:
136+
MIX_ENV: dev
122137
run: mix dialyzer

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## Unicode String v2.3.1
4+
5+
This is the changelog for Unicode String v2.3.1 released on _unreleased_. For older changelogs please consult the release tag on [GitHub](https://github.com/elixir-unicode/unicode_string/tags)
6+
7+
### Bug Fixes
8+
9+
* Word breaking in a dictionary locale now applies the dictionary only to runs of text written in the script(s) that dictionary covers, with the standard Unicode rules governing everything else. Previously `Unicode.String.split("Japanese", break: :word, locale: :ja)` returned each letter separately.
10+
11+
* Word and line breaking no longer raise a `File.Error` when the ICU dictionaries have not been downloaded with `mix unicode.string.download.dictionaries`. Segmentation now falls back to the standard Unicode rules, and `Unicode.String.break/2` and `Unicode.String.splitter/2` return `{:error, reason}`.
12+
313
## Unicode String v2.3.0
414

515
This is the changelog for Unicode String v2.3.0 released on July 23rd, 2026. For older changelogs please consult the release tag on [GitHub](https://github.com/elixir-unicode/unicode_string/tags)

lib/unicode/break.ex

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -213,34 +213,95 @@ defmodule Unicode.String.Break do
213213
@doc false
214214
def next("", _locale, _break, _options), do: nil
215215

216-
def next(string, locale, :word = break, options) when locale in @dictionary_locales do
217-
<<char::utf8, rest::binary>> = string
216+
def next(string, locale, :word = break, options)
217+
when locale in @dictionary_locales do
218+
# The guard restricts `locale` to the locales that have a dictionary, so
219+
# this always resolves.
220+
{:ok, dictionary} = Dictionary.dictionary_locale(locale)
221+
222+
# Without a downloaded dictionary the trie lookups find nothing and every
223+
# character becomes its own segment. The standard rules are a much better
224+
# answer than that, so fall back to them.
225+
if Dictionary.loaded?(dictionary) do
226+
next_word_with_dictionary(string, locale, dictionary, break, options)
227+
else
228+
next_by_rules(string, locale, break, options)
229+
end
230+
end
231+
232+
def next(string, locale, break, options) when break in @break_keys and is_binary(string) do
233+
next_by_rules(string, locale, break, options)
234+
end
218235

219-
{<<char::utf8>>, rest}
220-
|> next_dict(locale, options)
236+
# A dictionary only knows how to segment the script(s) it covers. Applying it
237+
# to anything else - Latin words, digits, punctuation - shatters that text
238+
# into single characters, so only runs of dictionary script go to the
239+
# dictionary and everything else goes to the standard rules.
240+
241+
defp next_word_with_dictionary(string, locale, dictionary, break, options) do
242+
string
243+
|> word_pair_with_dictionary(dictionary, locale, break, options)
221244
|> repeat_if_trimming_required(locale, break, options, options[:trim])
222245
end
223246

224-
def next(string, locale, break, options) when break in @break_keys and is_binary(string) do
225-
pair =
226-
case Map.fetch!(@break_map, break) do
227-
:grapheme_cluster_break ->
228-
G.next(string)
247+
defp word_pair_with_dictionary(
248+
<<char::utf8, rest::binary>> = string,
249+
dictionary,
250+
locale,
251+
break,
252+
options
253+
) do
254+
if Dictionary.dictionary_script?(char, dictionary) do
255+
next_dict({<<char::utf8>>, rest}, dictionary, options)
256+
else
257+
string
258+
|> break_pair(locale, break, options)
259+
|> truncate_at_dictionary_run(dictionary)
260+
end
261+
end
229262

230-
:word_break ->
231-
W.next(string)
263+
# Not valid UTF-8. Leave it to the standard rules to deal with.
264+
defp word_pair_with_dictionary(string, _dictionary, locale, break, options) do
265+
break_pair(string, locale, break, options)
266+
end
232267

233-
:sentence_break ->
234-
S.next(string, locale, sentence_suppressions(locale, options))
268+
# A rule such as WB13b (`$ExtendNumLet × $Katakana`) can carry a segment past
269+
# the start of a dictionary script run. Cut the segment back so the
270+
# dictionary, not the rules, decides how that run is broken.
235271

236-
:line_break ->
237-
L.next(string)
238-
end
272+
defp truncate_at_dictionary_run(nil, _dictionary) do
273+
nil
274+
end
239275

240-
pair
276+
defp truncate_at_dictionary_run({segment, rest}, dictionary) do
277+
case Dictionary.split_at_dictionary_run(segment, dictionary) do
278+
{^segment, ""} -> {segment, rest}
279+
{truncated, remaining} -> {truncated, remaining <> rest}
280+
end
281+
end
282+
283+
defp next_by_rules(string, locale, break, options) do
284+
string
285+
|> break_pair(locale, break, options)
241286
|> repeat_if_trimming_required(locale, break, options, options[:trim])
242287
end
243288

289+
defp break_pair(string, locale, break, options) do
290+
case Map.fetch!(@break_map, break) do
291+
:grapheme_cluster_break ->
292+
G.next(string)
293+
294+
:word_break ->
295+
W.next(string)
296+
297+
:sentence_break ->
298+
S.next(string, locale, sentence_suppressions(locale, options))
299+
300+
:line_break ->
301+
L.next(string)
302+
end
303+
end
304+
244305
defp repeat_if_trimming_required(nil, _locale, _break, _options, _) do
245306
nil
246307
end
@@ -263,6 +324,17 @@ defmodule Unicode.String.Break do
263324

264325
defp next_dict({string_before, string_after}, locale, options) do
265326
<<next::utf8, rest::binary>> = string_after
327+
328+
if Dictionary.dictionary_script?(next, locale) do
329+
next_dict_word({string_before, string_after}, {next, rest}, locale, options)
330+
else
331+
# The end of the dictionary script run. The standard rules take over from
332+
# here so the dictionary must not consume any further.
333+
{string_before, string_after}
334+
end
335+
end
336+
337+
defp next_dict_word({string_before, string_after}, {next, rest}, locale, options) do
266338
word = string_before <> <<next::utf8>>
267339

268340
case Dictionary.find_prefix(word, locale) do

lib/unicode/dictionary.ex

Lines changed: 122 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)