scriptconv 0.0.4a4__tar.gz → 0.0.4a5__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 (95) hide show
  1. {scriptconv-0.0.4a4/scriptconv.egg-info → scriptconv-0.0.4a5}/PKG-INFO +2 -1
  2. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/pyproject.toml +1 -1
  3. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/__init__.py +2 -0
  4. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/cangjie.py +8 -7
  5. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/diacritics.py +17 -8
  6. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/graph.py +9 -1
  7. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/notation.py +9 -4
  8. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/hangul2ipa.py +41 -15
  9. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_vendored/kog2p/__init__.py +11 -1
  10. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/base.py +29 -4
  11. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/en.py +3 -4
  12. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/fa.py +8 -1
  13. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/gl.py +5 -3
  14. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/he.py +8 -1
  15. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/registry.py +2 -1
  16. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/zh.py +48 -8
  17. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/scripts.py +4 -0
  18. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/version.py +1 -1
  19. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5/scriptconv.egg-info}/PKG-INFO +2 -1
  20. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv.egg-info/SOURCES.txt +1 -0
  21. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv.egg-info/requires.txt +1 -0
  22. scriptconv-0.0.4a5/tests/test_diacritics.py +90 -0
  23. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/tests/test_phonemizers_cjk_ar.py +59 -0
  24. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/tests/test_scripts.py +12 -0
  25. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/LICENSE +0 -0
  26. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/README.md +0 -0
  27. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/requirements.txt +0 -0
  28. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/__main__.py +0 -0
  29. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/conventions.py +0 -0
  30. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/data/__init__.py +0 -0
  31. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/data/cangjie5_tc.tsv.gz +0 -0
  32. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/__init__.py +0 -0
  33. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/__init__.py +0 -0
  34. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/bw2ipa.py +0 -0
  35. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/ko_tables/aspiration.csv +0 -0
  36. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/ko_tables/assimilation.csv +0 -0
  37. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/ko_tables/double_coda.csv +0 -0
  38. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/ko_tables/hanja.tsv +0 -0
  39. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/ko_tables/ipa.csv +0 -0
  40. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/ko_tables/neutralization.csv +0 -0
  41. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/ko_tables/tensification.csv +0 -0
  42. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/ko_tables/yale.csv +0 -0
  43. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/shami/__init__.py +0 -0
  44. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/shami/codeswitch.py +0 -0
  45. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/shami/diacritize.py +0 -0
  46. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/shami/dialectal.py +0 -0
  47. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/shami/english_g2p.py +0 -0
  48. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/shami/espeak.py +0 -0
  49. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/shami/frontend.py +0 -0
  50. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/shami/levantine_g2p.py +0 -0
  51. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/shami/normalize.py +0 -0
  52. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/shami/phoneme_inventory.py +0 -0
  53. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_thirdparty/zh_num.py +0 -0
  54. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_vendored/__init__.py +0 -0
  55. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_vendored/kog2p/LICENSE.md +0 -0
  56. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_vendored/kog2p/rulebook.txt +0 -0
  57. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_vendored/mantoq/LICENSE.md +0 -0
  58. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_vendored/mantoq/__init__.py +0 -0
  59. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_vendored/mantoq/buck/__init__.py +0 -0
  60. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_vendored/mantoq/buck/phonetise_buckwalter.py +0 -0
  61. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_vendored/mantoq/buck/symbols.py +0 -0
  62. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_vendored/mantoq/buck/tokenization.py +0 -0
  63. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_vendored/mantoq/num2words.py +0 -0
  64. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/_vendored/mantoq/unicode_symbol2label.py +0 -0
  65. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/ar.py +0 -0
  66. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/enums.py +0 -0
  67. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/eu.py +0 -0
  68. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/ja.py +0 -0
  69. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/ko.py +0 -0
  70. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/mul.py +0 -0
  71. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/mwl.py +0 -0
  72. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/o2ipa.py +0 -0
  73. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/pt.py +0 -0
  74. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/shami.py +0 -0
  75. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/phonemizers/vi.py +0 -0
  76. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/py.typed +0 -0
  77. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/readings.py +0 -0
  78. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv/translit.py +0 -0
  79. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv.egg-info/dependency_links.txt +0 -0
  80. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/scriptconv.egg-info/top_level.txt +0 -0
  81. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/setup.cfg +0 -0
  82. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/tests/test_arpa_stress.py +0 -0
  83. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/tests/test_cangjie.py +0 -0
  84. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/tests/test_cli.py +0 -0
  85. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/tests/test_conventions.py +0 -0
  86. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/tests/test_diacritics_graph.py +0 -0
  87. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/tests/test_errors_policy.py +0 -0
  88. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/tests/test_examples.py +0 -0
  89. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/tests/test_graph.py +0 -0
  90. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/tests/test_notation.py +0 -0
  91. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/tests/test_phonemizers_base.py +0 -0
  92. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/tests/test_readings.py +0 -0
  93. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/tests/test_readings_zh.py +0 -0
  94. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/tests/test_scripts_stressonnx_compat.py +0 -0
  95. {scriptconv-0.0.4a4 → scriptconv-0.0.4a5}/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.4a5
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)
@@ -45,7 +45,7 @@ tashkeel, stressonnx) are strippable; spelling-integral backends (bifonia) are
45
45
  not.
46
46
  """
47
47
  from scriptconv.graph import Edge
48
- from scriptconv.phonemizers.base import STRESS_LANGS, _primary_subtag
48
+ from scriptconv.phonemizers.base import _diacritizer_family
49
49
 
50
50
  #: The lang-contextual node produced by diacritization. Like ``"text"`` it is
51
51
  #: meaningful only with ``lang=`` context and exists only in opted-in graphs.
@@ -55,6 +55,9 @@ _DEFAULT_PHONEMIZER = None
55
55
 
56
56
 
57
57
  def _get_phonemizer(phonikud_model=None):
58
+ # any concrete BasePhonemizer subclass works here — it's just a vessel
59
+ # for add_diacritics(); GraphemePhonemizer is picked for having no extra
60
+ # runtime dependencies of its own
58
61
  global _DEFAULT_PHONEMIZER
59
62
  if phonikud_model:
60
63
  from scriptconv.phonemizers.base import GraphemePhonemizer
@@ -81,6 +84,10 @@ def diacritize(text: str, lang: str = "und", model=None, **context) -> str:
81
84
  # which (via NFD) would decompose and destroy precomposed native letters:
82
85
  # Cyrillic й/ё, Latvian ī, Azerbaijani ç/ö, and Arabic hamza carriers أ إ آ ؤ ئ.
83
86
  _STRESS_MARKS = frozenset({0x0300, 0x0301}) # combining grave / acute
87
+ # U+0300 is included defensively alongside the U+0301 that stressonnx
88
+ # actually emits, in case any backend/locale marks secondary stress with a
89
+ # grave instead of an acute; harmless to strip since native precomposed
90
+ # letters (e.g. Cyrillic й/ё) are unaffected either way.
84
91
  _ARABIC_MARKS = frozenset(range(0x064B, 0x0660)) | frozenset({0x0670}) # tashkeel + dagger alef
85
92
  _HEBREW_MARKS = (frozenset(range(0x05B0, 0x05BE))
86
93
  | frozenset({0x05BF, 0x05C1, 0x05C2, 0x05C4, 0x05C5, 0x05C7})) # niqqud
@@ -89,16 +96,18 @@ _HEBREW_MARKS = (frozenset(range(0x05B0, 0x05BE))
89
96
  def _overlay_marks(lang: str) -> "frozenset | None":
90
97
  """The overlay codepoints for *lang*'s diacritization backend, or None.
91
98
 
92
- Uses exact primary-subtag matching (never ``startswith``) so Aragonese
93
- (``arg``), Herero (``her``), Mapudungun (``arn``) etc. are NOT misread as
94
- Arabic/Hebrew and stripped.
99
+ Resolves the backend family through :func:`_diacritizer_family` (the shared
100
+ lang→backend routing), so this can never disagree with
101
+ :meth:`BasePhonemizer.add_diacritics` about which language a backend owns.
102
+ Only *overlay* families are strippable: ``"pt"`` (bifonia sense marks are
103
+ native orthography) and ``None`` both return None.
95
104
  """
96
- p = _primary_subtag(lang)
97
- if p in STRESS_LANGS:
105
+ family = _diacritizer_family(lang)
106
+ if family == "stress":
98
107
  return _STRESS_MARKS
99
- if p == "ar":
108
+ if family == "ar":
100
109
  return _ARABIC_MARKS
101
- if p == "he":
110
+ if family == "he":
102
111
  return _HEBREW_MARKS
103
112
  return None
104
113
 
@@ -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:
@@ -74,6 +74,30 @@ def _is_european_portuguese(lang: str) -> bool:
74
74
  return norm == "pt" or norm == "pt-pt"
75
75
 
76
76
 
77
+ def _diacritizer_family(lang: str) -> Optional[str]:
78
+ """Which diacritization backend family handles *lang*, or ``None``.
79
+
80
+ Single source of truth for lang→backend routing: both the forward
81
+ dispatch (:meth:`BasePhonemizer.add_diacritics`) and the strip direction
82
+ (:func:`scriptconv.diacritics._overlay_marks`) resolve through this, so the
83
+ two can never disagree about which language uses which backend. Returns one
84
+ of ``"he"`` (niqqud), ``"ar"`` (tashkeel), ``"stress"`` (stressonnx), ``"pt"``
85
+ (bifonia sense diacritics), or ``None``. Uses exact primary-subtag matching
86
+ (never ``startswith``), so Aragonese (``arg``), Herero (``her``) and
87
+ Mapudungun (``arn``) are never misread as Arabic/Hebrew.
88
+ """
89
+ p = _primary_subtag(lang)
90
+ if p == "he":
91
+ return "he"
92
+ if p == "ar":
93
+ return "ar"
94
+ if p in STRESS_LANGS:
95
+ return "stress"
96
+ if _is_european_portuguese(lang):
97
+ return "pt"
98
+ return None
99
+
100
+
77
101
  class BasePhonemizer(metaclass=abc.ABCMeta):
78
102
  def __init__(self, alphabet: Alphabet = Alphabet.UNICODE,
79
103
  diacritizer_model: str = "rawi-ensemble",
@@ -199,13 +223,14 @@ class BasePhonemizer(metaclass=abc.ABCMeta):
199
223
  ``ImportError`` naming its extra when the optional dependency is
200
224
  missing — scriptconv never installs anything on the caller's behalf.
201
225
  """
202
- if _primary_subtag(lang) == "he":
226
+ family = _diacritizer_family(lang)
227
+ if family == "he":
203
228
  return self.phonikud.add_diacritics(text)
204
- elif _primary_subtag(lang) == "ar":
229
+ if family == "ar":
205
230
  return self.tashkeel(model).diacritize(text)
206
- elif _primary_subtag(lang) in STRESS_LANGS:
231
+ if family == "stress":
207
232
  return self._stress(text, lang, model)
208
- elif _is_european_portuguese(lang):
233
+ if family == "pt":
209
234
  return self._sense_diacritics_pt(text)
210
235
  return text
211
236
 
@@ -1,7 +1,3 @@
1
- import os
2
-
3
- import requests
4
-
5
1
  from scriptconv.notation import _ARPA_TO_IPA as arpa_to_ipa_lookup
6
2
  from scriptconv.phonemizers.base import BasePhonemizer
7
3
  from scriptconv.phonemizers.enums import Alphabet
@@ -18,6 +14,8 @@ class DeepPhonemizer(BasePhonemizer):
18
14
  }
19
15
 
20
16
  def __init__(self, model="latin_ipa_forward.pt"):
17
+ import os
18
+
21
19
  import dp
22
20
  from dp.phonemizer import Phonemizer
23
21
  import torch
@@ -38,6 +36,7 @@ class DeepPhonemizer(BasePhonemizer):
38
36
  os.makedirs(cache_dir, exist_ok=True)
39
37
  model_path = os.path.join(cache_dir, model)
40
38
  if not os.path.isfile(model_path):
39
+ import requests
41
40
  print(f"Downloading {model} from {url}...")
42
41
  with requests.get(url, stream=True) as r:
43
42
  r.raise_for_status()
@@ -5,7 +5,14 @@ from scriptconv.phonemizers.enums import Alphabet
5
5
  class PersianPhonemizer(BasePhonemizer):
6
6
  """https://github.com/de-mh/persian_phonemizer"""
7
7
  def __init__(self, alphabet=Alphabet.IPA):
8
- from persian_phonemizer import Phonemizer
8
+ try:
9
+ from persian_phonemizer import Phonemizer
10
+ except ImportError as e:
11
+ raise ImportError(
12
+ "persian_phonemizer is required for the Persian phonemizer. "
13
+ "Install it with 'pip install persian_phonemizer' "
14
+ "(or 'pip install scriptconv[fa]')."
15
+ ) from e
9
16
  assert alphabet in [Alphabet.ERAAB, Alphabet.IPA]
10
17
  output_format = "IPA" if alphabet == Alphabet.IPA else 'eraab'
11
18
  self.g2p = Phonemizer(output_format)
@@ -10,7 +10,8 @@ class CotoviaPhonemizer(BasePhonemizer):
10
10
  G2P engine that has verified parity with the original C binary.
11
11
 
12
12
  Output alphabets:
13
- - ``Alphabet.COTOVIA`` — raw Cotovia phoneme notation (e.g. ``"Este e uN ..."``)
13
+ - ``Alphabet.COTOVIA`` (default) — raw Cotovia phoneme notation (e.g.
14
+ ``"Este e uN ..."``), the binary-parity-tested native phoneset
14
15
  - ``Alphabet.IPA`` — IPA string produced by pycotovia's ``cotovia_to_ipa``
15
16
 
16
17
  Voices trained on Cotovia-alphabet output continue to receive the same
@@ -21,11 +22,12 @@ class CotoviaPhonemizer(BasePhonemizer):
21
22
  lazily so that ``import scriptconv.phonemizers`` works without it installed.
22
23
  """
23
24
 
24
- def __init__(self, alphabet: Alphabet = Alphabet.IPA,
25
+ def __init__(self, alphabet: Alphabet = Alphabet.COTOVIA,
25
26
  model: Optional[str] = None):
26
27
  """
27
28
  Args:
28
- alphabet (Alphabet): ``COTOVIA`` (raw notation) or ``IPA``.
29
+ alphabet (Alphabet): ``COTOVIA`` (raw notation, the default) or
30
+ ``IPA``.
29
31
  model (Optional[str]): phonemizer variant, from the voice's
30
32
  ``phonemizer_model``. ``"stress"`` emits the cotovia notation
31
33
  with the stressed vowel marked by a trailing ``^`` (e.g.
@@ -5,7 +5,14 @@ from scriptconv.phonemizers.base import BasePhonemizer
5
5
  class PhonikudPhonemizer(BasePhonemizer):
6
6
 
7
7
  def __init__(self):
8
- from phonikud import phonemize
8
+ try:
9
+ from phonikud import phonemize
10
+ except ImportError as e:
11
+ raise ImportError(
12
+ "phonikud is required for the Hebrew phonemizer. "
13
+ "Install it with 'pip install phonikud-onnx' "
14
+ "(or 'pip install scriptconv[he]')."
15
+ ) from e
9
16
  self.g2p = phonemize
10
17
  super().__init__(Alphabet.IPA)
11
18
 
@@ -17,6 +17,7 @@ from __future__ import annotations
17
17
  import importlib
18
18
  from typing import Dict, Optional, Tuple
19
19
 
20
+ from scriptconv.phonemizers.base import _primary_subtag
20
21
  from scriptconv.phonemizers.enums import Alphabet, Phonemizer
21
22
 
22
23
  __all__ = ["PHONEMIZER_REGISTRY", "LANG_DEFAULTS", "get_phonemizer",
@@ -184,7 +185,7 @@ def phonemizer_for_lang(lang: str, alphabet: Alphabet = Alphabet.IPA,
184
185
  """
185
186
  if override is not None:
186
187
  return get_phonemizer(override, alphabet, model, **kwargs)
187
- key = lang.replace("_", "-").split("-")[0].lower()
188
+ key = _primary_subtag(lang)
188
189
  explicit = LANG_DEFAULTS.get(key, ())
189
190
  for candidate in explicit:
190
191
  if alphabet in _EMITS.get(candidate, (Alphabet.IPA,)):
@@ -1,4 +1,5 @@
1
1
  import abc
2
+ import unicodedata
2
3
  from typing import List
3
4
 
4
5
 
@@ -46,7 +47,9 @@ class JiebaPhonemizer(BasePhonemizer):
46
47
  import jieba
47
48
  lang = self.get_lang(lang)
48
49
  seg_list = jieba.cut(text, cut_all=False)
49
- seg_list = [num2str(w) if w.isdigit() else w for w in seg_list]
50
+ # NFKC folds full-width digits (e.g. "3") to ASCII so num2str's
51
+ # DIGITS table (keyed by ASCII "0"-"9") never raises KeyError on them
52
+ seg_list = [num2str(unicodedata.normalize("NFKC", w)) if w.isdigit() else w for w in seg_list]
50
53
  return " ".join(seg_list)
51
54
 
52
55
 
@@ -68,7 +71,13 @@ class BaseChinesePinyinPhonemizer(BasePhonemizer):
68
71
  super().__init__(alphabet)
69
72
  self.jieba = jieba
70
73
  self.retone = retone
71
- from pinyin_to_ipa import pinyin_to_ipa
74
+ try:
75
+ from pinyin_to_ipa import pinyin_to_ipa
76
+ except ImportError:
77
+ raise ImportError(
78
+ "pinyin->IPA conversion needs pinyin_to_ipa — install with "
79
+ "`pip install scriptconv[zh-phonemizers]`"
80
+ ) from None
72
81
  self.pinyin_to_ipa = pinyin_to_ipa
73
82
 
74
83
  @classmethod
@@ -95,7 +104,12 @@ class BaseChinesePinyinPhonemizer(BasePhonemizer):
95
104
  p = p.replace('˥˩', '↘') # fourth tone
96
105
  p = p.replace('˥', '→') # first tone
97
106
  p = p.replace(chr(635) + chr(809), 'ɨ').replace(chr(633) + chr(809), 'ɨ')
98
- assert chr(809) not in p, p
107
+ # U+0329 (combining vertical line below, syllabic diacritic) is only
108
+ # ever expected attached to ɻ/ɹ (U+027B/U+0279) above, which the two
109
+ # replacements just consumed; any leftover means pinyin_to_ipa
110
+ # emitted an unanticipated syllabic marking. Rather than crashing in
111
+ # production, leave it in place — it's still valid IPA, just not
112
+ # collapsed to the ɨ shorthand.
99
113
  return p
100
114
 
101
115
  def to_ipa(self, phones: List[str]) -> List[str]:
@@ -132,7 +146,9 @@ class BaseChinesePinyinPhonemizer(BasePhonemizer):
132
146
  import jieba
133
147
  for chunk in jieba.cut(text, cut_all=False):
134
148
  if chunk.isdigit():
135
- chunk = num2str(chunk)
149
+ # NFKC folds full-width digits (e.g. "3") to ASCII so
150
+ # num2str's DIGITS table never raises KeyError on them
151
+ chunk = num2str(unicodedata.normalize("NFKC", chunk))
136
152
  phones += self.get_pinyin(chunk)
137
153
  phones += [" "] # keep jieba whitespace
138
154
  else:
@@ -176,7 +192,13 @@ class G2pCPhonemizer(BaseChinesePinyinPhonemizer):
176
192
  """
177
193
 
178
194
  def __init__(self, alphabet=Alphabet.PINYIN, jieba: bool = True):
179
- from g2pc import G2pC
195
+ try:
196
+ from g2pc import G2pC
197
+ except ImportError:
198
+ raise ImportError(
199
+ "g2pc phonemization needs g2pc — install with "
200
+ "`pip install scriptconv[zh-phonemizers]`"
201
+ ) from None
180
202
  self.g2p = G2pC()
181
203
  super().__init__(alphabet, jieba)
182
204
 
@@ -200,7 +222,13 @@ class G2pMPhonemizer(BaseChinesePinyinPhonemizer):
200
222
  """
201
223
 
202
224
  def __init__(self, alphabet=Alphabet.PINYIN, tone: bool = True, char_split: bool = False, jieba: bool = True):
203
- from g2pM import G2pM
225
+ try:
226
+ from g2pM import G2pM
227
+ except ImportError:
228
+ raise ImportError(
229
+ "g2pM phonemization needs g2pM — install with "
230
+ "`pip install scriptconv[zh-phonemizers]`"
231
+ ) from None
204
232
  self.g2p = G2pM()
205
233
  self.tone = tone
206
234
  self.char_split = char_split
@@ -225,7 +253,13 @@ class XpinyinPhonemizer(BaseChinesePinyinPhonemizer):
225
253
  """
226
254
 
227
255
  def __init__(self, alphabet=Alphabet.PINYIN, tone_marks: str = "numbers", jieba: bool = True):
228
- from xpinyin import Pinyin
256
+ try:
257
+ from xpinyin import Pinyin
258
+ except ImportError:
259
+ raise ImportError(
260
+ "xpinyin phonemization needs xpinyin — install with "
261
+ "`pip install scriptconv[zh-phonemizers]`"
262
+ ) from None
229
263
  self.g2p = Pinyin()
230
264
  self.tone_marks = tone_marks
231
265
  super().__init__(alphabet, jieba)
@@ -249,7 +283,13 @@ class PypinyinPhonemizer(BaseChinesePinyinPhonemizer):
249
283
  """
250
284
 
251
285
  def __init__(self, alphabet=Alphabet.PINYIN, jieba: bool = True):
252
- from pypinyin import pinyin
286
+ try:
287
+ from pypinyin import pinyin
288
+ except ImportError:
289
+ raise ImportError(
290
+ "pypinyin phonemization needs pypinyin — install with "
291
+ "`pip install scriptconv[zh]`"
292
+ ) from None
253
293
  self.g2p = pinyin
254
294
  super().__init__(alphabet, jieba)
255
295
 
@@ -416,6 +416,10 @@ def char_script(ch: str) -> Optional[str]:
416
416
  if len(ch) != 1:
417
417
  return None
418
418
  cp = ord(ch)
419
+ # Only the interval immediately at-or-before cp (idx) is ever checked —
420
+ # correct only because SCRIPT_REGISTRY's char_ranges are non-overlapping,
421
+ # so at most one interval can ever contain cp. If ranges ever overlapped,
422
+ # this single-candidate check would silently miss matches.
419
423
  idx = bisect_right(_SORTED_STARTS, cp) - 1
420
424
  if idx >= 0 and cp <= _SORTED_ENDS[idx]:
421
425
  return _SORTED_SCRIPTS[idx]
@@ -2,7 +2,7 @@
2
2
  VERSION_MAJOR = 0
3
3
  VERSION_MINOR = 0
4
4
  VERSION_BUILD = 4
5
- VERSION_ALPHA = 4
5
+ VERSION_ALPHA = 5
6
6
  # END_VERSION_BLOCK
7
7
 
8
8
  VERSION_STR = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scriptconv
3
- Version: 0.0.4a4
3
+ Version: 0.0.4a5
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"
@@ -78,6 +78,7 @@ tests/test_arpa_stress.py
78
78
  tests/test_cangjie.py
79
79
  tests/test_cli.py
80
80
  tests/test_conventions.py
81
+ tests/test_diacritics.py
81
82
  tests/test_diacritics_graph.py
82
83
  tests/test_errors_policy.py
83
84
  tests/test_examples.py
@@ -14,6 +14,7 @@ scriptconv[phonemizers]
14
14
  g2p_en
15
15
  openphonemizer
16
16
  deep-phonemizer
17
+ requests
17
18
 
18
19
  [epitran]
19
20
  scriptconv[phonemizers]
@@ -0,0 +1,90 @@
1
+ """Pure-python tests for scriptconv.diacritics strip/overlay helpers.
2
+
3
+ No optional backend is required — strip_diacritics/_overlay_marks/_supports_strip
4
+ never load a phonemizer model.
5
+ """
6
+ import pytest
7
+
8
+ from scriptconv.diacritics import (
9
+ strip_diacritics,
10
+ _overlay_marks,
11
+ _supports_strip,
12
+ _STRESS_MARKS,
13
+ _ARABIC_MARKS,
14
+ _HEBREW_MARKS,
15
+ )
16
+ from scriptconv.phonemizers.base import STRESS_LANGS
17
+
18
+
19
+ def test_strip_removes_combining_acute_preserves_cyrillic():
20
+ assert strip_diacritics("мой родно́й край", "ru") == "мой родной край"
21
+
22
+
23
+ def test_strip_preserves_precomposed_cyrillic_letters():
24
+ # й (U+0439) and ё (U+0451) are precomposed, not combining marks — they
25
+ # must survive stripping even alongside a combining acute.
26
+ text = "ёж, война́, май" # combining acute (U+0301) after "а" in "война́"
27
+ stripped = strip_diacritics(text, "ru")
28
+ assert "ё" in stripped
29
+ assert "й" in stripped
30
+ assert stripped == "ёж, война, май"
31
+
32
+
33
+ def test_strip_arabic_removes_tashkeel_preserves_consonants():
34
+ assert strip_diacritics("مُحَمَّد", "ar") == "محمد"
35
+
36
+
37
+ def test_strip_arabic_preserves_hamza_carrier():
38
+ stripped = strip_diacritics("أَحْمَد", "ar")
39
+ assert stripped.startswith("أ")
40
+
41
+
42
+ def test_strip_hebrew_removes_niqqud_preserves_consonants():
43
+ # שָׁלוֹם with niqqud -> bare consonants שלום
44
+ stripped = strip_diacritics("שָׁלוֹם", "he")
45
+ assert stripped == "שלום"
46
+
47
+
48
+ @pytest.mark.parametrize("text,lang", [
49
+ ("café", "pt"),
50
+ ("café", "en"),
51
+ ("café", "es"),
52
+ ("café", "arg"),
53
+ ("café", "her"),
54
+ ("café", "arn"),
55
+ ])
56
+ def test_strip_raises_for_native_orthography_langs(text, lang):
57
+ with pytest.raises(ValueError):
58
+ strip_diacritics(text, lang)
59
+
60
+
61
+ @pytest.mark.parametrize("lang", [
62
+ "ru", "uk", "be", "ar", "ar-SA", "he", "he-IL", "bg", "ka",
63
+ ])
64
+ def test_supports_strip_true(lang):
65
+ assert _supports_strip(lang) is True
66
+
67
+
68
+ @pytest.mark.parametrize("lang", [
69
+ "pt", "pt-PT", "en", "arg", "her", "arn",
70
+ ])
71
+ def test_supports_strip_false(lang):
72
+ assert _supports_strip(lang) is False
73
+
74
+
75
+ def test_overlay_marks_stress_lang():
76
+ lang = next(iter(STRESS_LANGS))
77
+ assert _overlay_marks(lang) == _STRESS_MARKS
78
+
79
+
80
+ def test_overlay_marks_arabic():
81
+ assert _overlay_marks("ar") == _ARABIC_MARKS
82
+
83
+
84
+ def test_overlay_marks_hebrew():
85
+ assert _overlay_marks("he") == _HEBREW_MARKS
86
+
87
+
88
+ def test_overlay_marks_none_for_native_orthography():
89
+ assert _overlay_marks("pt") is None
90
+ assert _overlay_marks("en") is None
@@ -21,6 +21,65 @@ class TestVendoredChinese(unittest.TestCase):
21
21
  from scriptconv.phonemizers._thirdparty.zh_num import num2str
22
22
  self.assertEqual(num2str("123"), "一百二十三")
23
23
 
24
+ def test_zh_num_fullwidth_digits_normalized(self):
25
+ # zh_num.DIGITS is keyed by ASCII "0"-"9"; full-width digits (e.g.
26
+ # "3") must be NFKC-folded before lookup or num2str raises KeyError
27
+ import unicodedata
28
+ from scriptconv.phonemizers._thirdparty.zh_num import num2str
29
+ fullwidth = "123"
30
+ self.assertEqual(
31
+ num2str(unicodedata.normalize("NFKC", fullwidth)),
32
+ num2str("123"),
33
+ )
34
+
35
+
36
+ class TestPinyinRetone(unittest.TestCase):
37
+ def test_retone_tone_marks(self):
38
+ from scriptconv.phonemizers.zh import BaseChinesePinyinPhonemizer
39
+ retoned = BaseChinesePinyinPhonemizer._retone("ma˥")
40
+ self.assertEqual(retoned, "ma→")
41
+
42
+ def test_retone_leftover_syllabic_mark_does_not_raise(self):
43
+ # any pinyin_to_ipa output carrying an unanticipated combining
44
+ # U+0329 (not attached to ɻ/ɹ) must degrade gracefully, not crash
45
+ from scriptconv.phonemizers.zh import BaseChinesePinyinPhonemizer
46
+ leftover = "n" + chr(809)
47
+ retoned = BaseChinesePinyinPhonemizer._retone(leftover)
48
+ self.assertEqual(retoned, leftover)
49
+
50
+
51
+ class TestZhBackendFriendlyImportErrors(unittest.TestCase):
52
+ def _assert_friendly(self, module_name, cls, extra):
53
+ # sys.modules[name] = None makes the `import` statement raise
54
+ # ImportError regardless of whether the package is actually
55
+ # installed, so this simulates the missing-dependency case cleanly
56
+ import sys
57
+ from unittest.mock import patch
58
+ with patch.dict(sys.modules, {module_name: None}):
59
+ with self.assertRaises(ImportError) as ctx:
60
+ cls()
61
+ self.assertIn(f"scriptconv[{extra}]", str(ctx.exception))
62
+
63
+ def test_pypinyin_missing_dep_message(self):
64
+ from scriptconv.phonemizers.zh import PypinyinPhonemizer
65
+ self._assert_friendly("pypinyin", PypinyinPhonemizer, "zh")
66
+
67
+ def test_xpinyin_missing_dep_message(self):
68
+ from scriptconv.phonemizers.zh import XpinyinPhonemizer
69
+ self._assert_friendly("xpinyin", XpinyinPhonemizer, "zh-phonemizers")
70
+
71
+ def test_g2pm_missing_dep_message(self):
72
+ from scriptconv.phonemizers.zh import G2pMPhonemizer
73
+ self._assert_friendly("g2pM", G2pMPhonemizer, "zh-phonemizers")
74
+
75
+ def test_g2pc_missing_dep_message(self):
76
+ from scriptconv.phonemizers.zh import G2pCPhonemizer
77
+ self._assert_friendly("g2pc", G2pCPhonemizer, "zh-phonemizers")
78
+
79
+ def test_pinyin_to_ipa_missing_dep_message(self):
80
+ from scriptconv.phonemizers.zh import PypinyinPhonemizer
81
+ self._assert_friendly("pinyin_to_ipa", PypinyinPhonemizer, "zh-phonemizers")
82
+
24
83
 
25
84
  class TestShamiFrontend(unittest.TestCase):
26
85
  def test_codeswitch_language_ids_align(self):
@@ -742,3 +742,15 @@ def test_script_runs_empty_and_pure_punctuation():
742
742
  def test_script_runs_reconstructs_input():
743
743
  for text in ["привет hello", "Hello مرحبا world", "приве́т", " hi", "日本語 abc"]:
744
744
  assert "".join(t for _, t in script_runs(text)) == text
745
+
746
+
747
+ def test_char_script_ranges_are_non_overlapping():
748
+ # char_script's binary search only checks one candidate interval; that
749
+ # is only correct if no two registered char_ranges overlap.
750
+ ranges = sorted(
751
+ (lo, hi)
752
+ for script in SCRIPT_REGISTRY.values()
753
+ for lo, hi in script.char_ranges
754
+ )
755
+ for (_, prev_hi), (next_lo, _) in zip(ranges, ranges[1:]):
756
+ assert next_lo > prev_hi
File without changes
File without changes
File without changes