Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions epitran/_epitran.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@
logger = logging.getLogger('epitran')
logger.setLevel(logging.WARNING)

# Language-script pairs that use specialized backends instead of SimpleEpitran.
# Each key is an ISO 639-3 + ISO 15924 code; value is the backend class.
SPECIAL_LANGUAGE_BACKENDS = {
'eng-Latn': FliteLexLookup,
'cmn-Hans': Epihan,
'cmn-Hant': EpihanTraditional,
'jpn-Jpan': EpiJpan,
'yue-Hant': EpiCanto,
}


class Epitran(object):
"""Unified interface for IPA transliteration/transcription

Expand All @@ -23,12 +34,9 @@ class Epitran(object):
:param rev_preproc bool: if True, apply preprocessors when reverse transliterating
:param rev_postproc bool: if True, apply postprocessors when reverse transliterating
"""
special = {'eng-Latn': FliteLexLookup,
'cmn-Hans': Epihan,
'cmn-Hant': EpihanTraditional,
'jpn-Jpan': EpiJpan,
'yue-Hant': EpiCanto,
}

# Backward-compatible alias
special = SPECIAL_LANGUAGE_BACKENDS

def __init__(self, code: str, **kwargs):
"""Constructor method
Expand All @@ -45,8 +53,8 @@ def __init__(self, code: str, **kwargs):
rev_postproc (bool): if True, apply postprocessor when reverse transliterating (default: True)
tones (bool): if True, include tone information (default: False)
"""
if code in self.special:
self.epi = self.special[code](**kwargs)
if code in SPECIAL_LANGUAGE_BACKENDS:
self.epi = SPECIAL_LANGUAGE_BACKENDS[code](**kwargs)
else:
self.epi = SimpleEpitran(code, **kwargs)
self.ft = panphon.featuretable.FeatureTable()
Expand Down
2 changes: 2 additions & 0 deletions epitran/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Adapters for Regrets regression testing
# These wrap instance methods into standalone functions that Regrets can call.
12 changes: 12 additions & 0 deletions epitran/adapters/deu_transliterate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Adapter: German (deu-Latn) transliteration for Regrets."""
from epitran.simple import SimpleEpitran

_epi = SimpleEpitran('deu-Latn', preproc=True, postproc=True, ligatures=False)

def transliterate(text, normpunc=False, ligatures=False):
"""Transliterate German text to IPA."""
return _epi.transliterate(text, normpunc, ligatures)

def general_trans(text, filter_func, normpunc=False, ligatures=False):
"""General transliteration with filter function."""
return _epi.general_trans(text, filter_func, normpunc, ligatures)
12 changes: 12 additions & 0 deletions epitran/adapters/fra_transliterate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Adapter: French (fra-Latn) transliteration for Regrets."""
from epitran.simple import SimpleEpitran

_epi = SimpleEpitran('fra-Latn', preproc=True, postproc=True, ligatures=False)

def transliterate(text, normpunc=False, ligatures=False):
"""Transliterate French text to IPA."""
return _epi.transliterate(text, normpunc, ligatures)

def general_trans(text, filter_func, normpunc=False, ligatures=False):
"""General transliteration with filter function."""
return _epi.general_trans(text, filter_func, normpunc, ligatures)
8 changes: 8 additions & 0 deletions epitran/adapters/puncnorm_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Adapter: PuncNorm for Regrets."""
from epitran.puncnorm import PuncNorm

_pn = PuncNorm()

def norm(text):
"""Normalize punctuation in text."""
return _pn.norm(text)
8 changes: 8 additions & 0 deletions epitran/adapters/rules_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Adapter: Rules for Regrets — empty rules for basic test."""
from epitran.rules import Rules

_rules = Rules([])

def apply(text):
"""Apply context-sensitive rules to text."""
return _rules.apply(text)
12 changes: 12 additions & 0 deletions epitran/adapters/spa_strict_trans.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Adapter: Spanish strict transliteration for Regrets."""
from epitran.simple import SimpleEpitran

_epi = SimpleEpitran('spa-Latn', preproc=True, postproc=True, ligatures=False)

def strict_trans(text, normpunc=False, ligatures=False):
"""Strict transliteration — unmapped characters omitted."""
return _epi.strict_trans(text, normpunc, ligatures)

def general_trans(text, filter_func, normpunc=False, ligatures=False):
"""General transliteration with filter function."""
return _epi.general_trans(text, filter_func, normpunc, ligatures)
12 changes: 12 additions & 0 deletions epitran/adapters/spa_transliterate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Adapter: Spanish (spa-Latn) transliteration for Regrets."""
from epitran.simple import SimpleEpitran

_epi = SimpleEpitran('spa-Latn', preproc=True, postproc=True, ligatures=False)

def transliterate(text, normpunc=False, ligatures=False):
"""Transliterate Spanish text to IPA."""
return _epi.transliterate(text, normpunc, ligatures)

def general_trans(text, filter_func, normpunc=False, ligatures=False):
"""General transliteration with filter function."""
return _epi.general_trans(text, filter_func, normpunc, ligatures)
8 changes: 8 additions & 0 deletions epitran/adapters/spa_word_to_tuples.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Adapter: Spanish word_to_tuples for Regrets."""
from epitran.simple import SimpleEpitran

_epi = SimpleEpitran('spa-Latn', preproc=True, postproc=True, ligatures=False)

def word_to_tuples(text, normpunc=False):
"""Word to tuples — detailed segment analysis."""
return _epi.word_to_tuples(text, normpunc)
8 changes: 8 additions & 0 deletions epitran/adapters/strip_diacritics_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Adapter: StripDiacritics for spa-Latn for Regrets."""
from epitran.stripdiacritics import StripDiacritics

_sd = StripDiacritics('spa-Latn')

def process(word):
"""Strip specified diacritics from text."""
return _sd.process(word)
12 changes: 12 additions & 0 deletions epitran/adapters/xsampa_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Adapter: XSampa for Regrets."""
from epitran.xsampa import XSampa

_xs = XSampa()

def ipa2xs(ipa):
"""Convert IPA string to X-SAMPA."""
return _xs.ipa2xs(ipa)

def longest_prefix(s):
"""Find longest matching prefix in trie."""
return _xs.longest_prefix(s)
34 changes: 23 additions & 11 deletions epitran/ligaturize.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,34 @@
# -*- coding: utf-8 -*-

# Mapping from decomposed affricate sequences to precomposed ligature characters.
# Each tuple maps an IPA affricate (e.g., t͡s) to its precomposed form (e.g., ʦ).
AFFRICATE_LIGATURES = (
(u't͡s', u'ʦ'),
(u't͡ʃ', u'ʧ'),
(u't͡ɕ', u'ʨ'),
(u'd͡z', u'ʣ'),
(u'd͡ʒ', u'ʤ'),
(u'd͡ʑ', u'ʥ'),
)


def ligaturize(text: str) -> str:
"""Convert text to employ non-standard ligatures
def convert_affricates_to_ligatures(text: str) -> str:
"""Convert IPA affricate sequences to their precomposed ligature forms.

Replaces decomposed affricate representations (e.g., "t͡s") with their
precomposed Unicode ligatures (e.g., "ʦ"). This is useful for conventions
that prefer compact affricate symbols over the decomposed form.

Args:
text (str): IPA text to Convert
text (str): IPA text potentially containing decomposed affricates

Return:
str: non-standard IPA text with phonetic ligatures for affricates
str: IPA text with affricates converted to precomposed ligatures
"""
mapping = [(u't͡s', u'ʦ'),
(u't͡ʃ', u'ʧ'),
(u't͡ɕ', u'ʨ'),
(u'd͡z', u'ʣ'),
(u'd͡ʒ', u'ʤ'),
(u'd͡ʑ', u'ʥ'),]
for from_, to_ in mapping:
for from_, to_ in AFFRICATE_LIGATURES:
text = text.replace(from_, to_)
return text


# Backward-compatible alias
ligaturize = convert_affricates_to_ligatures
15 changes: 9 additions & 6 deletions epitran/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ def _read_rule_file(self, rule_file: Union[str, Path]) -> List[Callable[[str], s
rules.append(self._read_rule(i, line))
return [rule for rule in rules if rule is not None]

def _sub_symbols(self, line: str) -> str:
def _expand_symbol_references(self, line: str) -> str:
"""Replace ::symbol:: references with their defined values."""
while re.search(r'::\w+::', line):
s = re.search(r'::\w+::', line).group(0)
if s in self.symbols:
Expand All @@ -73,7 +74,7 @@ def _read_rule(self, i: int, line: str) -> Optional[Callable[[str], str]]:
if s:
self.symbols[s.group('symbol')] = s.group('value')
else:
line = self._sub_symbols(line)
line = self._expand_symbol_references(line)
r = re.match(r'(\S+)\s*->\s*(\S+)\s*/\s*(\S*)\s*[_]\s*(\S*)', line)
try:
a, b, X, Y = r.groups()
Expand All @@ -83,14 +84,15 @@ def _read_rule(self, i: int, line: str) -> Optional[Callable[[str], str]]:
a, b = a.replace('0', ''), b.replace('0', '')
try:
if re.search(r'[?]P[<]sw1[>].+[?]P[<]sw2[>]', a):
return self._fields_to_function_metathesis(a, X, Y)
return self._compile_metathesis_rule(a, X, Y)
else:
return self._fields_to_function(a, b, X, Y)
return self._compile_replacement_rule(a, b, X, Y)
except Exception as e:
raise DatafileError('Line {}: "{}" cannot be compiled as regex: ̪{}'.format(i + 1, line, e))
return None

def _fields_to_function_metathesis(self, a: str, X: str, Y: str) -> Callable[[str], str]:
def _compile_metathesis_rule(self, a: str, X: str, Y: str) -> Callable[[str], str]:
"""Compile a metathesis (swap) rule: swap two captured groups within context."""
left = r'(?P<X>{}){}(?P<Y>{})'.format(X, a, Y)
regexp = re.compile(left)

Expand All @@ -100,7 +102,8 @@ def rewrite(m: Any) -> str:

return lambda w: regexp.sub(rewrite, w, re.U)

def _fields_to_function(self, a: str, b: str, X: str, Y: str) -> Callable[[str], str]:
def _compile_replacement_rule(self, a: str, b: str, X: str, Y: str) -> Callable[[str], str]:
"""Compile a context-sensitive replacement rule into a regex substitution."""
left = r'(?P<X>{})(?P<a>{})(?P<Y>{})'.format(X, a, Y)
regexp = re.compile(left)

Expand Down
41 changes: 25 additions & 16 deletions epitran/simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ def __init__(self, code: str, **kwargs):
tones = kwargs.get('tones', False)
self.rev = rev
self.tones = tones
self.g2p = self._load_g2p_map(code, False)
self.regexp = self._construct_regex(self.g2p.keys())
self.g2p = self._load_grapheme_to_phoneme_map(code, False)
self.regexp = self._build_greedy_match_regex(self.g2p.keys())
self.puncnorm = PuncNorm()
self.ft = panphon.FeatureTable()
self.num_panphon_fts = len(self.ft.names)
Expand All @@ -72,8 +72,8 @@ def __init__(self, code: str, **kwargs):
self.rev_preproc = rev_preproc
self.rev_postproc = rev_postproc
if rev:
self.rev_g2p = self._load_g2p_map(code, True)
self.rev_regexp = self._construct_regex(self.rev_g2p.keys())
self.rev_g2p = self._load_grapheme_to_phoneme_map(code, True)
self.rev_regexp = self._build_greedy_match_regex(self.rev_g2p.keys())
self.rev_preprocessor = PrePostProcessor(code, 'pre', True)
self.rev_postprocessor = PrePostProcessor(code, 'post', True)

Expand Down Expand Up @@ -101,13 +101,14 @@ def __exit__(self, _type_: Any, _val: Any, _trace_back: Any) -> None:
# return (g, ls)
# return ("", [])

def _non_deterministic_mappings(self, gr_by_line: "dict[str, list[int]]") -> "list[tuple[str, list[int]]]":
def _find_ambiguous_mappings(self, gr_by_line: "dict[str, list[int]]") -> "list[tuple[str, list[int]]]":
"""Find graphemes that map to multiple phonemes (one-to-many mappings)."""
return [(g, ls) for (g, ls) in gr_by_line.items() if len(ls) > 1]

def _load_g2p_map(self, code: str, rev: bool) -> "DefaultDict[str, list[str]]":
"""Load the code table for the specified language.
def _load_grapheme_to_phoneme_map(self, code: str, rev: bool) -> "DefaultDict[str, list[str]]":
"""Load the grapheme-to-phoneme mapping table for the specified language.

:param code str: ISO 639-3 code plus "-" plus ISO 15924 code for the language/script to be loaded
:param code str: ISO 639-3 code plus "-" plus ISO 15924 code for the language/script to be loaded
:param rev bool: If True, reverse the table (for reverse transliterating)
:return: A mapping from graphemes to phonemes
:rtype: DefaultDict[str, list[str]]
Expand Down Expand Up @@ -138,7 +139,7 @@ def _load_g2p_map(self, code: str, rev: bool) -> "DefaultDict[str, list[str]]":
except (FileNotFoundError, IndexError) as malformed_data_file:
raise DatafileError(
'Add an appropriately-named mapping to the data/maps directory.') from malformed_data_file
nondeterminisms = self._non_deterministic_mappings(gr_by_line)
nondeterminisms = self._find_ambiguous_mappings(gr_by_line)
if nondeterminisms:
message = ""
for graph, lines in nondeterminisms:
Expand All @@ -157,9 +158,10 @@ def _load_punc_norm_map(self) -> "dict[str, str]":
next(reader)
return {punc: norm for (punc, norm) in reader}

def _construct_regex(self, g2p_keys: Any) -> Any:
"""Build a regular expression that will greadily match segments from
the mapping table.
def _build_greedy_match_regex(self, g2p_keys: Any) -> Any:
"""Build a regular expression that will greedily match segments from
the mapping table. Longer graphemes are tried first to ensure
maximal munch tokenization.
"""
graphemes = sorted(g2p_keys, key=len, reverse=True)
return regex.compile(f"({r'|'.join(graphemes)})", regex.I)
Expand Down Expand Up @@ -213,14 +215,21 @@ def general_trans(self, text: str, filter_func: "Callable[[tuple[str, bool]], bo
text = self.puncnorm.norm(text)
return unicodedata.normalize('NFC', text)

# Korean exception handling in transliterate
def is_korean(self, text: str) -> bool:
"""Check if the text contains any Korean characters."""
def contains_korean_syllables(self, text: str) -> bool:
"""Check if the text contains any Korean Hangul syllable characters.

Detects characters in the Hangul Syllables Unicode block (U+AC00–U+D7A3),
which covers precomposed Korean syllables. Jamo components are not detected.
"""
for char in text:
if '\uAC00' <= char <= '\uD7A3': # Checking Korean Unicode
return True
return False

def is_korean(self, text: str) -> bool:
"""Deprecated: Use contains_korean_syllables() instead."""
return self.contains_korean_syllables(text)

def transliterate(self, text: str, normpunc: bool = False, ligatures: bool = False) -> str:
"""Transliterates/transcribes a word into IPA. Passes unmapped
characters through to output unchanged.
Expand All @@ -234,7 +243,7 @@ def transliterate(self, text: str, normpunc: bool = False, ligatures: bool = Fal
:rtype: str
"""
try:
if self.is_korean(text):
if self.contains_korean_syllables(text):
text = j2hcj(h2j(text))
except Exception as e:
print(f"Error during Korean transliteration: {e}")
Expand Down
17 changes: 11 additions & 6 deletions epitran/stripdiacritics.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,22 @@ def _read_diacritics(self, code: str) -> List[str]:
pass
return diacritics

def process(self, word: str) -> str:
"""Remove diacritics from an input string
def strip_specified_diacritics(self, word: str) -> str:
"""Remove language-specific diacritics from an input string.

Strips only the diacritics specified in the language's strip file,
leaving all other characters (including other diacritics) untouched.

Args:
word (str): Unicode IPA string

Returns:
str: Unicode IPA string with specified diacritics
removed
str: Unicode IPA string with specified diacritics removed
"""
# word = unicodedata.normalize('NFD', word)
word = ''.join(filter(lambda x: x not in self.diacritics, word))
# return unicodedata.normalize('NFC', word)
return word

# Backward-compatible alias
def process(self, word: str) -> str:
"""Deprecated: Use strip_specified_diacritics() instead."""
return self.strip_specified_diacritics(word)
Loading