Packages

A library and Mix task for pseudolocalizing Gettext translation files

Current section

Files

Jump to
pseudoloc lib pseudoloc.ex
Raw

lib/pseudoloc.ex

defmodule Pseudoloc do
@moduledoc """
Creates a [pseudolocalized](https://en.wikipedia.org/wiki/Pseudolocalization) translation of
`Gettext` data files.
Because this module is designed to work with `Gettext`, it specifically ignores
[interpolated](https://hexdocs.pm/gettext/Gettext.html#module-interpolation) sections of the
strings it localizes.
"""
@interpolation_pattern ~r/%\{[^}\s\t\n]+\}/
@typedoc """
A mapping of individual graphemes to a list of alternate representations.
Both the key and all entries in the value list should be a single character.
## Examples
```
%{
"a" => ["à", "á", "å"],
"b" => ["ḅ"]
}
```
"""
@type alternates :: %{optional(String.t()) => list(String.t())}
@typedoc """
Represents a range of text within a string by starting index and length.
"""
@type range :: {non_neg_integer, non_neg_integer}
@doc """
Gets the ranges within the text that need to be localized.
In other words, the ranges of the text that are not interpolations.
Returns a list of tuples containing the index and length of each range to be localized.
## Examples
A string with no interpolations:
```
iex> Pseudoloc.get_localizable_ranges("foo")
[{0, 3}]
```
A string consisting of only interpolations:
```
iex> Pseudoloc.get_localizable_ranges("%{foo}")
[]
```
A string containing multiple interpolations:
```
iex> Pseudoloc.get_localizable_ranges("foo%{bar}baz%{quux}quuux")
[{0, 3}, {9, 3}, {19, 5}]
```
"""
@spec get_localizable_ranges(String.t()) :: [range]
def get_localizable_ranges(text) do
interpolation_ranges = Regex.scan(@interpolation_pattern, text, return: :index)
do_get_ranges(text, 0, interpolation_ranges, [])
end
@doc """
Localizes the `grapheme` if there are valid `alternates`.
## Examples
Returns the grapheme unchanged if there are no alternates:
```
iex> Pseudoloc.localize_grapheme("a", %{"b" => ["ḅ"]})
"a"
```
Returns a random alternative if they exist:
```
iex> Pseudoloc.localize_grapheme("a", %{"a" => ["α"]})
"α"
```
```
iex> alts = ["1", "2", "3"]
iex> Pseudoloc.localize_grapheme("a", %{"a" => alts}) in alts
true
```
"""
@spec localize_grapheme(String.t(), alternates) :: String.t()
def localize_grapheme(grapheme, alternates) do
case Map.has_key?(alternates, grapheme) do
false -> grapheme
true -> Enum.random(alternates[grapheme])
end
end
@doc """
Localizes `text` within the `range` with the `alternates`.
## Examples
```
iex> Pseudoloc.localize_range("foo", {1, 1}, %{"o" => ["ṓ"]})
"fṓo"
```
"""
@spec localize_range(String.t(), range, alternates) :: String.t()
def localize_range(text, range, alternates)
def localize_range(text, {_start, length}, _alternates) when length <= 0, do: text
def localize_range(text, {start, length}, alternates) do
range = Range.new(start, start + length - 1)
{_, result} =
Enum.reduce(range, {:cont, text}, fn elem, {_, text} ->
{:cont, localize_grapheme_at(text, elem, alternates)}
end)
result
end
@doc """
Localizes `text` with the default alternates.
See `localize_string/2` for details.
"""
@spec localize_string(String.t()) :: String.t()
def localize_string(text), do: localize_string(text, default_alternates())
@doc """
Localizes `text` with the given `alternates`.
## Examples
Localizing the non-interpolated sections of a string:
```
iex> alternates = %{"a" => ["α"], "f" => ["ϝ"], "u" => ["ṵ"]}
iex> text = "foo%{bar}baz%{quux}quuux"
iex> Pseudoloc.localize_string(text, alternates)
"ϝoo%{bar}bαz%{quux}qṵṵṵx"
```
"""
@spec localize_string(String.t(), alternates) :: String.t()
def localize_string(text, alternates) do
ranges = get_localizable_ranges(text)
{_, result} =
Enum.reduce(ranges, {:cont, text}, fn range, {_, text} ->
{:cont, localize_range(text, range, alternates)}
end)
result
end
# ----- Private functions -----
defp cleanup_ranges(ranges) do
ranges
|> Enum.reverse()
|> Enum.reject(fn elem -> match?({_, 0}, elem) end)
end
defp default_alternates do
{map, _} = Code.eval_file(Path.join(:code.priv_dir(:pseudoloc), "alternates.exs"))
map
end
defp do_get_ranges(text, last_pos, interpolation_ranges, translate_ranges)
defp do_get_ranges(text, last_pos, [], translate_ranges) do
result =
if last_pos < String.length(text) do
[{last_pos, String.length(text) - last_pos} | translate_ranges]
else
translate_ranges
end
cleanup_ranges(result)
end
defp do_get_ranges(text, last_pos, [head | tail], translate_ranges) do
[{start, length}] = head
do_get_ranges(text, start + length, tail, [{last_pos, start - last_pos} | translate_ranges])
end
defp localize_grapheme_at(text, at, alternates) do
before_text = String.slice(text, 0, at)
after_text = String.slice(text, at + 1, String.length(text))
Enum.join([before_text, localize_grapheme(String.at(text, at), alternates), after_text])
end
end