scriptconv 0.0.4a4__tar.gz → 0.0.4a6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {scriptconv-0.0.4a4/scriptconv.egg-info → scriptconv-0.0.4a6}/PKG-INFO +2 -1
  2. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/pyproject.toml +1 -1
  3. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/__init__.py +2 -0
  4. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/cangjie.py +8 -7
  5. scriptconv-0.0.4a6/scriptconv/diacritics.py +319 -0
  6. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/graph.py +9 -1
  7. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/notation.py +9 -4
  8. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/hangul2ipa.py +41 -15
  9. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_vendored/kog2p/__init__.py +11 -1
  10. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/base.py +4 -152
  11. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/en.py +3 -4
  12. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/fa.py +8 -1
  13. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/gl.py +5 -3
  14. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/he.py +8 -1
  15. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/registry.py +4 -6
  16. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/zh.py +48 -8
  17. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/scripts.py +4 -0
  18. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/version.py +1 -1
  19. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6/scriptconv.egg-info}/PKG-INFO +2 -1
  20. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv.egg-info/SOURCES.txt +1 -0
  21. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv.egg-info/requires.txt +1 -0
  22. scriptconv-0.0.4a6/tests/test_diacritics.py +201 -0
  23. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/tests/test_diacritics_graph.py +1 -2
  24. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/tests/test_phonemizers_base.py +0 -100
  25. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/tests/test_phonemizers_cjk_ar.py +59 -0
  26. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/tests/test_scripts.py +12 -0
  27. scriptconv-0.0.4a4/scriptconv/diacritics.py +0 -153
  28. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/LICENSE +0 -0
  29. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/README.md +0 -0
  30. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/requirements.txt +0 -0
  31. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/__main__.py +0 -0
  32. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/conventions.py +0 -0
  33. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/data/__init__.py +0 -0
  34. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/data/cangjie5_tc.tsv.gz +0 -0
  35. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/__init__.py +0 -0
  36. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/__init__.py +0 -0
  37. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/bw2ipa.py +0 -0
  38. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/ko_tables/aspiration.csv +0 -0
  39. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/ko_tables/assimilation.csv +0 -0
  40. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/ko_tables/double_coda.csv +0 -0
  41. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/ko_tables/hanja.tsv +0 -0
  42. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/ko_tables/ipa.csv +0 -0
  43. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/ko_tables/neutralization.csv +0 -0
  44. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/ko_tables/tensification.csv +0 -0
  45. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/ko_tables/yale.csv +0 -0
  46. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/shami/__init__.py +0 -0
  47. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/shami/codeswitch.py +0 -0
  48. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/shami/diacritize.py +0 -0
  49. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/shami/dialectal.py +0 -0
  50. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/shami/english_g2p.py +0 -0
  51. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/shami/espeak.py +0 -0
  52. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/shami/frontend.py +0 -0
  53. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/shami/levantine_g2p.py +0 -0
  54. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/shami/normalize.py +0 -0
  55. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/shami/phoneme_inventory.py +0 -0
  56. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_thirdparty/zh_num.py +0 -0
  57. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_vendored/__init__.py +0 -0
  58. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_vendored/kog2p/LICENSE.md +0 -0
  59. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_vendored/kog2p/rulebook.txt +0 -0
  60. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_vendored/mantoq/LICENSE.md +0 -0
  61. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_vendored/mantoq/__init__.py +0 -0
  62. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_vendored/mantoq/buck/__init__.py +0 -0
  63. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_vendored/mantoq/buck/phonetise_buckwalter.py +0 -0
  64. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_vendored/mantoq/buck/symbols.py +0 -0
  65. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_vendored/mantoq/buck/tokenization.py +0 -0
  66. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_vendored/mantoq/num2words.py +0 -0
  67. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/_vendored/mantoq/unicode_symbol2label.py +0 -0
  68. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/ar.py +0 -0
  69. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/enums.py +0 -0
  70. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/eu.py +0 -0
  71. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/ja.py +0 -0
  72. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/ko.py +0 -0
  73. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/mul.py +0 -0
  74. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/mwl.py +0 -0
  75. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/o2ipa.py +0 -0
  76. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/pt.py +0 -0
  77. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/shami.py +0 -0
  78. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/phonemizers/vi.py +0 -0
  79. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/py.typed +0 -0
  80. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/readings.py +0 -0
  81. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv/translit.py +0 -0
  82. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv.egg-info/dependency_links.txt +0 -0
  83. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/scriptconv.egg-info/top_level.txt +0 -0
  84. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/setup.cfg +0 -0
  85. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/tests/test_arpa_stress.py +0 -0
  86. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/tests/test_cangjie.py +0 -0
  87. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/tests/test_cli.py +0 -0
  88. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/tests/test_conventions.py +0 -0
  89. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/tests/test_errors_policy.py +0 -0
  90. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/tests/test_examples.py +0 -0
  91. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/tests/test_graph.py +0 -0
  92. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/tests/test_notation.py +0 -0
  93. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/tests/test_readings.py +0 -0
  94. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/tests/test_readings_zh.py +0 -0
  95. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/tests/test_scripts_stressonnx_compat.py +0 -0
  96. {scriptconv-0.0.4a4 → scriptconv-0.0.4a6}/tests/test_translit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scriptconv
3
- Version: 0.0.4a4
3
+ Version: 0.0.4a6
4
4
  Summary: Zero-dependency script & phoneme-notation core — ISO-15924 detection & metadata, IPA↔ARPABET/X-SAMPA/Lexique/Kirshenbaum/Cotovía/RFE, Buckwalter↔Arabic, Hangul→jamo, kana
5
5
  License: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/TigreGotico/scriptconv
@@ -105,6 +105,7 @@ Requires-Dist: scriptconv[phonemizers]; extra == "en-phonemizers"
105
105
  Requires-Dist: g2p_en; extra == "en-phonemizers"
106
106
  Requires-Dist: openphonemizer; extra == "en-phonemizers"
107
107
  Requires-Dist: deep-phonemizer; extra == "en-phonemizers"
108
+ Requires-Dist: requests; extra == "en-phonemizers"
108
109
  Provides-Extra: ar-phonemizers
109
110
  Requires-Dist: scriptconv[phonemizers]; extra == "ar-phonemizers"
110
111
  Requires-Dist: arbtok; extra == "ar-phonemizers"
@@ -58,7 +58,7 @@ pt = ["scriptconv[phonemizers]", "bifonia>=0.1.1"]
58
58
  ja-phonemizers = ["scriptconv[phonemizers]", "pyopenjtalk", "cutlet", "pykakasi>=2.3,<3"]
59
59
  ko = ["scriptconv[phonemizers]", "g2pk", "regex"]
60
60
  zh-phonemizers = ["scriptconv[phonemizers]", "jieba", "pypinyin>=0.50,<1", "xpinyin", "g2pM", "pinyin_to_ipa"]
61
- en-phonemizers = ["scriptconv[phonemizers]", "g2p_en", "openphonemizer", "deep-phonemizer"]
61
+ en-phonemizers = ["scriptconv[phonemizers]", "g2p_en", "openphonemizer", "deep-phonemizer", "requests"]
62
62
  ar-phonemizers = ["scriptconv[phonemizers]", "arbtok", "ovos-number-parser"]
63
63
  shami = ["scriptconv[phonemizers]", "regex"]
64
64
  test = ["pytest", "pytest-timeout", "pykakasi>=2.3,<3", "pypinyin>=0.50,<1", "quebra-frases", "langcodes", "regex", "ovos-number-parser", "bifonia>=0.1.1"]
@@ -69,6 +69,7 @@ from scriptconv.notation import (
69
69
  ipa_to_cotovia,
70
70
  rfe_to_ipa,
71
71
  ipa_to_rfe,
72
+ mantoq_to_ipa,
72
73
  looks_like_ipa,
73
74
  )
74
75
  from scriptconv.translit import (
@@ -140,6 +141,7 @@ __all__ = [
140
141
  "ipa_to_cotovia",
141
142
  "rfe_to_ipa",
142
143
  "ipa_to_rfe",
144
+ "mantoq_to_ipa",
143
145
  "looks_like_ipa",
144
146
  # translit
145
147
  "decompose_hangul",
@@ -15,7 +15,7 @@ from __future__ import annotations
15
15
 
16
16
  import gzip
17
17
  from importlib.resources import files
18
- from typing import Dict, Optional
18
+ from typing import Dict, List, Optional
19
19
 
20
20
  __all__ = ["cangjie_code", "to_cangjie"]
21
21
 
@@ -49,15 +49,16 @@ def to_cangjie(text: str, sep: str = " ") -> str:
49
49
  """
50
50
  table = _table()
51
51
  tokens = []
52
- last_raw = False
52
+ raw_run: List[str] = []
53
53
  for ch in text:
54
54
  code = table.get(ch)
55
55
  if code is not None:
56
+ if raw_run:
57
+ tokens.append("".join(raw_run))
58
+ raw_run = []
56
59
  tokens.append(code)
57
- last_raw = False
58
- elif last_raw:
59
- tokens[-1] += ch
60
60
  else:
61
- tokens.append(ch)
62
- last_raw = True
61
+ raw_run.append(ch)
62
+ if raw_run:
63
+ tokens.append("".join(raw_run))
63
64
  return sep.join(tokens)
@@ -0,0 +1,319 @@
1
+ """Diacritization: mechanism, dispatch, and graph extension.
2
+
3
+ This module OWNS diacritization — lang→backend routing, lazy/cached backend
4
+ loading (phonikud, text2tashkeel, stressonnx, bifonia), and the overlay-strip
5
+ helpers. :mod:`scriptconv.phonemizers` (``BasePhonemizer`` and subclasses)
6
+ knows nothing about diacritics: phonemization (orthography → sound) and
7
+ diacritization (a text → text graph transform disambiguating pronunciation
8
+ before G2P) are separate concerns.
9
+
10
+ Diacritization (Arabic tashkeel, Hebrew niqqud, East-Slavic/Turkic/Caucasian
11
+ word stress, European-Portuguese homograph sense marks) is, architecturally,
12
+ just another transform between text representations: it maps the ``"text"``
13
+ node to a lang-contextual ``"text-diacritized"`` node. Like
14
+ :func:`scriptconv.phonemizers.register` it is opt-in — ``DEFAULT_GRAPH`` stays
15
+ free of it until a caller extends a graph::
16
+
17
+ from scriptconv.graph import DEFAULT_GRAPH
18
+ from scriptconv import diacritics, phonemizers
19
+ g = DEFAULT_GRAPH.extend(diacritics.register).extend(phonemizers.register)
20
+
21
+ g.convert("замок стоит на горе", "text", "text-diacritized", lang="ru")
22
+ # 'за́мок сто́ит на горе́' — just the diacritized text
23
+
24
+ g.convert("Tenho muita sede hoje.", "text", "text-diacritized", lang="pt")
25
+ # 'Tenho muita sêde hoje.'
26
+
27
+ Routing ``"text" -> "ipa"`` still takes the direct phonemization edge by
28
+ default: the diacritization edge is model-based (``lossless=False``), so it
29
+ never out-prices a direct phonemization, and enabling this extension does not
30
+ silently change phonemization output. The diacritized route is taken only
31
+ when a caller asks for ``"text-diacritized"`` explicitly.
32
+
33
+ A future per-engine *stance* would push this further into topology rather than
34
+ a flag: an engine that *requires* vocalized input would carry only a
35
+ ``"text-diacritized" -> "ipa"`` edge (forcing the detour), one that *forbids*
36
+ diacritics would carry only ``"text" -> "ipa"`` (making the detour
37
+ unroutable), and a tolerant engine (e.g. arbtok, which self-vocalizes) carries
38
+ both. The strip direction (``"text-diacritized" -> "text"``) is registered too, but
39
+ gated to languages whose marks are removable *overlay* diacritics —
40
+ Arabic/Hebrew vocalization and East-Slavic/Turkic/Caucasian stress. Strip
41
+ removes only the specific overlay codepoints each backend adds (combining
42
+ acute/grave for stress, tashkeel for Arabic, niqqud for Hebrew) — never a
43
+ blanket combining-mark filter — so precomposed native letters survive
44
+ (Cyrillic й/ё, Latvian macrons, Azerbaijani ç/ö, Arabic hamza carriers أ إ آ ؤ
45
+ ئ). The gate uses exact primary-subtag matching, so Aragonese (``arg``),
46
+ Herero (``her``), Mapudungun (``arn``) etc. are never misread as Arabic/Hebrew.
47
+ Languages whose diacritics are native orthography (European Portuguese, via
48
+ bifonia) refuse the strip with :class:`ValueError` instead of silently
49
+ corrupting the spelling (``café`` must never become ``cafe``). This split
50
+ tracks which backend/model produced the marks: overlay backends (phonikud,
51
+ tashkeel, stressonnx) are strippable; spelling-integral backends (bifonia) are
52
+ not.
53
+ """
54
+ from typing import Optional
55
+
56
+ from scriptconv.graph import Edge
57
+ from scriptconv.phonemizers.base import _primary_subtag
58
+
59
+ #: The lang-contextual node produced by diacritization. Like ``"text"`` it is
60
+ #: meaningful only with ``lang=`` context and exists only in opted-in graphs.
61
+ DIACRITIZED = "text-diacritized"
62
+
63
+ # Across East Slavic, Bulgarian/Macedonian/Slovene, Latvian, Armenian,
64
+ # Georgian, and several Turkic/Caucasian languages, lexical word stress is
65
+ # free (not fixed to a syllable) and ordinary orthography leaves it unwritten
66
+ # or under-marked. The clearest case is East Slavic: stress is also mobile
67
+ # (it shifts between forms of the same word) and unstressed vowels *reduce*
68
+ # — Russian unstressed "о" surfaces as [ɐ] or [ə] depending on distance from
69
+ # the stress, not [o] — so a wrong or missing mark there corrupts the vowel
70
+ # quality of the whole word, not just its prosody. Other families in this set
71
+ # don't necessarily reduce vowels, but still need the mark for correct stress
72
+ # placement and prosody. stressonnx restores it as a combining acute (U+0301)
73
+ # after the stressed vowel, covering 26 BCP-47 tags across these families
74
+ # (24 primary subtags; Azerbaijani and Uzbek each have Cyrillic/Latin script
75
+ # variants routed by the full tag).
76
+ STRESS_LANGS = {
77
+ "az", "ba", "be", "bg", "cv", "hy", "ka", "kbd", "kjh", "kk", "ky", "lv",
78
+ "mdf", "mk", "myv", "ru", "sah", "sl", "tg", "tt", "udm", "uk", "uz", "xal",
79
+ }
80
+
81
+
82
+ def _is_european_portuguese(lang: str) -> bool:
83
+ """True for European Portuguese (``pt`` or a ``pt-PT`` region tag).
84
+
85
+ False for Brazilian Portuguese (``pt-BR``) and everything else — the two
86
+ varieties' vowel systems differ, and bifonia's open/closed diacritics are
87
+ only valid for European Portuguese phonology.
88
+ """
89
+ norm = lang.lower().replace("_", "-")
90
+ return norm == "pt" or norm == "pt-pt"
91
+
92
+
93
+ def _diacritizer_family(lang: str) -> Optional[str]:
94
+ """Which diacritization backend family handles *lang*, or ``None``.
95
+
96
+ Single source of truth for lang→backend routing: both the forward
97
+ dispatch (:func:`diacritize`) and the strip direction (:func:`_overlay_marks`)
98
+ resolve through this, so the two can never disagree about which language
99
+ uses which backend. Returns one of ``"he"`` (niqqud), ``"ar"`` (tashkeel),
100
+ ``"stress"`` (stressonnx), ``"pt"`` (bifonia sense diacritics), or
101
+ ``None``. Uses exact primary-subtag matching (never ``startswith``), so
102
+ Aragonese (``arg``), Herero (``her``) and Mapudungun (``arn``) are never
103
+ misread as Arabic/Hebrew.
104
+ """
105
+ p = _primary_subtag(lang)
106
+ if p == "he":
107
+ return "he"
108
+ if p == "ar":
109
+ return "ar"
110
+ if p in STRESS_LANGS:
111
+ return "stress"
112
+ if _is_european_portuguese(lang):
113
+ return "pt"
114
+ return None
115
+
116
+
117
+ _DEFAULT_DIACRITIZER_MODEL = "rawi-ensemble"
118
+
119
+ _PHONIKUD_CACHE: dict = {} # resolved model path/string -> Phonikud instance
120
+ _TASHKEEL_CACHE: dict = {} # model name -> text2tashkeel Diacritizer
121
+
122
+
123
+ def _phonikud(phonikud_model):
124
+ """Lazily build (and cache) the phonikud Phonikud instance used for Hebrew.
125
+
126
+ ``phonikud_model`` is a local path to a phonikud ONNX model, or a
127
+ zero-arg callable resolving one lazily; scriptconv never downloads —
128
+ the consumer resolves the file. Install with ``pip install
129
+ scriptconv[he]`` (or ``pip install phonikud-onnx``)."""
130
+ model = phonikud_model() if callable(phonikud_model) else phonikud_model
131
+ if not model:
132
+ raise ValueError(
133
+ "Hebrew diacritization needs a local phonikud ONNX model: "
134
+ "pass phonikud_model=<path> (scriptconv never downloads "
135
+ "models; obtain one from the phonikud-onnx release)")
136
+ if model not in _PHONIKUD_CACHE:
137
+ try:
138
+ from phonikud_onnx import Phonikud
139
+ except ImportError:
140
+ raise ImportError(
141
+ "Hebrew diacritization needs phonikud-onnx — install "
142
+ "with `pip install scriptconv[he]`") from None
143
+ _PHONIKUD_CACHE[model] = Phonikud(model)
144
+ return _PHONIKUD_CACHE[model]
145
+
146
+
147
+ def _tashkeel(model: Optional[str] = None):
148
+ """Lazily build (and cache) the text2tashkeel Diacritizer used for Arabic.
149
+
150
+ text2tashkeel is a dependency of the ``[ar]`` extra; it restores hamza and the
151
+ dagger alef in addition to the standard marks. Install with
152
+ ``pip install scriptconv[tashkeel]`` (or ``pip install text2tashkeel``)."""
153
+ model = model or _DEFAULT_DIACRITIZER_MODEL
154
+ if model not in _TASHKEEL_CACHE:
155
+ try:
156
+ from text2tashkeel import Diacritizer
157
+ except ImportError as e:
158
+ raise ImportError(
159
+ "Arabic diacritization requires the text2tashkeel package: "
160
+ "pip install scriptconv[tashkeel] (or pip install text2tashkeel)"
161
+ ) from e
162
+ _TASHKEEL_CACHE[model] = Diacritizer(model)
163
+ return _TASHKEEL_CACHE[model]
164
+
165
+
166
+ def _stress(text: str, lang: str, model: Optional[str] = None) -> str:
167
+ """Word-stress restoration via stressonnx, for the 26 language tags
168
+ it covers (see ``STRESS_LANGS``) — East Slavic, Bulgarian/Macedonian/
169
+ Slovene, Latvian, Armenian, Georgian, and Turkic/Caucasian languages.
170
+
171
+ stressonnx is not on PyPI yet; install straight from source. Install
172
+ with ``pip install scriptconv[stress]`` (or ``pip install
173
+ stressonnx``)."""
174
+ try:
175
+ from stressonnx import stress
176
+ except ImportError as e:
177
+ raise ImportError(
178
+ "stress restoration requires the stressonnx package: "
179
+ "pip install scriptconv[stress] (or pip install stressonnx)"
180
+ ) from e
181
+ return stress(text, lang, model=model)
182
+
183
+
184
+ def _sense_diacritics_pt(text: str) -> str:
185
+ """European-Portuguese heterophonic-homograph sense diacritics via bifonia.
186
+
187
+ Rewrites homographs whose pronunciation depends on meaning (e.g.
188
+ "sede" thirst/closed vs. seat/open) with an explicit open/closed
189
+ vowel diacritic. These are ordinary Portuguese orthographic marks,
190
+ chosen so any downstream G2P — rule-based, neural, or espeak —
191
+ reads them correctly. Install with ``pip install scriptconv[pt]``
192
+ (or ``pip install bifonia``)."""
193
+ try:
194
+ from bifonia import add_extra_diacritics
195
+ except ImportError as e:
196
+ raise ImportError(
197
+ "European-Portuguese sense diacritics require the bifonia package: "
198
+ "pip install scriptconv[pt] (or pip install bifonia)"
199
+ ) from e
200
+ return add_extra_diacritics(text)
201
+
202
+
203
+ def diacritize(text: str, lang: str = "und", model=None,
204
+ phonikud_model=None, diacritizer_model=None, **_) -> str:
205
+ """Add pronunciation-disambiguating diacritics to *text* for *lang*.
206
+
207
+ Four backends, each restoring information ordinary orthography omits but
208
+ downstream G2P needs:
209
+
210
+ - Hebrew (``he``) — niqqud via phonikud (``phonikud_model=``).
211
+ - Arabic (``ar``) — tashkeel via text2tashkeel (``[tashkeel]``,
212
+ ``model=``/``diacritizer_model=``).
213
+ - East Slavic, Bulgarian/Macedonian/Slovene, Latvian, Armenian, Georgian,
214
+ and Turkic/Caucasian languages (``STRESS_LANGS``, 26 stressonnx tags) —
215
+ word stress via stressonnx (``[stress]``); stress is unwritten or
216
+ under-marked in these languages, and in East Slavic unstressed vowels
217
+ also reduce, so a missing mark can corrupt more than prosody.
218
+ - European Portuguese (``pt``/``pt-PT``, never ``pt-BR``) —
219
+ heterophonic-homograph sense diacritics via bifonia (``[pt]``);
220
+ ordinary Portuguese orthographic marks that any downstream G2P reads
221
+ correctly.
222
+
223
+ Unrecognized languages are returned unchanged. Each backend raises
224
+ ``ImportError`` naming its extra when the optional dependency is missing
225
+ — scriptconv never installs anything on the caller's behalf.
226
+ """
227
+ family = _diacritizer_family(lang)
228
+ if family == "he":
229
+ return _phonikud(phonikud_model).add_diacritics(text)
230
+ if family == "ar":
231
+ return _tashkeel(model or diacritizer_model).diacritize(text)
232
+ if family == "stress":
233
+ return _stress(text, lang, model)
234
+ if family == "pt":
235
+ return _sense_diacritics_pt(text)
236
+ return text
237
+
238
+
239
+ # Combining marks each diacritization backend overlays onto the bare text.
240
+ # Strip removes ONLY these codepoints — never a blanket combining-mark filter,
241
+ # which (via NFD) would decompose and destroy precomposed native letters:
242
+ # Cyrillic й/ё, Latvian ī, Azerbaijani ç/ö, and Arabic hamza carriers أ إ آ ؤ ئ.
243
+ _STRESS_MARKS = frozenset({0x0300, 0x0301}) # combining grave / acute
244
+ # U+0300 is included defensively alongside the U+0301 that stressonnx
245
+ # actually emits, in case any backend/locale marks secondary stress with a
246
+ # grave instead of an acute; harmless to strip since native precomposed
247
+ # letters (e.g. Cyrillic й/ё) are unaffected either way.
248
+ _ARABIC_MARKS = frozenset(range(0x064B, 0x0660)) | frozenset({0x0670}) # tashkeel + dagger alef
249
+ _HEBREW_MARKS = (frozenset(range(0x05B0, 0x05BE))
250
+ | frozenset({0x05BF, 0x05C1, 0x05C2, 0x05C4, 0x05C5, 0x05C7})) # niqqud
251
+
252
+
253
+ def _overlay_marks(lang: str) -> "frozenset | None":
254
+ """The overlay codepoints for *lang*'s diacritization backend, or None.
255
+
256
+ Resolves the backend family through :func:`_diacritizer_family` (the shared
257
+ lang→backend routing), so this can never disagree with
258
+ :func:`diacritize` about which language a backend owns.
259
+ Only *overlay* families are strippable: ``"pt"`` (bifonia sense marks are
260
+ native orthography) and ``None`` both return None.
261
+ """
262
+ family = _diacritizer_family(lang)
263
+ if family == "stress":
264
+ return _STRESS_MARKS
265
+ if family == "ar":
266
+ return _ARABIC_MARKS
267
+ if family == "he":
268
+ return _HEBREW_MARKS
269
+ return None
270
+
271
+
272
+ def _supports_strip(lang: str) -> bool:
273
+ """True for languages whose diacritics are removable overlays (stress marks,
274
+ Arabic tashkeel, Hebrew niqqud) rather than native orthography."""
275
+ return _overlay_marks(lang) is not None
276
+
277
+
278
+ def strip_diacritics(text: str, lang: str = "und", **_) -> str:
279
+ """Remove the overlay diacritics *lang*'s backend adds, recovering the bare
280
+ text WITHOUT touching native letters.
281
+
282
+ Removes only the specific overlay codepoints (combining acute/grave for
283
+ stress, tashkeel for Arabic, niqqud for Hebrew) — precomposed native
284
+ letters (Cyrillic й/ё, Latvian ī, Azerbaijani ç, Arabic hamza carriers) are
285
+ left intact. Raises :class:`ValueError` for languages whose diacritics are
286
+ part of the native orthography (e.g. European Portuguese), where any removal
287
+ would corrupt the spelling.
288
+ """
289
+ marks = _overlay_marks(lang)
290
+ if marks is None:
291
+ raise ValueError(
292
+ f"cannot strip diacritics for lang={lang!r}: its diacritics are "
293
+ "part of the native orthography, so removal would corrupt the "
294
+ "spelling. strip is defined only for overlay diacritics "
295
+ "(Arabic/Hebrew vocalization, East-Slavic/Turkic stress).")
296
+ return "".join(c for c in text if ord(c) not in marks)
297
+
298
+
299
+ def register(graph) -> None:
300
+ """Opt-in graph integration: add the diacritize/strip edge pair.
301
+
302
+ ``text -> text-diacritized`` is model-based (``lossless=False``), so a
303
+ direct ``text -> ipa`` phonemization always out-prices the detour —
304
+ enabling this extension is safe and non-invasive. Pair with
305
+ :func:`scriptconv.phonemizers.register` to make
306
+ ``"text-diacritized" -> "ipa"`` reachable.
307
+
308
+ ``text-diacritized -> text`` (:func:`strip_diacritics`) is lossless and
309
+ cheap, but gated: it raises for languages whose diacritics are native
310
+ orthography rather than removable overlays.
311
+ """
312
+ graph.register(
313
+ Edge("text", DIACRITIZED,
314
+ lambda text, lang="und", model=None, **c: diacritize(text, lang, model, **c),
315
+ lossless=False))
316
+ graph.register(
317
+ Edge(DIACRITIZED, "text",
318
+ lambda text, lang="und", **_: strip_diacritics(text, lang),
319
+ lossless=True))
@@ -38,6 +38,11 @@ from typing import Callable, Dict, List, Optional, Tuple
38
38
  __all__ = ["Representation", "Edge", "ConversionGraph", "DEFAULT_GRAPH",
39
39
  "REPRESENTATIONS"]
40
40
 
41
+ # Chosen so a single lossy hop never wins over a lossless path unless the
42
+ # lossless alternative is at least this many hops longer. A 10-hop lossless
43
+ # chain (cost 10, unit edges) ties a 1-hop lossy edge (cost 10) exactly, and
44
+ # heapq's stable tie-break then decides — a non-issue in practice since real
45
+ # scriptconv graphs are shallow (a handful of hops at most).
41
46
  _LOSSY_COST = 10.0
42
47
 
43
48
 
@@ -56,7 +61,10 @@ class Edge:
56
61
  """One registered transform between two representations.
57
62
 
58
63
  ``fn`` is called as ``fn(text, **context)``; context keys (``lang``,
59
- engine-specific options…) pass through the router opaquely. ``requires``
64
+ engine-specific options…) pass through the router opaquely. Every
65
+ registered ``fn`` MUST accept ``**kwargs`` for this reason — a callable
66
+ that only takes ``text`` raises :class:`TypeError` the moment routing
67
+ passes through any context. ``requires``
60
68
  names an optional extra the transform needs — metadata only; the
61
69
  transform itself raises :class:`ImportError` with an install hint.
62
70
  ``cost`` defaults from ``lossless`` so routing prefers lossless paths.
@@ -41,6 +41,7 @@ __all__ = [
41
41
  "ipa_to_cotovia",
42
42
  "rfe_to_ipa",
43
43
  "ipa_to_rfe",
44
+ "mantoq_to_ipa",
44
45
  "looks_like_ipa",
45
46
  ]
46
47
 
@@ -1053,7 +1054,7 @@ def _tokenize_mantoq(text: str) -> list[str]:
1053
1054
  return tokens
1054
1055
 
1055
1056
 
1056
- def mantoq_to_ipa(mantoq, errors: str = "pass") -> str:
1057
+ def mantoq_to_ipa(mantoq: str | list[str], errors: str = "pass") -> str:
1057
1058
  """Convert a Mantoq phoneme string to IPA.
1058
1059
 
1059
1060
  ``_dbl_`` lengthens/geminates the preceding symbol (``ː``), ``_+_``
@@ -1235,15 +1236,19 @@ def can_convert(src: str | Notation, dst: str | Notation) -> bool:
1235
1236
  src = Notation(src)
1236
1237
  dst = Notation(dst)
1237
1238
  if src == dst:
1238
- # historical contract: identity is not a "conversion"
1239
+ # historical contract: identity is not a "conversion" here, unlike
1240
+ # graph.ConversionGraph.can_convert (which deliberately returns True
1241
+ # for identity — an empty route is a valid, zero-cost conversion there)
1239
1242
  return False
1240
1243
  from scriptconv.graph import DEFAULT_GRAPH
1241
- if not DEFAULT_GRAPH.can_convert(src.value, dst.value):
1244
+ try:
1245
+ route = DEFAULT_GRAPH.route(src.value, dst.value)
1246
+ except ValueError:
1242
1247
  return False
1243
1248
  # only notation-to-notation reachability counts here; guard against paths
1244
1249
  # that would leave the notation node set (none exist today, cheap to keep)
1245
1250
  return all(e.src in _NOTATION_VALUES and e.dst in _NOTATION_VALUES
1246
- for e in DEFAULT_GRAPH.route(src.value, dst.value))
1251
+ for e in route)
1247
1252
 
1248
1253
 
1249
1254
  # ---------------------------------------------------------------------------
@@ -2,9 +2,10 @@
2
2
  import csv
3
3
  import math
4
4
  import os.path
5
+ import threading
5
6
  from base64 import b64decode
6
7
  from pathlib import Path
7
- from typing import Union, List, Dict
8
+ from typing import Union, List, Dict, Optional
8
9
 
9
10
  import regex as re
10
11
 
@@ -311,21 +312,46 @@ OBSTRUENTS = ()
311
312
  SONORANTS = ()
312
313
 
313
314
 
315
+ _initialized_tables_dir: Optional[Path] = None
316
+ # guards the one-time table population below. The rule functions read the
317
+ # module globals (CT_*, CONSONANTS, VOWELS, ...) without locking, which is safe
318
+ # only because those globals are written exactly once and never mutated
319
+ # afterwards; this lock serialises concurrent first-time initialisation so no
320
+ # thread can observe a half-populated set of tables.
321
+ _init_lock = threading.Lock()
322
+
323
+
314
324
  def initialize_conversion_tables(tables_dir: Path):
315
- global CT_double_codas, CT_neutral, CT_tensification, CT_assimilation, CT_aspiration, CT_convention
316
- CT_double_codas = ConversionTable('double_coda', tables_dir)
317
- CT_neutral = ConversionTable('neutralization', tables_dir)
318
- CT_tensification = ConversionTable('tensification', tables_dir)
319
- CT_assimilation = ConversionTable('assimilation', tables_dir)
320
- CT_aspiration = ConversionTable('aspiration', tables_dir)
321
- CT_convention = ConversionTable('ipa', tables_dir)
322
-
323
- global CONSONANTS, VOWELS, OBSTRUENTS, SONORANTS
324
- CONSONANTS = tuple(
325
- list(CT_convention.C)[:-2]) # from the C column of the IPA table, remove special characters # and $
326
- VOWELS = tuple(list(CT_convention.V)) # from the V column of the IPA table
327
- OBSTRUENTS = tuple(set(CONSONANTS) - set(C_SONORANTS))
328
- SONORANTS = VOWELS + C_SONORANTS
325
+ global _initialized_tables_dir
326
+ # the CSV tables never change at runtime, so once a given tables_dir has
327
+ # been parsed there is no need to re-read and re-parse it on every call.
328
+ # Fast path is lock-free: _initialized_tables_dir is published (assigned)
329
+ # last, after every table global is fully populated, so any thread that
330
+ # sees it set also sees the complete tables.
331
+ if _initialized_tables_dir == tables_dir:
332
+ return
333
+ with _init_lock:
334
+ # re-check under the lock: another thread may have initialised while we
335
+ # waited (double-checked locking)
336
+ if _initialized_tables_dir == tables_dir:
337
+ return
338
+ global CT_double_codas, CT_neutral, CT_tensification, CT_assimilation, CT_aspiration, CT_convention
339
+ CT_double_codas = ConversionTable('double_coda', tables_dir)
340
+ CT_neutral = ConversionTable('neutralization', tables_dir)
341
+ CT_tensification = ConversionTable('tensification', tables_dir)
342
+ CT_assimilation = ConversionTable('assimilation', tables_dir)
343
+ CT_aspiration = ConversionTable('aspiration', tables_dir)
344
+ CT_convention = ConversionTable('ipa', tables_dir)
345
+
346
+ global CONSONANTS, VOWELS, OBSTRUENTS, SONORANTS
347
+ CONSONANTS = tuple(
348
+ list(CT_convention.C)[:-2]) # from the C column of the IPA table, remove special characters # and $
349
+ VOWELS = tuple(list(CT_convention.V)) # from the V column of the IPA table
350
+ OBSTRUENTS = tuple(set(CONSONANTS) - set(C_SONORANTS))
351
+ SONORANTS = VOWELS + C_SONORANTS
352
+
353
+ # published last: any thread seeing this set also sees all tables above
354
+ _initialized_tables_dir = tables_dir
329
355
 
330
356
 
331
357
  def get_substring_ind(string: str, pattern: str) -> List[int]:
@@ -33,6 +33,7 @@ import re
33
33
  import math
34
34
  import sys
35
35
  import optparse
36
+ from functools import lru_cache
36
37
 
37
38
  # Option
38
39
  # Import-time CLI parsing removed for library use (the upstream script
@@ -366,10 +367,19 @@ kog2p_to_hangul = {
366
367
  }
367
368
 
368
369
 
370
+ @lru_cache(maxsize=None)
371
+ def _cachedReadRules(pver, rule_book):
372
+ # rulebook.txt never changes at runtime, so parse it once per path
373
+ # instead of re-opening and re-parsing it on every runKoG2P call
374
+ rule_in, rule_out = readRules(pver, rule_book)
375
+ return tuple(rule_in), tuple(rule_out)
376
+
377
+
369
378
  def runKoG2P(graph, rulebook=None):
370
379
  if not rulebook:
371
380
  rulebook = f"{os.path.dirname(__file__)}/rulebook.txt"
372
- [rule_in, rule_out] = readRules(ver_info[0], rulebook)
381
+ rule_in, rule_out = _cachedReadRules(ver_info[0], rulebook)
382
+ rule_in, rule_out = list(rule_in), list(rule_out)
373
383
  words = graph.split()
374
384
  phonemized = []
375
385
  for w in words: