Packages

Unicode locale-aware case folding, case mapping (upcase, downcase and titlecase) case-insensitive equality as well as word, line, grapheme and sentence breaking and streaming.

Current section

Files

Jump to
unicode_string lib unicode dictionary_break.ex
Raw

lib/unicode/dictionary_break.ex

defmodule Unicode.String.DictionaryBreak do
@moduledoc """
Implements ICU's lookahead-based dictionary word break algorithm
for scripts that don't use spaces between words.
This module handles word segmentation for Thai, Lao, Khmer, and
Burmese (Myanmar) using the same approach as ICU's
`DictionaryBreakEngine`: a 3-word lookahead with fallback for
non-dictionary character sequences.
The algorithm works by:
1. At each position, gathering all dictionary word candidates
(shortest to longest match).
2. When exactly one candidate exists, accepting it immediately.
3. When multiple candidates exist, using a 3-word lookahead to
select the candidate that leads to the best overall
segmentation — preferring the candidate where subsequent
words are also found in the dictionary.
4. When no dictionary word follows a short word, scanning
forward through non-dictionary characters until finding a
position where dictionary words resume, then combining the
non-dictionary stretch with the preceding word.
5. Absorbing combining marks (Unicode General Category M) into
the preceding word so that vowel signs, tone marks, and
virama/coeng characters stay attached to their base.
"""
alias Unicode.String.Dictionary
@root_combine_threshold 3
@prefix_combine_threshold 3
@min_word_span 4
# ── Script-specific character sets ──────────────────────────
# Characters that can begin a word
@thai_begin_word_chars Enum.to_list(0x0E01..0x0E2E) ++ Enum.to_list(0x0E40..0x0E44)
@lao_begin_word_chars Enum.to_list(0x0E81..0x0EAE) ++
Enum.to_list(0x0EDC..0x0EDD) ++ Enum.to_list(0x0EC0..0x0EC4)
@khmer_begin_word_chars Enum.to_list(0x1780..0x17B3)
@burmese_begin_word_chars Enum.to_list(0x1000..0x102A)
# Characters that can end a word (used to detect plausible
# word boundaries when scanning non-dictionary text).
# Thai: all Thai SA characters minus above-vowels
@thai_end_word_exclude [0x0E31 | Enum.to_list(0x0E40..0x0E44)]
# Khmer: all Khmer SA characters minus coeng (U+17D2)
@khmer_end_word_exclude [0x17D2]
# Thai suffix characters
@thai_paiyannoi 0x0E2F
@thai_maiyamok 0x0E46
# ── Public API ──────────────────────────────────────────────
@doc """
Splits a string into word segments using dictionary-based
lookahead for the given locale.
Returns a list of word segments (strings). Unlike the simple
greedy algorithm, this considers multiple word candidates at
each position and uses a 3-word lookahead to select the
segmentation that produces the most dictionary-valid sequence.
### Arguments
* `string` is a binary string to segment.
* `locale` is a dictionary locale atom (`:th`, `:lo`, `:km`,
or `:my`).
### Returns
* A list of binary strings representing word segments.
"""
@spec split(String.t(), atom()) :: [String.t()]
def split("", _locale), do: []
def split(string, locale) do
codepoints = String.to_charlist(string)
len = length(codepoints)
if len < @min_word_span do
[string]
else
{:ok, dict_locale} = Dictionary.dictionary_locale(locale)
breaks = find_breaks(codepoints, 0, len, dict_locale, locale)
extract_segments(string, breaks)
end
end
@doc """
Splits a string using the dictionary break algorithm for
target-script ranges and a fallback function for other ranges.
Text is partitioned into ranges belonging to the locale's
script and ranges that don't. Dictionary breaking is applied
to the former; `fallback_fn` is called on the latter.
"""
@spec split_with_fallback(String.t(), atom(), (String.t() -> [String.t()])) :: [String.t()]
def split_with_fallback("", _locale, _fallback_fn), do: []
def split_with_fallback(string, locale, fallback_fn) do
script_range = script_range_for(locale)
{:ok, dict_locale} = Dictionary.dictionary_locale(locale)
string
|> partition_by_script(script_range)
|> Enum.flat_map(fn
{:dict, segment} -> split_dict_segment(segment, dict_locale, locale)
{:other, segment} -> fallback_fn.(segment)
end)
end
# Segment a single run of dictionary-script characters, leaving very
# short runs intact.
defp split_dict_segment(segment, dict_locale, locale) do
codepoints = String.to_charlist(segment)
len = length(codepoints)
if len < @min_word_span do
[segment]
else
breaks = find_breaks(codepoints, 0, len, dict_locale, locale)
extract_segments(segment, breaks)
end
end
# Unicode script ranges for dictionary locales.
defp script_range_for(:th), do: 0x0E01..0x0E5B
defp script_range_for(:lo), do: 0x0E81..0x0EDF
defp script_range_for(:km), do: 0x1780..0x17FF
defp script_range_for(:my), do: 0x1000..0x109F
defp script_range_for(_), do: 0..0
# Partition a string into consecutive runs of target-script
# characters and non-target-script characters.
defp partition_by_script(string, script_range) do
string
|> String.to_charlist()
|> Enum.chunk_while(
{:init, []},
fn cp, {prev_type, acc} ->
type = if cp in script_range, do: :dict, else: :other
case prev_type do
:init ->
{:cont, {type, [cp]}}
^type ->
{:cont, {type, [cp | acc]}}
_ ->
segment = acc |> Enum.reverse() |> List.to_string()
{:cont, {prev_type, segment}, {type, [cp]}}
end
end,
fn
{:init, []} ->
{:cont, []}
{type, acc} ->
segment = acc |> Enum.reverse() |> List.to_string()
{:cont, {type, segment}, []}
end
)
end
# ── Core algorithm ──────────────────────────────────────────
# Find all word break positions in the codepoint list.
defp find_breaks(codepoints, pos, range_end, dict_locale, locale) do
find_breaks(codepoints, pos, range_end, dict_locale, locale, 0, [])
end
defp find_breaks(_codepoints, pos, range_end, _dict_locale, _locale, _words_found, breaks)
when pos >= range_end do
# Remove any break at the very end (it's implicit)
breaks
|> Enum.reverse()
|> Enum.reject(&(&1 >= range_end))
end
defp find_breaks(codepoints, pos, range_end, dict_locale, locale, words_found, breaks) do
# Gather all dictionary word candidates at this position
candidates = dictionary_candidates(codepoints, pos, range_end, dict_locale)
{word_len, words_found} =
case candidates do
[] ->
# No dictionary word here
{0, words_found}
[single] ->
# Exactly one candidate — accept it
{single, words_found + 1}
multiple ->
# Multiple candidates — use lookahead to pick the best
best = lookahead_select(codepoints, pos, range_end, dict_locale, multiple)
{best, words_found + 1}
end
# Handle non-dictionary characters after a short or absent word
word_len = maybe_scan_to_resync(codepoints, pos, word_len, range_end, dict_locale, locale)
# Absorb combining marks
word_len = absorb_marks(codepoints, pos + word_len, range_end) - pos
# Absorb Thai suffix characters if applicable
word_len = maybe_absorb_suffix(codepoints, pos, word_len, range_end, dict_locale, locale)
# Record break position if we have a word
if word_len > 0 do
break_pos = pos + word_len
find_breaks(codepoints, break_pos, range_end, dict_locale, locale, words_found, [
break_pos | breaks
])
else
# Skip one codepoint and try again (shouldn't happen with
# proper resync, but a safety fallback)
find_breaks(codepoints, pos + 1, range_end, dict_locale, locale, words_found, breaks)
end
end
# After a short or absent dictionary word, scan forward over
# non-dictionary characters to the next point where dictionary words
# resume, extending the current word to absorb them.
defp maybe_scan_to_resync(codepoints, pos, word_len, range_end, dict_locale, locale)
when pos + word_len < range_end and word_len < @root_combine_threshold do
next_pos = pos + word_len
next_candidates = dictionary_candidates(codepoints, next_pos, range_end, dict_locale)
longest_prefix = longest_dictionary_prefix(codepoints, next_pos, range_end, dict_locale)
if next_candidates == [] and (word_len == 0 or longest_prefix < @prefix_combine_threshold) do
word_len + scan_to_resync(codepoints, next_pos, range_end, dict_locale, locale)
else
word_len
end
end
defp maybe_scan_to_resync(_codepoints, _pos, word_len, _range_end, _dict_locale, _locale) do
word_len
end
# ── Dictionary candidate gathering ─────────────────────────
# Returns a sorted list of word lengths (in codepoints) that
# are valid dictionary words starting at `pos`.
defp dictionary_candidates(codepoints, pos, range_end, dict_locale) do
gather_candidates(codepoints, pos, pos + 1, range_end, dict_locale, [])
end
defp gather_candidates(_codepoints, _start, current, range_end, _dict_locale, acc)
when current > range_end do
Enum.sort(acc)
end
defp gather_candidates(codepoints, start, current, range_end, dict_locale, acc) do
word = codepoints_to_string(codepoints, start, current)
case Dictionary.find_prefix(word, dict_locale) do
{:ok, _} ->
# Found a complete word — record its length and keep looking
# for longer matches
word_len = current - start
gather_candidates(codepoints, start, current + 1, range_end, dict_locale, [word_len | acc])
:prefix ->
# Prefix match — keep extending
gather_candidates(codepoints, start, current + 1, range_end, dict_locale, acc)
:error ->
# No match and no prefix — stop looking
Enum.sort(acc)
end
end
# ── 3-word lookahead ────────────────────────────────────────
# Given multiple candidate word lengths at `pos`, try each
# candidate and look ahead up to 2 more words to find the
# candidate that leads to the best segmentation.
defp lookahead_select(codepoints, pos, range_end, dict_locale, candidates) do
# Try candidates from longest to shortest (ICU order).
result =
candidates
|> Enum.reverse()
|> Enum.reduce_while(nil, fn candidate_len, _acc ->
lookahead_step(codepoints, pos, range_end, dict_locale, candidate_len)
end)
# If no candidate worked via lookahead, take the longest.
result || List.last(candidates)
end
# Evaluate a single candidate word length, looking ahead for a word 2
# (and, via `try_word3/5`, a word 3) that confirms it.
defp lookahead_step(codepoints, pos, range_end, dict_locale, candidate_len) do
next_pos = pos + candidate_len
if next_pos >= range_end do
# This candidate reaches the end — accept it.
{:halt, candidate_len}
else
word2_candidates = dictionary_candidates(codepoints, next_pos, range_end, dict_locale)
confirm_with_word3(
codepoints,
next_pos,
range_end,
dict_locale,
word2_candidates,
candidate_len
)
end
end
# No word 2 follows — try the next shorter candidate.
defp confirm_with_word3(_codepoints, _next_pos, _range_end, _dict_locale, [], _candidate_len) do
{:cont, nil}
end
# Word 2 exists; halt if a word 3 confirms it, otherwise keep it as the
# best-so-far while trying shorter candidates.
defp confirm_with_word3(
codepoints,
next_pos,
range_end,
dict_locale,
word2_candidates,
candidate_len
) do
if try_word3(codepoints, next_pos, range_end, dict_locale, word2_candidates) do
{:halt, candidate_len}
else
{:cont, candidate_len}
end
end
# Try to find a word 3 for any of the word 2 candidates
defp try_word3(codepoints, word1_end, range_end, dict_locale, word2_candidates) do
Enum.any?(word2_candidates, fn w2_len ->
word2_end = word1_end + w2_len
if word2_end >= range_end do
true
else
word3_candidates = dictionary_candidates(codepoints, word2_end, range_end, dict_locale)
word3_candidates != []
end
end)
end
# ── Non-dictionary character scanning ───────────────────────
# Scan forward from `pos` through non-dictionary characters
# until finding a position where dictionary words resume.
# Returns the number of codepoints consumed.
defp scan_to_resync(codepoints, pos, range_end, dict_locale, locale) do
scan_to_resync(codepoints, pos, pos, range_end, dict_locale, locale)
end
defp scan_to_resync(_codepoints, start, current, range_end, _dict_locale, _locale)
when current >= range_end do
current - start
end
defp scan_to_resync(codepoints, start, current, range_end, dict_locale, locale) do
if resync_point?(codepoints, start, current, range_end, dict_locale, locale) do
# Found a resync point — return characters consumed.
current - start
else
scan_to_resync(codepoints, start, current + 1, range_end, dict_locale, locale)
end
end
# A plausible word boundary where dictionary words resume: the previous
# char can end a word, the current char can begin one, and a dictionary
# word actually starts here.
defp resync_point?(_codepoints, start, current, _range_end, _dict_locale, _locale)
when current <= start do
false
end
defp resync_point?(codepoints, _start, current, range_end, dict_locale, locale) do
prev_cp = Enum.at(codepoints, current - 1)
curr_cp = Enum.at(codepoints, current)
can_end_word?(prev_cp, locale) and can_begin_word?(curr_cp, locale) and
dictionary_candidates(codepoints, current, range_end, dict_locale) != []
end
# ── Combining mark absorption ───────────────────────────────
# Advance past any combining marks (General Category M) at
# the given position.
defp absorb_marks(_codepoints, pos, range_end) when pos >= range_end, do: pos
defp absorb_marks(codepoints, pos, range_end) do
cp = Enum.at(codepoints, pos)
if combining_mark?(cp) do
absorb_marks(codepoints, pos + 1, range_end)
else
pos
end
end
defp combining_mark?(codepoint) do
Unicode.category(codepoint) in [:Mn, :Mc, :Me]
end
# ── Thai suffix absorption ──────────────────────────────────
defp maybe_absorb_suffix(codepoints, pos, word_len, range_end, :th, _locale)
when word_len > 0 and pos + word_len < range_end do
next_pos = pos + word_len
# Only absorb a suffix if no dictionary word follows.
if dictionary_candidates(codepoints, next_pos, range_end, :th) == [] do
next_cp = Enum.at(codepoints, next_pos)
prev_cp = Enum.at(codepoints, next_pos - 1)
thai_suffix_length(next_cp, prev_cp, word_len)
else
word_len
end
end
defp maybe_absorb_suffix(_codepoints, _pos, word_len, _range_end, _dict_locale, _locale) do
word_len
end
# PAIYANNOI and MAIYAMOK are absorbed into the preceding word unless
# they themselves follow another such suffix character.
defp thai_suffix_length(@thai_paiyannoi, prev_cp, word_len)
when prev_cp in [@thai_paiyannoi, @thai_maiyamok],
do: word_len
defp thai_suffix_length(@thai_paiyannoi, _prev_cp, word_len), do: word_len + 1
defp thai_suffix_length(@thai_maiyamok, @thai_maiyamok, word_len), do: word_len
defp thai_suffix_length(@thai_maiyamok, _prev_cp, word_len), do: word_len + 1
defp thai_suffix_length(_next_cp, _prev_cp, word_len), do: word_len
# ── Script-specific character set checks ────────────────────
# `can_begin_word?/2` is only ever evaluated as the right operand of
# `can_end_word?(prev_cp, locale) and can_begin_word?(curr_cp, locale)`
# in `scan_to_resync/6`. Reaching that operand implies `can_end_word?`
# returned true, which narrows `locale` to a dictionary locale, so a
# non-dictionary-locale catch-all clause here is unreachable.
defp can_begin_word?(nil, _locale), do: false
defp can_begin_word?(cp, :th), do: cp in @thai_begin_word_chars
defp can_begin_word?(cp, :lo), do: cp in @lao_begin_word_chars
defp can_begin_word?(cp, :km), do: cp in @khmer_begin_word_chars
defp can_begin_word?(cp, :my), do: cp in @burmese_begin_word_chars
defp can_end_word?(nil, _locale), do: false
defp can_end_word?(cp, :th) do
cp in 0x0E01..0x0E3A and cp not in @thai_end_word_exclude
end
defp can_end_word?(cp, :lo) do
cp in 0x0E81..0x0EDF and cp not in Enum.to_list(0x0EC0..0x0EC4)
end
defp can_end_word?(cp, :km) do
cp in 0x1780..0x17FF and cp not in @khmer_end_word_exclude
end
defp can_end_word?(cp, :my) do
cp in 0x1000..0x109F
end
defp can_end_word?(_cp, _locale), do: false
# ── Utility functions ───────────────────────────────────────
# Extract a substring from codepoints as a binary string.
defp codepoints_to_string(codepoints, start, stop) do
codepoints
|> Enum.slice(start, stop - start)
|> List.to_string()
end
# Find the length of the longest dictionary prefix at `pos`.
defp longest_dictionary_prefix(codepoints, pos, range_end, dict_locale) do
find_longest_prefix(codepoints, pos, pos + 1, range_end, dict_locale, 0)
end
defp find_longest_prefix(_codepoints, _start, current, range_end, _dict_locale, longest)
when current > range_end do
longest
end
defp find_longest_prefix(codepoints, start, current, range_end, dict_locale, longest) do
word = codepoints_to_string(codepoints, start, current)
case Dictionary.find_prefix(word, dict_locale) do
{:ok, _} ->
find_longest_prefix(
codepoints,
start,
current + 1,
range_end,
dict_locale,
current - start
)
:prefix ->
find_longest_prefix(codepoints, start, current + 1, range_end, dict_locale, longest)
:error ->
longest
end
end
# Convert a list of codepoint-based break positions into
# string segments.
defp extract_segments(string, []) do
if string == "", do: [], else: [string]
end
defp extract_segments(string, breaks) do
codepoints = String.to_charlist(string)
do_extract(codepoints, [0 | breaks] ++ [length(codepoints)])
end
defp do_extract(_codepoints, [_last]) do
[]
end
defp do_extract(codepoints, [start, stop | rest]) do
segment = codepoints_to_string(codepoints, start, stop)
if segment == "" do
do_extract(codepoints, [stop | rest])
else
[segment | do_extract(codepoints, [stop | rest])]
end
end
end