scriptconv 0.0.3a5__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.
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/PKG-INFO +1 -1
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/scriptconv/__init__.py +25 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/scriptconv/__main__.py +47 -0
- scriptconv-0.0.3a7/scriptconv/conventions.py +495 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/scriptconv/readings.py +47 -13
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/scriptconv/translit.py +2 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/scriptconv/version.py +1 -1
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/scriptconv.egg-info/PKG-INFO +1 -1
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/scriptconv.egg-info/SOURCES.txt +2 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/tests/test_cli.py +22 -0
- scriptconv-0.0.3a7/tests/test_conventions.py +210 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/tests/test_readings.py +42 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/LICENSE +0 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/README.md +0 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/pyproject.toml +0 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/requirements.txt +0 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/scriptconv/cangjie.py +0 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/scriptconv/data/__init__.py +0 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/scriptconv/data/cangjie5_tc.tsv.gz +0 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/scriptconv/notation.py +0 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/scriptconv/py.typed +0 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/scriptconv/scripts.py +0 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/scriptconv.egg-info/dependency_links.txt +0 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/scriptconv.egg-info/requires.txt +0 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/scriptconv.egg-info/top_level.txt +0 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/setup.cfg +0 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/tests/test_cangjie.py +0 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/tests/test_examples.py +0 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/tests/test_notation.py +0 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/tests/test_readings_zh.py +0 -0
- {scriptconv-0.0.3a5 → scriptconv-0.0.3a7}/tests/test_scripts.py +0 -0
- {scriptconv-0.0.3a5 → 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.
|
|
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.
|
|
@@ -68,11 +72,22 @@ from scriptconv.translit import (
|
|
|
68
72
|
kana_to_hira,
|
|
69
73
|
)
|
|
70
74
|
from scriptconv.readings import (
|
|
75
|
+
ReadingToken,
|
|
76
|
+
tokens,
|
|
71
77
|
to_hiragana,
|
|
72
78
|
to_katakana,
|
|
73
79
|
to_pinyin,
|
|
74
80
|
to_bopomofo,
|
|
75
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
|
+
)
|
|
76
91
|
from scriptconv.cangjie import (
|
|
77
92
|
cangjie_code,
|
|
78
93
|
to_cangjie,
|
|
@@ -117,8 +132,18 @@ __all__ = [
|
|
|
117
132
|
"hira_to_kana",
|
|
118
133
|
"kana_to_hira",
|
|
119
134
|
# readings (dictionary-backed, needs scriptconv[ja])
|
|
135
|
+
"ReadingToken",
|
|
136
|
+
"tokens",
|
|
120
137
|
"to_hiragana",
|
|
121
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",
|
|
122
147
|
# cangjie (vendored Cangjie5 table)
|
|
123
148
|
"cangjie_code",
|
|
124
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"
|
|
@@ -22,7 +22,17 @@ unconverted text.
|
|
|
22
22
|
"""
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
from typing import Iterator, NamedTuple
|
|
26
|
+
|
|
27
|
+
__all__ = ["ReadingToken", "tokens", "to_hiragana", "to_katakana",
|
|
28
|
+
"to_pinyin", "to_bopomofo"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ReadingToken(NamedTuple):
|
|
32
|
+
"""One dictionary-segmented token of Japanese text and its kana readings."""
|
|
33
|
+
orig: str
|
|
34
|
+
hira: str
|
|
35
|
+
kana: str
|
|
26
36
|
|
|
27
37
|
_kakasi = None
|
|
28
38
|
|
|
@@ -44,32 +54,53 @@ def _converter():
|
|
|
44
54
|
return _kakasi
|
|
45
55
|
|
|
46
56
|
|
|
47
|
-
def
|
|
57
|
+
def tokens(text: str) -> Iterator[ReadingToken]:
|
|
58
|
+
"""Iterate the dictionary's segmentation of *text* with per-token readings.
|
|
59
|
+
|
|
60
|
+
Each token is a :class:`ReadingToken` with the original surface form and
|
|
61
|
+
its hiragana and katakana readings. This is the primitive under
|
|
62
|
+
:func:`to_hiragana`/:func:`to_katakana`; use it directly when the consumer
|
|
63
|
+
needs token boundaries — e.g. word segmentation (wakachigaki) or
|
|
64
|
+
reading-dependent post-processing — rather than a joined string.
|
|
65
|
+
"""
|
|
66
|
+
for t in _converter().convert(text):
|
|
67
|
+
if t["orig"]:
|
|
68
|
+
yield ReadingToken(t["orig"], t["hira"], t["kana"])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def to_hiragana(text: str, keep_katakana: bool = False,
|
|
72
|
+
segment: bool = False) -> str:
|
|
48
73
|
"""Respell Japanese text in hiragana.
|
|
49
74
|
|
|
50
75
|
Kanji are resolved to their dictionary reading; existing kana and any
|
|
51
76
|
non-Japanese characters pass through. With ``keep_katakana=True``,
|
|
52
77
|
katakana tokens keep their original script (useful when katakana marks
|
|
53
|
-
loanwords deliberately) instead of being folded into hiragana.
|
|
78
|
+
loanwords deliberately) instead of being folded into hiragana. With
|
|
79
|
+
``segment=True``, dictionary tokens are joined with spaces (wakachigaki,
|
|
80
|
+
the spaced orthographic mode) instead of concatenated — equivalent to
|
|
81
|
+
``scriptconv.apply(..., "wakachigaki")`` over this token stream
|
|
82
|
+
(:mod:`scriptconv.conventions`).
|
|
54
83
|
"""
|
|
55
84
|
out = []
|
|
56
|
-
for
|
|
57
|
-
orig
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
out.append(orig)
|
|
85
|
+
for tok in tokens(text):
|
|
86
|
+
if keep_katakana and tok.orig and all(
|
|
87
|
+
ord(c) in _KANA or not _is_japanese(c) for c in tok.orig):
|
|
88
|
+
out.append(tok.orig)
|
|
61
89
|
else:
|
|
62
|
-
out.append(
|
|
63
|
-
return "".join(out)
|
|
90
|
+
out.append(tok.hira)
|
|
91
|
+
return " ".join(" ".join(out).split()) if segment else "".join(out)
|
|
64
92
|
|
|
65
93
|
|
|
66
|
-
def to_katakana(text: str) -> str:
|
|
94
|
+
def to_katakana(text: str, segment: bool = False) -> str:
|
|
67
95
|
"""Respell Japanese text in katakana.
|
|
68
96
|
|
|
69
97
|
Kanji are resolved to their dictionary reading; existing kana are
|
|
70
98
|
transposed to katakana and any non-Japanese characters pass through.
|
|
99
|
+
With ``segment=True``, dictionary tokens are joined with spaces
|
|
100
|
+
(wakachigaki) instead of concatenated.
|
|
71
101
|
"""
|
|
72
|
-
|
|
102
|
+
kana = [tok.kana for tok in tokens(text)]
|
|
103
|
+
return " ".join(" ".join(kana).split()) if segment else "".join(kana)
|
|
73
104
|
|
|
74
105
|
|
|
75
106
|
def _is_japanese(ch: str) -> bool:
|
|
@@ -105,7 +136,10 @@ def to_pinyin(text: str, tone: str = "mark") -> str:
|
|
|
105
136
|
Hanzi are resolved to their dictionary reading (heteronyms decided by
|
|
106
137
|
pypinyin's phrase dictionary); non-Chinese characters pass through.
|
|
107
138
|
``tone`` selects the tone spelling: ``"mark"`` (``zhōng``, default),
|
|
108
|
-
``"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.
|
|
109
143
|
"""
|
|
110
144
|
return _pinyin(text, tone)
|
|
111
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scriptconv
|
|
3
|
-
Version: 0.0.
|
|
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()
|
|
@@ -71,3 +71,45 @@ class TestDependencyBoundary(unittest.TestCase):
|
|
|
71
71
|
|
|
72
72
|
if __name__ == "__main__":
|
|
73
73
|
unittest.main()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestTokens(unittest.TestCase):
|
|
77
|
+
def test_yields_reading_tokens_with_all_forms(self):
|
|
78
|
+
from scriptconv import ReadingToken, tokens
|
|
79
|
+
toks = list(tokens("東京タワー"))
|
|
80
|
+
self.assertTrue(all(isinstance(t, ReadingToken) for t in toks))
|
|
81
|
+
origs = [t.orig for t in toks]
|
|
82
|
+
self.assertIn("東京", origs)
|
|
83
|
+
tokyo = toks[origs.index("東京")]
|
|
84
|
+
self.assertEqual(tokyo.hira, "とうきょう")
|
|
85
|
+
self.assertEqual(tokyo.kana, "トウキョウ")
|
|
86
|
+
|
|
87
|
+
def test_tokens_cover_input_exactly(self):
|
|
88
|
+
from scriptconv import tokens
|
|
89
|
+
text = "私はcoffeeが好き"
|
|
90
|
+
self.assertEqual("".join(t.orig for t in tokens(text)), text)
|
|
91
|
+
|
|
92
|
+
def test_empty_string_yields_nothing(self):
|
|
93
|
+
from scriptconv import tokens
|
|
94
|
+
self.assertEqual(list(tokens("")), [])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TestSegmentFlag(unittest.TestCase):
|
|
98
|
+
def test_hiragana_wakachigaki(self):
|
|
99
|
+
from scriptconv import to_hiragana
|
|
100
|
+
self.assertEqual(to_hiragana("私は学生です", segment=True),
|
|
101
|
+
"わたし は がくせい です")
|
|
102
|
+
|
|
103
|
+
def test_katakana_wakachigaki(self):
|
|
104
|
+
from scriptconv import to_katakana
|
|
105
|
+
self.assertEqual(to_katakana("私は学生です", segment=True),
|
|
106
|
+
"ワタシ ハ ガクセイ デス")
|
|
107
|
+
|
|
108
|
+
def test_segment_false_is_default_concatenation(self):
|
|
109
|
+
from scriptconv import to_hiragana
|
|
110
|
+
self.assertEqual(to_hiragana("私は学生です"), "わたしはがくせいです")
|
|
111
|
+
|
|
112
|
+
def test_segment_collapses_original_whitespace(self):
|
|
113
|
+
from scriptconv import to_hiragana
|
|
114
|
+
out = to_hiragana("東京 大阪", segment=True)
|
|
115
|
+
self.assertNotIn(" ", out)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|