scriptconv 0.0.3a6__tar.gz → 0.0.3a7__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 (32) hide show
  1. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/PKG-INFO +1 -1
  2. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/scriptconv/__init__.py +21 -0
  3. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/scriptconv/__main__.py +47 -0
  4. scriptconv-0.0.3a7/scriptconv/conventions.py +495 -0
  5. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/scriptconv/readings.py +7 -2
  6. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/scriptconv/translit.py +2 -0
  7. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/scriptconv/version.py +1 -1
  8. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/scriptconv.egg-info/PKG-INFO +1 -1
  9. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/scriptconv.egg-info/SOURCES.txt +2 -0
  10. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/tests/test_cli.py +22 -0
  11. scriptconv-0.0.3a7/tests/test_conventions.py +210 -0
  12. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/LICENSE +0 -0
  13. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/README.md +0 -0
  14. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/pyproject.toml +0 -0
  15. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/requirements.txt +0 -0
  16. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/scriptconv/cangjie.py +0 -0
  17. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/scriptconv/data/__init__.py +0 -0
  18. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/scriptconv/data/cangjie5_tc.tsv.gz +0 -0
  19. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/scriptconv/notation.py +0 -0
  20. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/scriptconv/py.typed +0 -0
  21. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/scriptconv/scripts.py +0 -0
  22. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/scriptconv.egg-info/dependency_links.txt +0 -0
  23. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/scriptconv.egg-info/requires.txt +0 -0
  24. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/scriptconv.egg-info/top_level.txt +0 -0
  25. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/setup.cfg +0 -0
  26. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/tests/test_cangjie.py +0 -0
  27. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/tests/test_examples.py +0 -0
  28. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/tests/test_notation.py +0 -0
  29. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/tests/test_readings.py +0 -0
  30. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/tests/test_readings_zh.py +0 -0
  31. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/tests/test_scripts.py +0 -0
  32. {scriptconv-0.0.3a6 → scriptconv-0.0.3a7}/tests/test_translit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scriptconv
3
- Version: 0.0.3a6
3
+ Version: 0.0.3a7
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
@@ -22,6 +22,10 @@ readings:
22
22
  extra; Chinese hanzi → pinyin/bopomofo via the ``zh`` extra).
23
23
  cangjie:
24
24
  Hanzi → Cangjie5 input codes (shape decomposition, vendored table).
25
+ conventions:
26
+ Orthographic conventions — script-scoped decorations orthogonal to
27
+ identity (tashkeel, niqqud, wakachigaki, pinyin tone spelling…).
28
+ Scripts are nodes; conventions are parameters, never nodes.
25
29
 
26
30
  Zero required runtime dependencies (stdlib only); the readings module
27
31
  needs the optional ``ja``/``zh`` extras.
@@ -75,6 +79,15 @@ from scriptconv.readings import (
75
79
  to_pinyin,
76
80
  to_bopomofo,
77
81
  )
82
+ from scriptconv.conventions import (
83
+ Convention,
84
+ CONVENTION_REGISTRY,
85
+ conventions_for,
86
+ restyle,
87
+ strip,
88
+ apply,
89
+ detect as detect_convention,
90
+ )
78
91
  from scriptconv.cangjie import (
79
92
  cangjie_code,
80
93
  to_cangjie,
@@ -123,6 +136,14 @@ __all__ = [
123
136
  "tokens",
124
137
  "to_hiragana",
125
138
  "to_katakana",
139
+ # conventions (orthographic decorations, orthogonal to script identity)
140
+ "Convention",
141
+ "CONVENTION_REGISTRY",
142
+ "conventions_for",
143
+ "restyle",
144
+ "strip",
145
+ "apply",
146
+ "detect_convention",
126
147
  # cangjie (vendored Cangjie5 table)
127
148
  "cangjie_code",
128
149
  "to_cangjie",
@@ -8,6 +8,9 @@ Usage
8
8
  python -m scriptconv direction <text>
9
9
  python -m scriptconv decompose <text>
10
10
  python -m scriptconv lang <code>
11
+ python -m scriptconv strip <convention> <text>
12
+ python -m scriptconv restyle <convention> <style> <text>
13
+ python -m scriptconv conventions [--script CODE]
11
14
 
12
15
  Examples
13
16
  --------
@@ -48,6 +51,19 @@ def main(argv: list[str] | None = None) -> int:
48
51
  lang = sub.add_parser("lang", help="Map language code to script")
49
52
  lang.add_argument("code", help="BCP-47 or ISO 639 language code")
50
53
 
54
+ st = sub.add_parser("strip", help="Remove an orthographic convention's decoration")
55
+ st.add_argument("convention", help="Convention id (see `conventions`)")
56
+ st.add_argument("text", help="Text to strip")
57
+
58
+ rs = sub.add_parser("restyle", help="Rewrite text between a convention's styles")
59
+ rs.add_argument("convention", help="Convention id (see `conventions`)")
60
+ rs.add_argument("to", help="Target style")
61
+ rs.add_argument("text", help="Text to restyle")
62
+ rs.add_argument("--from", dest="frm", default=None, help="Source style")
63
+
64
+ cv = sub.add_parser("conventions", help="List orthographic conventions")
65
+ cv.add_argument("--script", default=None, help="Filter by ISO-15924 script code")
66
+
51
67
  args = p.parse_args(argv)
52
68
 
53
69
  if args.command is None:
@@ -61,6 +77,9 @@ def main(argv: list[str] | None = None) -> int:
61
77
  "direction": lambda: _do_direction(args),
62
78
  "decompose": lambda: _do_decompose(args),
63
79
  "lang": lambda: _do_lang(args),
80
+ "strip": lambda: _do_strip(args),
81
+ "restyle": lambda: _do_restyle(args),
82
+ "conventions": lambda: _do_conventions(args),
64
83
  }
65
84
  dispatch[args.command]()
66
85
  return 0
@@ -100,6 +119,34 @@ def _do_decompose(args: argparse.Namespace) -> None:
100
119
  print(decompose_hangul(args.text))
101
120
 
102
121
 
122
+ def _do_strip(args: argparse.Namespace) -> None:
123
+ from scriptconv.conventions import strip
124
+ try:
125
+ print(strip(args.text, args.convention))
126
+ except ValueError as e:
127
+ raise SystemExit(f"error: {e}")
128
+
129
+
130
+ def _do_restyle(args: argparse.Namespace) -> None:
131
+ from scriptconv.conventions import restyle
132
+ try:
133
+ print(restyle(args.text, args.convention, args.to, frm=args.frm))
134
+ except ValueError as e:
135
+ raise SystemExit(f"error: {e}")
136
+
137
+
138
+ def _do_conventions(args: argparse.Namespace) -> None:
139
+ from scriptconv.conventions import CONVENTION_REGISTRY, conventions_for
140
+ convs = (conventions_for(args.script) if args.script
141
+ else list(CONVENTION_REGISTRY.values()))
142
+ if not convs:
143
+ print("(no conventions registered for that script)")
144
+ for c in convs:
145
+ system = f" [{c.system}]" if c.system else ""
146
+ print(f" {c.id}{system} scripts={','.join(c.scripts)} "
147
+ f"styles={','.join(c.styles)} — {c.name}")
148
+
149
+
103
150
  def _do_lang(args: argparse.Namespace) -> None:
104
151
  from scriptconv.scripts import lang_to_script
105
152
  print(lang_to_script(args.code) or "(unknown)")
@@ -0,0 +1,495 @@
1
+ """Orthographic conventions — script-scoped decorations, orthogonal to identity.
2
+
3
+ Scripts are text *identity*: character-evident, ISO-15924-coded, the nodes of
4
+ scriptconv's conversion space (:mod:`scriptconv.scripts`). Conventions are
5
+ *decorations* a script's orthography can carry or omit — optional vocalization
6
+ (Arabic tashkeel, Hebrew niqqud), justification (kashida), spacing modes
7
+ (Japanese wakachigaki), or mutually-exclusive spellings of the same
8
+ information (pinyin tone marks vs digits). A convention never participates in
9
+ script detection or conversion routing; it is always a parameter, never a
10
+ node.
11
+
12
+ Each convention declares its *styles* — the mutually exclusive states its text
13
+ can be in. Binary decorations have styles ``("marked", "none")``; variant
14
+ conventions name their spellings (``("mark", "number", "none")``). The one
15
+ real operation is :func:`restyle`, a partial transition matrix between styles;
16
+ :func:`strip` and :func:`apply` are sugar for transitions to and from
17
+ ``"none"``.
18
+
19
+ Which transitions exist follows the same boundary the rest of scriptconv
20
+ draws:
21
+
22
+ * stripping and deterministic re-spelling are pure orthography — always
23
+ available, zero-dependency;
24
+ * transitions that need a *dictionary lookup* (wakachigaki application =
25
+ word segmentation) are orthography too, but live behind the optional
26
+ extras, like :mod:`scriptconv.readings`;
27
+ * transitions that need contextual *prediction* (restoring tashkeel or
28
+ niqqud, i.e. diacritization) are linguistic inference, out of scope for the
29
+ same reason grapheme-to-phoneme is. Their absence is queryable data, not a
30
+ runtime surprise.
31
+
32
+ What is **not** a convention: marks that are letter identity rather than
33
+ decoration. Vietnamese tone diacritics spell different words, Arabic
34
+ hamza/madda (U+0653–U+0655) distinguish letters, and stripping them corrupts
35
+ text — none are registered. Casing on bicameral scripts *is* a convention in
36
+ the sense used here, but the stdlib (``str.lower``/``str.casefold``) already
37
+ models it correctly, so it is deliberately not duplicated.
38
+
39
+ ``jamo-form`` is the registry's one documented impurity: compatibility vs
40
+ conjoining jamo is a Unicode *representation* convention rather than an
41
+ orthographic one, registered because free text genuinely occurs in both
42
+ repertoires and the restyle is deterministic.
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import re
47
+ import unicodedata
48
+ from dataclasses import dataclass
49
+ from typing import Callable, Dict, List, Optional, Tuple
50
+
51
+ __all__ = [
52
+ "Convention",
53
+ "CONVENTION_REGISTRY",
54
+ "conventions_for",
55
+ "detect",
56
+ "restyle",
57
+ "strip",
58
+ "apply",
59
+ ]
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Convention:
64
+ """Metadata for one orthographic convention (NotationInfo-style: data, not prose)."""
65
+ id: str
66
+ name: str
67
+ scripts: Tuple[str, ...] # ISO-15924 codes whose text it decorates
68
+ system: Optional[str] # sub-script system qualifier ("pinyin", "japanese")
69
+ styles: Tuple[str, ...] # mutually exclusive states; includes "none" if strippable
70
+ default_style: str
71
+ detectable: bool # current style is character-evident
72
+ strip_lossless: bool # can the stripped information be recovered?
73
+ apply_requires: Optional[str] # optional-extra name needed to leave "none", if any
74
+ ranges: Tuple[Tuple[int, int], ...] # decoration codepoints (empty for spacing/variant conventions)
75
+ reference: str
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Registry
80
+ # ---------------------------------------------------------------------------
81
+ # Tashkeel deliberately EXCLUDES U+0653–0655 (madda above, hamza above, hamza
82
+ # below): in NFD text آ/أ/إ decompose to alef + those marks, which are letter
83
+ # identity — stripping them would corrupt the consonantal skeleton.
84
+ _TASHKEEL_RANGES = ((0x064B, 0x0652), (0x0656, 0x065F), (0x0670, 0x0670),
85
+ (0x08F0, 0x08F2))
86
+ _KASHIDA_RANGES = ((0x0640, 0x0640),)
87
+ # 06DD/06DE (end of ayah, rub el hizb) are structural signs and 06E5/06E6 are
88
+ # small letters — kept; 06E7-06E9 (small high marks, place of sajdah) and
89
+ # 06EA-06ED are annotation marks — stripped.
90
+ _QURANIC_RANGES = ((0x06D6, 0x06DC), (0x06DF, 0x06E4), (0x06E7, 0x06ED))
91
+ # Niqqud includes dagesh/mapiq U+05BC, meteg U+05BD (a masoretic stress mark,
92
+ # but traditionally stripped with the points), rafe U+05BF and the shin/sin
93
+ # dots U+05C1/05C2 — stripping loses the שׁ/שׂ distinction, exactly as
94
+ # unpointed Hebrew does. Maqaf U+05BE, sof pasuq U+05C3 and nun hafukha
95
+ # U+05C6 are punctuation and survive.
96
+ _NIQQUD_RANGES = ((0x05B0, 0x05BD), (0x05BF, 0x05BF), (0x05C1, 0x05C2),
97
+ (0x05C7, 0x05C7))
98
+ _TEAMIM_RANGES = ((0x0591, 0x05AF), (0x05C0, 0x05C0), (0x05C4, 0x05C5))
99
+
100
+ CONVENTION_REGISTRY: Dict[str, Convention] = {c.id: c for c in (
101
+ Convention("tashkeel", "Arabic vocalization (harakat/tanwin/shadda/sukun)",
102
+ ("Arab",), None, ("marked", "none"), "marked",
103
+ detectable=True, strip_lossless=False, apply_requires=None,
104
+ ranges=_TASHKEEL_RANGES,
105
+ reference="Unicode 15 ch. 9.2 (Arabic); excludes letter-identity U+0653-0655"),
106
+ Convention("kashida", "Arabic justification elongation (tatweel)",
107
+ ("Arab",), None, ("marked", "none"), "none",
108
+ detectable=True, strip_lossless=False, apply_requires=None,
109
+ ranges=_KASHIDA_RANGES, reference="Unicode U+0640 ARABIC TATWEEL"),
110
+ Convention("quranic-marks", "Quranic annotation signs",
111
+ ("Arab",), None, ("marked", "none"), "none",
112
+ detectable=True, strip_lossless=False, apply_requires=None,
113
+ ranges=_QURANIC_RANGES, reference="Unicode U+06D6-06ED Quranic annotation signs"),
114
+ Convention("niqqud", "Hebrew vocalization points",
115
+ ("Hebr",), None, ("marked", "none"), "marked",
116
+ detectable=True, strip_lossless=False, apply_requires=None,
117
+ ranges=_NIQQUD_RANGES,
118
+ reference="Unicode 15 ch. 9.1 (Hebrew); strip loses the shin/sin dot distinction"),
119
+ Convention("teamim", "Hebrew cantillation marks",
120
+ ("Hebr",), None, ("marked", "none"), "none",
121
+ detectable=True, strip_lossless=False, apply_requires=None,
122
+ ranges=_TEAMIM_RANGES, reference="Unicode U+0591-05AF Hebrew accents (masoretic)"),
123
+ Convention("wakachigaki", "Japanese spaced writing (分かち書き)",
124
+ ("Hira", "Kana", "Hani"), "japanese", ("marked", "none"), "none",
125
+ detectable=False, strip_lossless=False, apply_requires="ja",
126
+ ranges=(),
127
+ reference="Spaced orthographic mode of Japanese (children's books, games)"),
128
+ Convention("pinyin-tone", "Pinyin tone spelling",
129
+ ("Latn",), "pinyin", ("mark", "number", "none"), "mark",
130
+ detectable=False, strip_lossless=False, apply_requires=None,
131
+ ranges=(),
132
+ reference="GB/T 16159 Basic rules of Hanyu Pinyin orthography"),
133
+ Convention("jamo-form", "Hangul jamo repertoire (compatibility vs conjoining)",
134
+ ("Hang",), None, ("compatibility", "conjoining"), "compatibility",
135
+ detectable=True, strip_lossless=True, apply_requires=None,
136
+ ranges=(),
137
+ reference="Unicode Hangul Compatibility Jamo U+3130 vs Hangul Jamo U+1100"),
138
+ )}
139
+
140
+
141
+ def conventions_for(script: str) -> List[Convention]:
142
+ """Conventions that decorate *script* (ISO-15924 code), registry order."""
143
+ return [c for c in CONVENTION_REGISTRY.values() if script in c.scripts]
144
+
145
+
146
+ def _conv(convention_id: str) -> Convention:
147
+ try:
148
+ return CONVENTION_REGISTRY[convention_id]
149
+ except KeyError:
150
+ raise ValueError(
151
+ f"unknown convention {convention_id!r}; known: "
152
+ f"{', '.join(sorted(CONVENTION_REGISTRY))}") from None
153
+
154
+
155
+ def _in_ranges(cp: int, ranges: Tuple[Tuple[int, int], ...]) -> bool:
156
+ return any(lo <= cp <= hi for lo, hi in ranges)
157
+
158
+
159
+ def _range_strip(ranges: Tuple[Tuple[int, int], ...]) -> Callable[[str], str]:
160
+ def _strip(text: str) -> str:
161
+ return "".join(c for c in text if not _in_ranges(ord(c), ranges))
162
+ return _strip
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Wakachigaki
167
+ # ---------------------------------------------------------------------------
168
+ # Strip removes space runs only when BOTH flanks are Japanese-class: spaces
169
+ # next to Latin, digits or boundaries carry information ("きょうは good day",
170
+ # "第 3 章") and must survive.
171
+
172
+ def _is_japanese_flank(ch: str) -> bool:
173
+ cp = ord(ch)
174
+ return (0x3041 <= cp <= 0x309F or # hiragana
175
+ 0x30A0 <= cp <= 0x30FF or # katakana incl. ー
176
+ 0x31F0 <= cp <= 0x31FF or # katakana phonetic extensions
177
+ 0xFF66 <= cp <= 0xFF9F or # halfwidth katakana
178
+ 0x4E00 <= cp <= 0x9FFF or # CJK unified
179
+ 0x3400 <= cp <= 0x4DBF or # CJK ext A
180
+ cp in (0x3005, 0x3006, 0x303B)) # 々 〆 repeat marks
181
+
182
+ _WAKACHI_SPACE = re.compile(r"[  ]+")
183
+
184
+
185
+ def _wakachigaki_strip(text: str) -> str:
186
+ out = []
187
+ i = 0
188
+ while i < len(text):
189
+ m = _WAKACHI_SPACE.match(text, i)
190
+ if m:
191
+ before = out[-1] if out else ""
192
+ after = text[m.end():m.end() + 1]
193
+ if not (before and after and _is_japanese_flank(before[-1])
194
+ and _is_japanese_flank(after)):
195
+ out.append(m.group(0))
196
+ i = m.end()
197
+ else:
198
+ out.append(text[i])
199
+ i += 1
200
+ return "".join(out)
201
+
202
+
203
+ def _wakachigaki_apply(text: str) -> str:
204
+ try:
205
+ from scriptconv.readings import tokens
206
+ except ImportError:
207
+ raise ImportError(
208
+ "applying wakachigaki needs dictionary word segmentation — "
209
+ "install with `pip install scriptconv[ja]`") from None
210
+ parts: List[str] = []
211
+ for tok in tokens(text):
212
+ if parts and _is_japanese_flank(parts[-1][-1]) and _is_japanese_flank(tok.orig[0]):
213
+ parts.append(" ")
214
+ parts.append(tok.orig)
215
+ return "".join(parts)
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Pinyin tone spelling
220
+ # ---------------------------------------------------------------------------
221
+ # number→mark is deterministic. mark→number is deterministic for GB/T
222
+ # 16159-compliant (apostrophized) pinyin; on non-compliant input where several
223
+ # marked vowels share one letter run, syllables are split best-effort before
224
+ # the longest valid onset preceding each subsequent marked vowel.
225
+
226
+ _TONE_COMBINING = {1: "̄", 2: "́", 3: "̌", 4: "̀"}
227
+ _COMBINING_TONE = {v: k for k, v in _TONE_COMBINING.items()}
228
+ _VOWELS = "aeiouü"
229
+ _ONSETS = {"b", "p", "m", "f", "d", "t", "n", "l", "g", "k", "h",
230
+ "j", "q", "x", "r", "z", "c", "s", "y", "w", "zh", "ch", "sh"}
231
+
232
+
233
+ def _mark_syllable(syl: str, tone: int) -> str:
234
+ """Place the tone mark of *syl* (tone digit already removed)."""
235
+ syl = syl.replace("v", "ü").replace("V", "Ü").replace("u:", "ü").replace("U:", "Ü")
236
+ if tone in (0, 5):
237
+ return syl
238
+ low = syl.lower()
239
+ pos = -1
240
+ if "a" in low:
241
+ pos = low.index("a")
242
+ elif "e" in low:
243
+ pos = low.index("e")
244
+ elif "ou" in low:
245
+ pos = low.index("o")
246
+ else:
247
+ for i in range(len(low) - 1, -1, -1):
248
+ if low[i] in _VOWELS:
249
+ pos = i
250
+ break
251
+ if pos == -1: # syllabic nasal (m, n, ng, hm, hng): mark the first nasal
252
+ for i, c in enumerate(low):
253
+ if c in "mn":
254
+ pos = i
255
+ break
256
+ if pos == -1:
257
+ return syl
258
+ marked = unicodedata.normalize(
259
+ "NFC", syl[:pos + 1] + _TONE_COMBINING[tone] + syl[pos + 1:])
260
+ return marked
261
+
262
+
263
+ _NUM_SYL = re.compile(r"([a-zA-ZüÜ:]+)([0-5])")
264
+
265
+
266
+ def _pinyin_number_to_mark(text: str) -> str:
267
+ return _NUM_SYL.sub(lambda m: _mark_syllable(m.group(1), int(m.group(2))), text)
268
+
269
+
270
+ def _split_run(run: str) -> List[str]:
271
+ """Split one letter run into syllables, one tone mark each (best effort)."""
272
+ decomp = unicodedata.normalize("NFD", run)
273
+ mark_positions = [i for i, c in enumerate(decomp) if c in _COMBINING_TONE]
274
+ if len(mark_positions) <= 1:
275
+ return [run]
276
+ cuts = []
277
+ for mp in mark_positions[1:]:
278
+ # walk back over the marked vowel's preceding consonant cluster and
279
+ # cut before the longest valid onset
280
+ j = mp - 1
281
+ while j >= 0 and decomp[j] in _COMBINING_TONE:
282
+ j -= 1
283
+ # j is the vowel carrying the mark; find start of consonant cluster
284
+ k = j
285
+ while k > 0 and decomp[k - 1].lower() not in _VOWELS \
286
+ and decomp[k - 1] not in _COMBINING_TONE:
287
+ k -= 1
288
+ cluster = decomp[k:j]
289
+ cut = j
290
+ for size in (2, 1):
291
+ if len(cluster) >= size and cluster[-size:].lower() in _ONSETS:
292
+ cut = j - size
293
+ break
294
+ cuts.append(cut)
295
+ pieces, prev = [], 0
296
+ for cut in cuts:
297
+ pieces.append(unicodedata.normalize("NFC", decomp[prev:cut]))
298
+ prev = cut
299
+ pieces.append(unicodedata.normalize("NFC", decomp[prev:]))
300
+ return [p for p in pieces if p]
301
+
302
+
303
+ _LETTER_RUN = re.compile(r"[^\W\d_]+", re.UNICODE)
304
+
305
+
306
+ def _pinyin_mark_to_number(text: str) -> str:
307
+ def _convert_run(m: re.Match) -> str:
308
+ out = []
309
+ for syl in _split_run(m.group(0)):
310
+ decomp = unicodedata.normalize("NFD", syl)
311
+ tone = 5
312
+ kept = []
313
+ for c in decomp:
314
+ if c in _COMBINING_TONE:
315
+ tone = _COMBINING_TONE[c]
316
+ else:
317
+ kept.append(c)
318
+ out.append(unicodedata.normalize("NFC", "".join(kept)) + str(tone))
319
+ return "".join(out)
320
+ return _LETTER_RUN.sub(_convert_run, text)
321
+
322
+
323
+ def _pinyin_strip(text: str) -> str:
324
+ def _strip_run(m: re.Match) -> str:
325
+ decomp = unicodedata.normalize("NFD", m.group(0))
326
+ kept = "".join(c for c in decomp if c not in _COMBINING_TONE)
327
+ return unicodedata.normalize("NFC", kept)
328
+ # digits are removed only when attached to a letter (tone digits), so
329
+ # ordinary numerals in surrounding text survive
330
+ return re.sub(r"(?<=[^\W\d_])[0-5]", "", _LETTER_RUN.sub(_strip_run, text))
331
+
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # Jamo form
335
+ # ---------------------------------------------------------------------------
336
+ # Conjoining→compatibility is a direct table. Compatibility→conjoining needs
337
+ # position: compatibility jamo do not distinguish onset from coda (both ㄱ are
338
+ # U+3131), so a consonant is treated as an onset when a vowel follows,
339
+ # otherwise as a coda — deterministic over well-formed jamo sequences.
340
+
341
+ def _jamo_tables():
342
+ from scriptconv.translit import _ONSETS as _CO, _VOWELS as _CV, _CODAS as _CT
343
+ conj_to_compat = {}
344
+ compat_onset = {}
345
+ compat_vowel = {}
346
+ compat_coda = {}
347
+ for i, ch in enumerate(_CO):
348
+ conj_to_compat[chr(0x1100 + i)] = ch
349
+ compat_onset[ch] = chr(0x1100 + i)
350
+ for i, ch in enumerate(_CV):
351
+ conj_to_compat[chr(0x1161 + i)] = ch
352
+ compat_vowel[ch] = chr(0x1161 + i)
353
+ for i, ch in enumerate(_CT):
354
+ if ch:
355
+ conj_to_compat[chr(0x11A8 + i - 1)] = ch
356
+ compat_coda[ch] = chr(0x11A8 + i - 1)
357
+ return conj_to_compat, compat_onset, compat_vowel, compat_coda
358
+
359
+
360
+ _JAMO_TABLES = None
361
+
362
+
363
+ def _jamo_conjoining_to_compat(text: str) -> str:
364
+ global _JAMO_TABLES
365
+ if _JAMO_TABLES is None:
366
+ _JAMO_TABLES = _jamo_tables()
367
+ table = _JAMO_TABLES[0]
368
+ return "".join(table.get(c, c) for c in text)
369
+
370
+
371
+ def _jamo_compat_to_conjoining(text: str) -> str:
372
+ global _JAMO_TABLES
373
+ if _JAMO_TABLES is None:
374
+ _JAMO_TABLES = _jamo_tables()
375
+ _, onset, vowel, coda = _JAMO_TABLES
376
+ out = []
377
+ for i, c in enumerate(text):
378
+ if c in vowel:
379
+ out.append(vowel[c])
380
+ elif c in onset or c in coda:
381
+ nxt = text[i + 1] if i + 1 < len(text) else ""
382
+ if nxt in vowel and c in onset:
383
+ out.append(onset[c])
384
+ elif c in coda:
385
+ out.append(coda[c])
386
+ else:
387
+ out.append(onset[c])
388
+ else:
389
+ out.append(c)
390
+ return "".join(out)
391
+
392
+
393
+ def _jamo_detect(text: str) -> Optional[str]:
394
+ for c in text:
395
+ cp = ord(c)
396
+ if 0x1100 <= cp <= 0x11FF:
397
+ return "conjoining"
398
+ if 0x3130 <= cp <= 0x318F:
399
+ return "compatibility"
400
+ return None
401
+
402
+
403
+ # ---------------------------------------------------------------------------
404
+ # Transition matrix and operations
405
+ # ---------------------------------------------------------------------------
406
+
407
+ _TRANSITIONS: Dict[Tuple[str, str, str], Callable[[str], str]] = {
408
+ ("tashkeel", "marked", "none"): _range_strip(_TASHKEEL_RANGES),
409
+ ("kashida", "marked", "none"): _range_strip(_KASHIDA_RANGES),
410
+ ("quranic-marks", "marked", "none"): _range_strip(_QURANIC_RANGES),
411
+ ("niqqud", "marked", "none"): _range_strip(_NIQQUD_RANGES),
412
+ ("teamim", "marked", "none"): _range_strip(_TEAMIM_RANGES),
413
+ ("wakachigaki", "marked", "none"): _wakachigaki_strip,
414
+ ("wakachigaki", "none", "marked"): _wakachigaki_apply,
415
+ ("pinyin-tone", "mark", "number"): _pinyin_mark_to_number,
416
+ ("pinyin-tone", "number", "mark"): _pinyin_number_to_mark,
417
+ ("pinyin-tone", "mark", "none"): _pinyin_strip,
418
+ ("pinyin-tone", "number", "none"): _pinyin_strip,
419
+ ("jamo-form", "conjoining", "compatibility"): _jamo_conjoining_to_compat,
420
+ ("jamo-form", "compatibility", "conjoining"): _jamo_compat_to_conjoining,
421
+ }
422
+
423
+
424
+ def restyle(text: str, convention_id: str, to: str,
425
+ frm: Optional[str] = None) -> str:
426
+ """Rewrite *text* from one style of a convention to another.
427
+
428
+ ``frm`` defaults to the convention's marked/default style for strips and
429
+ is required where the source style is not inferable. Unsupported
430
+ transitions raise :class:`ValueError`; transitions that need a missing
431
+ optional dependency raise :class:`ImportError` with an install hint.
432
+ """
433
+ conv = _conv(convention_id)
434
+ if to not in conv.styles and to != "none":
435
+ raise ValueError(f"{convention_id!r} has styles {conv.styles}, not {to!r}")
436
+ if frm is None:
437
+ if conv.id == "jamo-form":
438
+ frm = _jamo_detect(text) or conv.default_style
439
+ else:
440
+ sources = sorted({f for (c, f, t) in _TRANSITIONS
441
+ if c == convention_id and t == to})
442
+ if len(sources) == 1:
443
+ frm = sources[0]
444
+ elif conv.default_style != to:
445
+ frm = conv.default_style
446
+ else:
447
+ raise ValueError(
448
+ f"{convention_id!r}: source style for ->{to!r} is ambiguous "
449
+ f"({', '.join(sources)}); pass frm=")
450
+ if frm == to:
451
+ return text
452
+ fn = _TRANSITIONS.get((convention_id, frm, to))
453
+ if fn is None:
454
+ supported = sorted(f"{f}->{t}" for (c, f, t) in _TRANSITIONS
455
+ if c == convention_id)
456
+ raise ValueError(
457
+ f"{convention_id!r} does not define {frm!r}->{to!r}; "
458
+ f"supported: {', '.join(supported)}")
459
+ return fn(text)
460
+
461
+
462
+ def strip(text: str, convention_id: str) -> str:
463
+ """Remove a convention's decoration (restyle to ``"none"``)."""
464
+ conv = _conv(convention_id)
465
+ if "none" not in conv.styles:
466
+ raise ValueError(f"{convention_id!r} has no unmarked style; "
467
+ f"styles: {conv.styles}")
468
+ frm = "mark" if conv.id == "pinyin-tone" else "marked"
469
+ if conv.id == "pinyin-tone":
470
+ # digits and marks are both handled by the strip transition
471
+ return _pinyin_strip(text)
472
+ return restyle(text, convention_id, "none", frm=frm)
473
+
474
+
475
+ def apply(text: str, convention_id: str, style: Optional[str] = None) -> str:
476
+ """Add a convention's decoration to unmarked text (restyle from ``"none"``)."""
477
+ conv = _conv(convention_id)
478
+ if style is None:
479
+ marked = [s for s in conv.styles if s != "none"]
480
+ style = conv.default_style if conv.default_style != "none" else marked[0]
481
+ return restyle(text, convention_id, style, frm="none")
482
+
483
+
484
+ def detect(text: str, convention_id: str) -> Optional[str]:
485
+ """Current style of *text* under a convention, or None when not character-evident.
486
+
487
+ For binary decorations this means "any mark present" — a single shadda in
488
+ a paragraph reports ``"marked"``.
489
+ """
490
+ conv = _conv(convention_id)
491
+ if not conv.detectable:
492
+ return None
493
+ if conv.id == "jamo-form":
494
+ return _jamo_detect(text)
495
+ return "marked" if any(_in_ranges(ord(c), conv.ranges) for c in text) else "none"
@@ -77,7 +77,9 @@ def to_hiragana(text: str, keep_katakana: bool = False,
77
77
  katakana tokens keep their original script (useful when katakana marks
78
78
  loanwords deliberately) instead of being folded into hiragana. With
79
79
  ``segment=True``, dictionary tokens are joined with spaces (wakachigaki,
80
- the spaced orthographic mode) instead of concatenated.
80
+ the spaced orthographic mode) instead of concatenated — equivalent to
81
+ ``scriptconv.apply(..., "wakachigaki")`` over this token stream
82
+ (:mod:`scriptconv.conventions`).
81
83
  """
82
84
  out = []
83
85
  for tok in tokens(text):
@@ -134,7 +136,10 @@ def to_pinyin(text: str, tone: str = "mark") -> str:
134
136
  Hanzi are resolved to their dictionary reading (heteronyms decided by
135
137
  pypinyin's phrase dictionary); non-Chinese characters pass through.
136
138
  ``tone`` selects the tone spelling: ``"mark"`` (``zhōng``, default),
137
- ``"number"`` (``zhong1``) or ``"none"`` (``zhong``).
139
+ ``"number"`` (``zhong1``) or ``"none"`` (``zhong``) — the styles of the
140
+ ``pinyin-tone`` convention (:mod:`scriptconv.conventions`); use
141
+ :func:`scriptconv.restyle` to convert existing pinyin between them
142
+ without the ``zh`` extra.
138
143
  """
139
144
  return _pinyin(text, tone)
140
145
 
@@ -52,6 +52,8 @@ def decompose_hangul(
52
52
  text:
53
53
  Input string.
54
54
  form:
55
+ A style of the ``jamo-form`` convention
56
+ (:mod:`scriptconv.conventions`);
55
57
  ``"compatibility"`` (default) emits Hangul Compatibility Jamo
56
58
  (U+3130 block, e.g. ``ㄱ``); ``"conjoining"`` emits conjoining
57
59
  jamo (U+1100/1161/11A8 blocks) that recombine into syllables.
@@ -2,7 +2,7 @@
2
2
  VERSION_MAJOR = 0
3
3
  VERSION_MINOR = 0
4
4
  VERSION_BUILD = 3
5
- VERSION_ALPHA = 6
5
+ VERSION_ALPHA = 7
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.3a6
3
+ Version: 0.0.3a7
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
@@ -5,6 +5,7 @@ requirements.txt
5
5
  scriptconv/__init__.py
6
6
  scriptconv/__main__.py
7
7
  scriptconv/cangjie.py
8
+ scriptconv/conventions.py
8
9
  scriptconv/notation.py
9
10
  scriptconv/py.typed
10
11
  scriptconv/readings.py
@@ -20,6 +21,7 @@ scriptconv/data/__init__.py
20
21
  scriptconv/data/cangjie5_tc.tsv.gz
21
22
  tests/test_cangjie.py
22
23
  tests/test_cli.py
24
+ tests/test_conventions.py
23
25
  tests/test_examples.py
24
26
  tests/test_notation.py
25
27
  tests/test_readings.py
@@ -86,3 +86,25 @@ def test_convert_bad_notation_clean_error(capsys):
86
86
  msg = str(exc.value)
87
87
  assert msg.startswith("error:")
88
88
  assert "valid notations:" in msg
89
+
90
+
91
+ def test_strip_tashkeel(capsys):
92
+ assert main(["strip", "tashkeel", "مُحَمَّد"]) == 0
93
+ assert capsys.readouterr().out.strip() == "محمد"
94
+
95
+
96
+ def test_restyle_pinyin(capsys):
97
+ assert main(["restyle", "pinyin-tone", "mark", "zhong1 guo2"]) == 0
98
+ assert capsys.readouterr().out.strip() == "zhōng guó"
99
+
100
+
101
+ def test_conventions_listing_filtered(capsys):
102
+ assert main(["conventions", "--script", "Arab"]) == 0
103
+ out = capsys.readouterr().out
104
+ assert "tashkeel" in out and "kashida" in out and "niqqud" not in out
105
+
106
+
107
+ def test_strip_unknown_convention_clean_error():
108
+ with pytest.raises(SystemExit) as exc:
109
+ main(["strip", "bogus", "text"])
110
+ assert str(exc.value).startswith("error:")
@@ -0,0 +1,210 @@
1
+ import unicodedata
2
+ import unittest
3
+
4
+ from scriptconv import (
5
+ CONVENTION_REGISTRY,
6
+ apply,
7
+ conventions_for,
8
+ decompose_hangul,
9
+ detect_convention,
10
+ restyle,
11
+ strip,
12
+ )
13
+
14
+
15
+ class TestRegistryInvariants(unittest.TestCase):
16
+ def test_default_style_is_a_declared_style(self):
17
+ for c in CONVENTION_REGISTRY.values():
18
+ self.assertIn(c.default_style, c.styles, c.id)
19
+
20
+ def test_sibling_conventions_on_same_script_have_disjoint_ranges(self):
21
+ convs = list(CONVENTION_REGISTRY.values())
22
+ for i, a in enumerate(convs):
23
+ for b in convs[i + 1:]:
24
+ if not set(a.scripts) & set(b.scripts):
25
+ continue
26
+ a_cps = {cp for lo, hi in a.ranges for cp in range(lo, hi + 1)}
27
+ b_cps = {cp for lo, hi in b.ranges for cp in range(lo, hi + 1)}
28
+ self.assertFalse(a_cps & b_cps, f"{a.id} overlaps {b.id}")
29
+
30
+ def test_strippable_conventions_declare_none_style(self):
31
+ for c in CONVENTION_REGISTRY.values():
32
+ if c.id == "jamo-form":
33
+ self.assertNotIn("none", c.styles) # no unmarked state exists
34
+ else:
35
+ self.assertIn("none", c.styles, c.id)
36
+
37
+ def test_conventions_for_surfaces_system(self):
38
+ latn = conventions_for("Latn")
39
+ self.assertEqual([c.id for c in latn], ["pinyin-tone"])
40
+ self.assertEqual(latn[0].system, "pinyin")
41
+
42
+ def test_unknown_convention_raises_with_known_ids(self):
43
+ with self.assertRaises(ValueError) as ctx:
44
+ strip("x", "vowel-harmony")
45
+ self.assertIn("tashkeel", str(ctx.exception))
46
+
47
+
48
+ class TestTashkeel(unittest.TestCase):
49
+ def test_strips_full_vocalization(self):
50
+ self.assertEqual(strip("مُحَمَّدٌ", "tashkeel"), "محمد")
51
+
52
+ def test_nfd_hamza_and_madda_survive(self):
53
+ # آ أ إ decomposed to alef + combining madda/hamza — letter identity,
54
+ # must NOT be stripped
55
+ nfd = unicodedata.normalize("NFD", "آمين أب إن")
56
+ out = strip(nfd, "tashkeel")
57
+ self.assertEqual(unicodedata.normalize("NFC", out), "آمين أب إن")
58
+
59
+ def test_dagger_alif_stripped(self):
60
+ self.assertEqual(strip("رحمٰن", "tashkeel"), "رحمن")
61
+
62
+ def test_lone_shadda_detects_marked(self):
63
+ self.assertEqual(detect_convention("plain text مدّ here", "tashkeel"), "marked")
64
+ self.assertEqual(detect_convention("محمد", "tashkeel"), "none")
65
+
66
+ def test_no_apply_transition(self):
67
+ with self.assertRaises(ValueError):
68
+ apply("محمد", "tashkeel")
69
+
70
+
71
+ class TestKashidaAndQuranic(unittest.TestCase):
72
+ def test_kashida_stripped_independently_of_tashkeel(self):
73
+ self.assertEqual(strip("محمـــد", "kashida"), "محمد")
74
+ # tashkeel strip leaves tatweel alone
75
+ self.assertEqual(strip("محمـــد", "tashkeel"), "محمـــد")
76
+
77
+ def test_quranic_sajdah_sign_stripped(self):
78
+ text = "قرأ۩"
79
+ self.assertEqual(strip(text, "quranic-marks"), "قرأ")
80
+
81
+
82
+ class TestHebrew(unittest.TestCase):
83
+ def test_niqqud_stripped_points_and_dagesh(self):
84
+ self.assertEqual(strip("שָׁלוֹם", "niqqud"), "שלום")
85
+
86
+ def test_maqaf_and_sof_pasuq_survive_both_strips(self):
87
+ text = "כָּל־הָעוֹלָם׃"
88
+ for conv in ("niqqud", "teamim"):
89
+ out = strip(text, conv)
90
+ self.assertIn("־", out)
91
+ self.assertIn("׃", out)
92
+
93
+ def test_teamim_stripped_niqqud_kept(self):
94
+ # etnahta U+0591 removed, qamats U+05B8 kept
95
+ text = "וַ֑יֹּאמֶר"
96
+ out = strip(text, "teamim")
97
+ self.assertNotIn("֑", out)
98
+ self.assertIn("ַ", unicodedata.normalize("NFD", out))
99
+
100
+ def test_detect_distinguishes_layers(self):
101
+ pointed_only = "שָׁלוֹם"
102
+ self.assertEqual(detect_convention(pointed_only, "niqqud"), "marked")
103
+ self.assertEqual(detect_convention(pointed_only, "teamim"), "none")
104
+
105
+
106
+ class TestWakachigaki(unittest.TestCase):
107
+ def test_strip_removes_spaces_between_japanese(self):
108
+ self.assertEqual(strip("わたし は がくせい です", "wakachigaki"),
109
+ "わたしはがくせいです")
110
+
111
+ def test_ideographic_space_stripped(self):
112
+ self.assertEqual(strip("東京 大阪", "wakachigaki"), "東京大阪")
113
+
114
+ def test_spaces_next_to_latin_survive(self):
115
+ self.assertEqual(strip("きょうは good day", "wakachigaki"),
116
+ "きょうは good day")
117
+
118
+ def test_spaces_next_to_digits_survive(self):
119
+ self.assertEqual(strip("第 3 章", "wakachigaki"), "第 3 章")
120
+
121
+ def test_not_detectable(self):
122
+ self.assertIsNone(detect_convention("わたし は", "wakachigaki"))
123
+
124
+ def test_apply_strip_round_trip_pure_japanese(self):
125
+ text = "私は学生です"
126
+ spaced = apply(text, "wakachigaki")
127
+ self.assertIn(" ", spaced)
128
+ self.assertEqual(strip(spaced, "wakachigaki"), text)
129
+
130
+
131
+ class TestPinyinTone(unittest.TestCase):
132
+ def test_number_to_mark_basic(self):
133
+ self.assertEqual(restyle("zhong1 guo2 ren2", "pinyin-tone", "mark"),
134
+ "zhōng guó rén")
135
+
136
+ def test_umlaut_v_and_colon_variants(self):
137
+ self.assertEqual(restyle("lv4 nu:3", "pinyin-tone", "mark"), "lǜ nǚ")
138
+
139
+ def test_capitals_and_apostrophe(self):
140
+ self.assertEqual(restyle("Xi1'an1", "pinyin-tone", "mark"), "Xī'ān")
141
+
142
+ def test_erhua_and_syllabic_nasal(self):
143
+ self.assertEqual(restyle("huar2", "pinyin-tone", "mark"), "huár")
144
+ self.assertEqual(restyle("m2", "pinyin-tone", "mark"), "ḿ")
145
+
146
+ def test_ou_placement_rule(self):
147
+ self.assertEqual(restyle("gou3", "pinyin-tone", "mark"), "gǒu")
148
+ self.assertEqual(restyle("guo2", "pinyin-tone", "mark"), "guó")
149
+ self.assertEqual(restyle("jiu3", "pinyin-tone", "mark"), "jiǔ")
150
+
151
+ def test_neutral_tone_unmarked(self):
152
+ self.assertEqual(restyle("ma5 de0", "pinyin-tone", "mark"), "ma de")
153
+
154
+ def test_mark_to_number_compliant(self):
155
+ self.assertEqual(restyle("zhōng guó", "pinyin-tone", "number", frm="mark"),
156
+ "zhong1 guo2")
157
+ self.assertEqual(restyle("Xī'ān", "pinyin-tone", "number", frm="mark"),
158
+ "Xi1'an1")
159
+
160
+ def test_mark_to_number_unapostrophized_best_effort(self):
161
+ # documented best-effort split: cut before the longest valid onset
162
+ self.assertEqual(restyle("fāngān", "pinyin-tone", "number", frm="mark"),
163
+ "fan1gan1")
164
+
165
+ def test_strip_handles_both_styles_and_spares_ordinary_digits(self):
166
+ self.assertEqual(strip("Nǐ hǎo ma5", "pinyin-tone"), "Ni hao ma")
167
+ self.assertEqual(strip("di4 3 ceng2", "pinyin-tone"), "di 3 ceng")
168
+
169
+ def test_restyle_to_mark_infers_number_source(self):
170
+ self.assertEqual(restyle("hao3", "pinyin-tone", "mark"), "hǎo")
171
+
172
+
173
+ class TestJamoForm(unittest.TestCase):
174
+ def test_round_trip_byte_identical(self):
175
+ compat = decompose_hangul("한국말")
176
+ conj = restyle(compat, "jamo-form", "conjoining")
177
+ self.assertEqual(restyle(conj, "jamo-form", "compatibility"), compat)
178
+
179
+ def test_agrees_with_decompose_hangul(self):
180
+ for word in ("안녕", "값", "한국말"):
181
+ self.assertEqual(
182
+ restyle(decompose_hangul(word), "jamo-form", "conjoining"),
183
+ decompose_hangul(word, form="conjoining"))
184
+
185
+ def test_detect_repertoire(self):
186
+ self.assertEqual(detect_convention("ㄱㅏ", "jamo-form"), "compatibility")
187
+ self.assertEqual(detect_convention("가", "jamo-form"), "conjoining")
188
+ self.assertIsNone(detect_convention("hangul-free", "jamo-form"))
189
+
190
+ def test_no_none_style(self):
191
+ with self.assertRaises(ValueError):
192
+ strip("ㄱㅏ", "jamo-form")
193
+
194
+
195
+ class TestRestyleContract(unittest.TestCase):
196
+ def test_unsupported_transition_names_supported_ones(self):
197
+ with self.assertRaises(ValueError) as ctx:
198
+ restyle("x", "tashkeel", "marked", frm="none")
199
+ self.assertIn("marked->none", str(ctx.exception))
200
+
201
+ def test_same_style_is_identity(self):
202
+ self.assertEqual(restyle("abc", "pinyin-tone", "mark", frm="mark"), "abc")
203
+
204
+ def test_invalid_target_style_raises(self):
205
+ with self.assertRaises(ValueError):
206
+ restyle("abc", "pinyin-tone", "cyrillic")
207
+
208
+
209
+ if __name__ == "__main__":
210
+ unittest.main()
File without changes
File without changes
File without changes