Current section
Files
Jump to
Current section
Files
lib/syllable.ex
defmodule Text.Syllable do
@moduledoc """
Syllable counting for English words.
Uses a vowel-group heuristic with a small table of known
exceptions. This is the same family of algorithm used by Python's
`textstat` and is accurate enough (~90%) for the readability
metrics in `Text.Readability`. For exact hyphenation points or
precise syllable boundaries, prefer `Text.Hyphenation`, which uses
Liang's algorithm with TeX hyphenation patterns.
Only English (`:en`) is supported at the moment. Other languages
will raise `ArgumentError`. Multilingual support will arrive when
`Text.Hyphenation` lands and this module can defer to its
pattern-based syllable boundaries.
"""
@vowels ~c"aeiouy"
@exceptions %{
"the" => 1,
"a" => 1,
"i" => 1,
"you" => 1,
"we" => 1,
"he" => 1,
"she" => 1,
"are" => 1,
"were" => 1,
"have" => 1,
"had" => 1,
"has" => 1,
"give" => 1,
"gave" => 1,
"love" => 1,
"live" => 1,
"lived" => 1,
"lives" => 1,
"moved" => 1,
"moves" => 1,
"people" => 2,
"every" => 2,
"everyone" => 3,
"everything" => 3,
"everywhere" => 3,
"everybody" => 4,
"business" => 2,
"interesting" => 3,
"different" => 3,
"vegetable" => 3,
"comfortable" => 3,
"family" => 3,
"literally" => 4,
"actually" => 4,
"usually" => 4,
"queue" => 1,
"queued" => 1,
"queueing" => 2,
"area" => 3,
"real" => 1,
"really" => 2,
"create" => 2,
"created" => 3,
"creating" => 3,
"ocean" => 2,
"idea" => 3,
"ideas" => 3,
"being" => 2,
"doing" => 2,
"going" => 2,
"seeing" => 2
}
@doc """
Returns the number of syllables in an English word.
### Arguments
* `word` is an English word as a string. Leading and trailing
non-letter characters are stripped before counting.
### Options
* `:language` is the language of the word. The default is `:en`.
Only `:en` is supported; other values raise `ArgumentError`.
### Returns
* A non-negative integer count of syllables. Returns `0` for an
empty string or a token containing no letters.
### Examples
iex> Text.Syllable.count("syllable")
3
iex> Text.Syllable.count("hello")
2
iex> Text.Syllable.count("queue")
1
iex> Text.Syllable.count("rhythm")
1
iex> Text.Syllable.count("")
0
"""
@spec count(String.t(), keyword()) :: non_neg_integer()
def count(word, options \\ []) when is_binary(word) do
case Keyword.get(options, :language, :en) do
:en ->
count_en(word)
other ->
raise ArgumentError, "Unsupported language #{inspect(other)}. Only :en is supported."
end
end
@doc """
Returns the total syllable count for a sentence or longer text.
Splits on whitespace and sums the syllable count of each token.
### Arguments
* `text` is a string containing zero or more words.
### Options
* `:language` is the language of the text. The default is `:en`.
### Returns
* A non-negative integer total syllable count across all tokens.
### Examples
iex> Text.Syllable.count_text("the quick brown fox")
4
iex> Text.Syllable.count_text("")
0
"""
@spec count_text(String.t(), keyword()) :: non_neg_integer()
def count_text(text, options \\ []) when is_binary(text) do
text
|> String.split()
|> Enum.reduce(0, fn token, acc -> acc + count(token, options) end)
end
defp count_en(word) do
cleaned = clean(word)
cond do
cleaned == "" ->
0
Map.has_key?(@exceptions, cleaned) ->
Map.fetch!(@exceptions, cleaned)
true ->
max(1, vowel_groups(cleaned))
end
end
defp clean(word) do
word
|> String.downcase()
|> String.to_charlist()
|> Enum.filter(&letter?/1)
|> List.to_string()
end
defp letter?(c) when c >= ?a and c <= ?z, do: true
defp letter?(_), do: false
defp vowel_groups(word) do
chars = String.to_charlist(word)
base = count_groups(chars, false, 0)
base + adjustments(chars)
end
defp count_groups([], _prev_vowel?, count), do: count
defp count_groups([c | rest], prev_vowel?, count) do
is_vowel = c in @vowels
cond do
is_vowel and not prev_vowel? -> count_groups(rest, true, count + 1)
is_vowel -> count_groups(rest, true, count)
true -> count_groups(rest, false, count)
end
end
# Adjustments after the basic vowel-group count.
defp adjustments(chars) do
silent_e_adjustment(chars) + le_ending_adjustment(chars)
end
# Trailing 'e' is normally silent in English. The '-le after consonant'
# case still subtracts here; le_ending_adjustment adds back the syllabic
# 'l'. Net effect: -le words like "table" stay at 2 syllables.
defp silent_e_adjustment(chars) do
case List.last(chars) do
?e -> -1
_ -> 0
end
end
# Add a syllable for words ending in '-le' where the 'l' is preceded by
# a consonant: "table" → 2, "candle" → 2, but "scale" → 1.
defp le_ending_adjustment(chars) do
if le_ending?(Enum.reverse(chars)), do: 1, else: 0
end
defp le_ending?([?e, ?l, c | _]) when c not in @vowels, do: true
defp le_ending?(_), do: false
end