serbian-translit 0.4.2__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.
- serbian_translit-0.4.2/LICENSE +21 -0
- serbian_translit-0.4.2/PKG-INFO +94 -0
- serbian_translit-0.4.2/README.md +57 -0
- serbian_translit-0.4.2/pyproject.toml +150 -0
- serbian_translit-0.4.2/serbian_translit/__init__.py +4 -0
- serbian_translit-0.4.2/serbian_translit/case.py +34 -0
- serbian_translit-0.4.2/serbian_translit/cnr.py +15 -0
- serbian_translit-0.4.2/serbian_translit/data/rules.yaml +185 -0
- serbian_translit-0.4.2/serbian_translit/letters.py +34 -0
- serbian_translit-0.4.2/serbian_translit/protection.py +45 -0
- serbian_translit-0.4.2/serbian_translit/roman.py +13 -0
- serbian_translit-0.4.2/serbian_translit/rule.py +71 -0
- serbian_translit-0.4.2/serbian_translit/skip.py +19 -0
- serbian_translit-0.4.2/serbian_translit/srp.py +15 -0
- serbian_translit-0.4.2/serbian_translit/table.py +29 -0
- serbian_translit-0.4.2/serbian_translit.egg-info/PKG-INFO +94 -0
- serbian_translit-0.4.2/serbian_translit.egg-info/SOURCES.txt +20 -0
- serbian_translit-0.4.2/serbian_translit.egg-info/dependency_links.txt +1 -0
- serbian_translit-0.4.2/serbian_translit.egg-info/requires.txt +11 -0
- serbian_translit-0.4.2/serbian_translit.egg-info/top_level.txt +1 -0
- serbian_translit-0.4.2/setup.cfg +4 -0
- serbian_translit-0.4.2/tests/test_transliterate.py +41 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Apakabarlabs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: serbian-translit
|
|
3
|
+
Version: 0.4.2
|
|
4
|
+
Summary: Deterministic Serbian and Montenegrin script conversion (Cyrillic ↔ Latin).
|
|
5
|
+
Author: Apakabarlabs
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/apakabarlabs/serbian-translit-python
|
|
8
|
+
Project-URL: Documentation, https://apakabarlabs.github.io/serbian-translit-python/serbian_translit.html
|
|
9
|
+
Project-URL: Changelog, https://github.com/apakabarlabs/serbian-translit-python/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Issues, https://github.com/apakabarlabs/serbian-translit-python/issues
|
|
11
|
+
Keywords: serbian,montenegrin,transliteration,script,conversion,cyrillic,latin,bcms
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Natural Language :: Serbian
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: PyYAML>=6.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
31
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
32
|
+
Requires-Dist: types-PyYAML>=6.0; extra == "dev"
|
|
33
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
34
|
+
Requires-Dist: twine>=6.0; extra == "dev"
|
|
35
|
+
Requires-Dist: pdoc>=16.0; extra == "dev"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
[](https://github.com/apakabarlabs/serbian-translit-python/actions/workflows/tests.yml)
|
|
39
|
+
[](https://apakabarlabs.github.io/serbian-translit-python/serbian_translit.html)
|
|
40
|
+
|
|
41
|
+
# serbian-translit
|
|
42
|
+
|
|
43
|
+
Deterministic Serbian and Montenegrin script conversion, Cyrillic ↔ Latin. Case preservation, digraph handling, quoted-region protection, Roman-numeral and non-native-word filtering.
|
|
44
|
+
|
|
45
|
+
Both official scripts of Serbian (and Montenegrin) map one-to-one at the letter level: `љ↔lj`, `њ↔nj`, `џ↔dž`, plus `с́↔ś`, `з́↔ź` for Montenegrin. The library plays the pairing from a YAML table; there is no per-language code path in the engine.
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
Install the released package from PyPI:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install serbian-translit
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from serbian_translit import srp, cnr
|
|
59
|
+
|
|
60
|
+
srp.to_cyr("Njujork") # 'Њујорк'
|
|
61
|
+
srp.to_cyr("LJUBAV") # 'ЉУБАВ'
|
|
62
|
+
srp.to_cyr("New York") # 'New York' (word skipped, has non-native letters)
|
|
63
|
+
srp.to_cyr('grupa „AC/DC"') # 'група „AC/DC"' (quoted region preserved)
|
|
64
|
+
srp.to_lat("Њујорк") # 'Njujork'
|
|
65
|
+
|
|
66
|
+
cnr.to_cyr("śever") # 'с́евер' (с + U+0301)
|
|
67
|
+
cnr.to_lat("с́евер") # 'śever'
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Behaviour
|
|
71
|
+
|
|
72
|
+
- **Digraphs** `lj`, `nj`, `dž` (Latin) ↔ `љ`, `њ`, `џ` (Cyrillic) with case preservation (`Nj` in title-case position, `NJ` inside all-caps).
|
|
73
|
+
- **Montenegrin extras** `ś`, `ź` ↔ `с́`, `з́` (base letter + combining acute U+0301; no precomposed codepoints exist).
|
|
74
|
+
- **Đ variants** `Đ` (U+0110), `đ` (U+0111), `Ð` (U+00D0 Eth), `ð` (U+00F0 eth) all map to `Ђ`/`ђ`.
|
|
75
|
+
- **Roman numerals** (`II`, `XIV`, `XX`) stay in Latin regardless of direction.
|
|
76
|
+
- **Words with non-native letters** (Latin `w`, `x`, `y`, `q`) are skipped whole; treated as foreign inclusions.
|
|
77
|
+
- **Quoted regions** (`"…"`, `„…"`, `“…”`, `«…»`) are preserved verbatim so brand names and foreign quotes survive round-trip.
|
|
78
|
+
- **Non-alphabetic content** (numbers, punctuation, whitespace) is left unchanged.
|
|
79
|
+
|
|
80
|
+
## Rules and tests
|
|
81
|
+
|
|
82
|
+
Rules live in [`serbian_translit/data/rules.yaml`](serbian_translit/data/rules.yaml); test cases in [`tests/tests.yaml`](tests/tests.yaml). Both files are the source of truth shared with the [Swift](https://github.com/apakabarlabs/serbian-translit-swift) and (upcoming) Kotlin ports so behaviour stays identical across languages.
|
|
83
|
+
|
|
84
|
+
## Documentation
|
|
85
|
+
|
|
86
|
+
The [API reference](https://apakabarlabs.github.io/serbian-translit-python/serbian_translit.html) is generated from the public Python API and deployed by GitHub Actions.
|
|
87
|
+
|
|
88
|
+
## Lines of Code
|
|
89
|
+
|
|
90
|
+
<picture>
|
|
91
|
+
<source media="(prefers-color-scheme: dark)" srcset=".github/loc-history-dark.svg">
|
|
92
|
+
<source media="(prefers-color-scheme: light)" srcset=".github/loc-history-light.svg">
|
|
93
|
+
<img alt="Lines of Code graph" src=".github/loc-history-light.svg">
|
|
94
|
+
</picture>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[](https://github.com/apakabarlabs/serbian-translit-python/actions/workflows/tests.yml)
|
|
2
|
+
[](https://apakabarlabs.github.io/serbian-translit-python/serbian_translit.html)
|
|
3
|
+
|
|
4
|
+
# serbian-translit
|
|
5
|
+
|
|
6
|
+
Deterministic Serbian and Montenegrin script conversion, Cyrillic ↔ Latin. Case preservation, digraph handling, quoted-region protection, Roman-numeral and non-native-word filtering.
|
|
7
|
+
|
|
8
|
+
Both official scripts of Serbian (and Montenegrin) map one-to-one at the letter level: `љ↔lj`, `њ↔nj`, `џ↔dž`, plus `с́↔ś`, `з́↔ź` for Montenegrin. The library plays the pairing from a YAML table; there is no per-language code path in the engine.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
Install the released package from PyPI:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install serbian-translit
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from serbian_translit import srp, cnr
|
|
22
|
+
|
|
23
|
+
srp.to_cyr("Njujork") # 'Њујорк'
|
|
24
|
+
srp.to_cyr("LJUBAV") # 'ЉУБАВ'
|
|
25
|
+
srp.to_cyr("New York") # 'New York' (word skipped, has non-native letters)
|
|
26
|
+
srp.to_cyr('grupa „AC/DC"') # 'група „AC/DC"' (quoted region preserved)
|
|
27
|
+
srp.to_lat("Њујорк") # 'Njujork'
|
|
28
|
+
|
|
29
|
+
cnr.to_cyr("śever") # 'с́евер' (с + U+0301)
|
|
30
|
+
cnr.to_lat("с́евер") # 'śever'
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Behaviour
|
|
34
|
+
|
|
35
|
+
- **Digraphs** `lj`, `nj`, `dž` (Latin) ↔ `љ`, `њ`, `џ` (Cyrillic) with case preservation (`Nj` in title-case position, `NJ` inside all-caps).
|
|
36
|
+
- **Montenegrin extras** `ś`, `ź` ↔ `с́`, `з́` (base letter + combining acute U+0301; no precomposed codepoints exist).
|
|
37
|
+
- **Đ variants** `Đ` (U+0110), `đ` (U+0111), `Ð` (U+00D0 Eth), `ð` (U+00F0 eth) all map to `Ђ`/`ђ`.
|
|
38
|
+
- **Roman numerals** (`II`, `XIV`, `XX`) stay in Latin regardless of direction.
|
|
39
|
+
- **Words with non-native letters** (Latin `w`, `x`, `y`, `q`) are skipped whole; treated as foreign inclusions.
|
|
40
|
+
- **Quoted regions** (`"…"`, `„…"`, `“…”`, `«…»`) are preserved verbatim so brand names and foreign quotes survive round-trip.
|
|
41
|
+
- **Non-alphabetic content** (numbers, punctuation, whitespace) is left unchanged.
|
|
42
|
+
|
|
43
|
+
## Rules and tests
|
|
44
|
+
|
|
45
|
+
Rules live in [`serbian_translit/data/rules.yaml`](serbian_translit/data/rules.yaml); test cases in [`tests/tests.yaml`](tests/tests.yaml). Both files are the source of truth shared with the [Swift](https://github.com/apakabarlabs/serbian-translit-swift) and (upcoming) Kotlin ports so behaviour stays identical across languages.
|
|
46
|
+
|
|
47
|
+
## Documentation
|
|
48
|
+
|
|
49
|
+
The [API reference](https://apakabarlabs.github.io/serbian-translit-python/serbian_translit.html) is generated from the public Python API and deployed by GitHub Actions.
|
|
50
|
+
|
|
51
|
+
## Lines of Code
|
|
52
|
+
|
|
53
|
+
<picture>
|
|
54
|
+
<source media="(prefers-color-scheme: dark)" srcset=".github/loc-history-dark.svg">
|
|
55
|
+
<source media="(prefers-color-scheme: light)" srcset=".github/loc-history-light.svg">
|
|
56
|
+
<img alt="Lines of Code graph" src=".github/loc-history-light.svg">
|
|
57
|
+
</picture>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "serbian-translit"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Deterministic Serbian and Montenegrin script conversion (Cyrillic ↔ Latin)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Apakabarlabs" }]
|
|
14
|
+
keywords = ["serbian", "montenegrin", "transliteration", "script", "conversion", "cyrillic", "latin", "bcms"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Natural Language :: Serbian",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Text Processing :: Linguistic",
|
|
27
|
+
]
|
|
28
|
+
dependencies = ["PyYAML>=6.0"]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=8.0",
|
|
33
|
+
"pytest-cov>=5.0",
|
|
34
|
+
"ruff>=0.5",
|
|
35
|
+
"mypy>=1.10",
|
|
36
|
+
"types-PyYAML>=6.0",
|
|
37
|
+
"build>=1.0",
|
|
38
|
+
"twine>=6.0",
|
|
39
|
+
"pdoc>=16.0",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/apakabarlabs/serbian-translit-python"
|
|
44
|
+
Documentation = "https://apakabarlabs.github.io/serbian-translit-python/serbian_translit.html"
|
|
45
|
+
Changelog = "https://github.com/apakabarlabs/serbian-translit-python/blob/main/CHANGELOG.md"
|
|
46
|
+
Issues = "https://github.com/apakabarlabs/serbian-translit-python/issues"
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.packages.find]
|
|
49
|
+
include = ["serbian_translit*"]
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.package-data]
|
|
52
|
+
serbian_translit = ["data/rules.yaml"]
|
|
53
|
+
|
|
54
|
+
# Single source of truth for the version. `__init__.py.__version__` is
|
|
55
|
+
# resolved at build time by setuptools; runtime code reads the same
|
|
56
|
+
# constant directly.
|
|
57
|
+
[tool.setuptools.dynamic]
|
|
58
|
+
version = { attr = "serbian_translit.__version__" }
|
|
59
|
+
|
|
60
|
+
[tool.ruff]
|
|
61
|
+
line-length = 120
|
|
62
|
+
# Match the lowest supported runtime (requires-python above). Bumping
|
|
63
|
+
# either without the other splits ruff's UP autofixes from the versions
|
|
64
|
+
# users actually run.
|
|
65
|
+
target-version = "py310"
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint]
|
|
68
|
+
select = [
|
|
69
|
+
"E", # pycodestyle errors
|
|
70
|
+
"W", # pycodestyle warnings
|
|
71
|
+
"F", # pyflakes
|
|
72
|
+
"I", # isort
|
|
73
|
+
"N", # pep8-naming
|
|
74
|
+
"B", # flake8-bugbear
|
|
75
|
+
"A", # flake8-builtins (don't shadow builtins)
|
|
76
|
+
"C4", # flake8-comprehensions
|
|
77
|
+
"COM", # flake8-commas
|
|
78
|
+
"ISC", # flake8-implicit-str-concat
|
|
79
|
+
"PIE", # flake8-pie (misc bad patterns)
|
|
80
|
+
"PT", # flake8-pytest-style
|
|
81
|
+
"Q", # flake8-quotes
|
|
82
|
+
"RET", # flake8-return
|
|
83
|
+
"SIM", # flake8-simplify
|
|
84
|
+
"TID", # flake8-tidy-imports
|
|
85
|
+
"ARG", # flake8-unused-arguments
|
|
86
|
+
"PTH", # flake8-use-pathlib
|
|
87
|
+
"ERA", # flake8-eradicate (dead commented code)
|
|
88
|
+
"PL", # pylint
|
|
89
|
+
"TRY", # tryceratops (try/except best practices)
|
|
90
|
+
"PERF", # perflint
|
|
91
|
+
"FURB", # refurb (modern-Python rewrites)
|
|
92
|
+
"RUF", # ruff-specific
|
|
93
|
+
"UP", # pyupgrade
|
|
94
|
+
"S", # flake8-bandit (security)
|
|
95
|
+
]
|
|
96
|
+
ignore = [
|
|
97
|
+
"E501", # line too long, handled by formatter
|
|
98
|
+
"B008", # function calls in argument defaults
|
|
99
|
+
"COM812", # trailing comma missing, conflicts with formatter
|
|
100
|
+
"ISC001", # implicit-string-concat, conflicts with formatter
|
|
101
|
+
"TRY003", # long messages inside exception class, fine here
|
|
102
|
+
# This library exists to distinguish Cyrillic vs Latin lookalikes.
|
|
103
|
+
# `с` vs `c` in a comment is the whole point; ruff's ambiguous-char
|
|
104
|
+
# warnings would fire on every meaningful docstring.
|
|
105
|
+
"RUF001", # ambiguous unicode in string
|
|
106
|
+
"RUF002", # ambiguous unicode in docstring
|
|
107
|
+
"RUF003", # ambiguous unicode in comment
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
[tool.ruff.lint.per-file-ignores]
|
|
111
|
+
"tests/**" = ["S101"] # pytest tests are all assert
|
|
112
|
+
|
|
113
|
+
[tool.ruff.format]
|
|
114
|
+
quote-style = "double"
|
|
115
|
+
indent-style = "space"
|
|
116
|
+
skip-magic-trailing-comma = false
|
|
117
|
+
line-ending = "auto"
|
|
118
|
+
|
|
119
|
+
[tool.pytest.ini_options]
|
|
120
|
+
testpaths = ["tests"]
|
|
121
|
+
python_files = ["test_*.py"]
|
|
122
|
+
addopts = [
|
|
123
|
+
"--strict-markers",
|
|
124
|
+
"--strict-config",
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
[tool.mypy]
|
|
128
|
+
python_version = "3.10"
|
|
129
|
+
strict = true
|
|
130
|
+
warn_unreachable = true
|
|
131
|
+
enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"]
|
|
132
|
+
files = ["serbian_translit", "tests"]
|
|
133
|
+
|
|
134
|
+
[[tool.mypy.overrides]]
|
|
135
|
+
module = ["yaml"]
|
|
136
|
+
ignore_missing_imports = true
|
|
137
|
+
|
|
138
|
+
[tool.coverage.run]
|
|
139
|
+
source = ["serbian_translit"]
|
|
140
|
+
branch = true
|
|
141
|
+
|
|
142
|
+
[tool.coverage.report]
|
|
143
|
+
fail_under = 95
|
|
144
|
+
show_missing = true
|
|
145
|
+
skip_covered = false
|
|
146
|
+
exclude_lines = [
|
|
147
|
+
"pragma: no cover",
|
|
148
|
+
"if TYPE_CHECKING:",
|
|
149
|
+
"raise NotImplementedError",
|
|
150
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Word-level case detection and reapplication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CasePattern(Enum):
|
|
9
|
+
LOWER = "lower"
|
|
10
|
+
UPPER = "upper"
|
|
11
|
+
TITLE = "title"
|
|
12
|
+
MIXED = "mixed"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def detect(text: str) -> CasePattern:
|
|
16
|
+
if text.islower():
|
|
17
|
+
return CasePattern.LOWER
|
|
18
|
+
if text.isupper():
|
|
19
|
+
return CasePattern.UPPER
|
|
20
|
+
if len(text) > 1 and text[0].isupper() and text[1:].islower():
|
|
21
|
+
return CasePattern.TITLE
|
|
22
|
+
return CasePattern.MIXED
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def apply(text: str, pattern: CasePattern) -> str:
|
|
26
|
+
match pattern:
|
|
27
|
+
case CasePattern.LOWER:
|
|
28
|
+
return text.lower()
|
|
29
|
+
case CasePattern.UPPER:
|
|
30
|
+
return text.upper()
|
|
31
|
+
case CasePattern.TITLE:
|
|
32
|
+
return text[0].upper() + text[1:].lower()
|
|
33
|
+
case CasePattern.MIXED:
|
|
34
|
+
return text
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Montenegrin (`cnr`) script conversion, Cyrillic ↔ Latin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .table import CNR_CYR_TO_LAT, CNR_LAT_TO_CYR
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def to_cyr(text: str) -> str:
|
|
9
|
+
"""Montenegrin Latin → Cyrillic."""
|
|
10
|
+
return CNR_LAT_TO_CYR.apply(text)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_lat(text: str) -> str:
|
|
14
|
+
"""Montenegrin Cyrillic → Latin."""
|
|
15
|
+
return CNR_CYR_TO_LAT.apply(text)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Serbian/Montenegrin script conversion rules.
|
|
2
|
+
#
|
|
3
|
+
# Each rule declares a source→target script and gives three ordered maps:
|
|
4
|
+
# digraphs: matched first, greedy (`lj`→`љ`, `dž`→`џ`); case-aware.
|
|
5
|
+
# pre_char: pre-processed single-character remaps applied before `singles`.
|
|
6
|
+
# Used for accepting alternate codepoints (Đ variants) that
|
|
7
|
+
# collapse to the same base letter.
|
|
8
|
+
# singles: 1:1 base-letter map.
|
|
9
|
+
# Case handling: the engine looks at the surrounding word to decide
|
|
10
|
+
# LOWER / UPPER / TITLE, so `Nj` in "Njujork" but `NJ` in "NJUJORK".
|
|
11
|
+
#
|
|
12
|
+
# extras_in_word: letters (beyond base ASCII / Cyrillic ranges) that count
|
|
13
|
+
# as part of a word for tokenisation. Without listing them, `š` or `ž` would
|
|
14
|
+
# split a Latin word into pieces and case-detection would misfire.
|
|
15
|
+
#
|
|
16
|
+
# non_native_letters: if a word contains any of these letters, it is left
|
|
17
|
+
# untouched (foreign inclusion). Applies to Latin source only.
|
|
18
|
+
|
|
19
|
+
rules:
|
|
20
|
+
|
|
21
|
+
- source: srp-latn
|
|
22
|
+
target: srp-cyrl
|
|
23
|
+
digraphs:
|
|
24
|
+
lj: љ
|
|
25
|
+
nj: њ
|
|
26
|
+
dž: џ
|
|
27
|
+
pre_char:
|
|
28
|
+
"Ð": "Đ" # Ð (Eth) → Đ (D-stroke): editors often type Eth by accident
|
|
29
|
+
"ð": "đ" # ð (eth) → đ (d-stroke): same
|
|
30
|
+
singles:
|
|
31
|
+
a: а
|
|
32
|
+
b: б
|
|
33
|
+
c: ц
|
|
34
|
+
"č": ч
|
|
35
|
+
"ć": ћ
|
|
36
|
+
d: д
|
|
37
|
+
"đ": ђ
|
|
38
|
+
e: е
|
|
39
|
+
f: ф
|
|
40
|
+
g: г
|
|
41
|
+
h: х
|
|
42
|
+
i: и
|
|
43
|
+
j: ј
|
|
44
|
+
k: к
|
|
45
|
+
l: л
|
|
46
|
+
m: м
|
|
47
|
+
n: н
|
|
48
|
+
o: о
|
|
49
|
+
p: п
|
|
50
|
+
r: р
|
|
51
|
+
s: с
|
|
52
|
+
"š": ш
|
|
53
|
+
t: т
|
|
54
|
+
u: у
|
|
55
|
+
v: в
|
|
56
|
+
z: з
|
|
57
|
+
"ž": ж
|
|
58
|
+
extras_in_word: "čČćĆđĐšŠžŽ"
|
|
59
|
+
non_native_letters: "wxyqWXYQ"
|
|
60
|
+
# Words that look like Roman numerals but are Serbian
|
|
61
|
+
# pronouns/particles, compared case-insensitively.
|
|
62
|
+
never_roman: ["MI", "LI", "VI", "CI"]
|
|
63
|
+
|
|
64
|
+
- source: srp-cyrl
|
|
65
|
+
target: srp-latn
|
|
66
|
+
digraphs:
|
|
67
|
+
"љ": lj
|
|
68
|
+
"њ": nj
|
|
69
|
+
"џ": dž
|
|
70
|
+
singles:
|
|
71
|
+
"а": a
|
|
72
|
+
"б": b
|
|
73
|
+
"в": v
|
|
74
|
+
"г": g
|
|
75
|
+
"д": d
|
|
76
|
+
"ђ": "đ"
|
|
77
|
+
"е": e
|
|
78
|
+
"ж": "ž"
|
|
79
|
+
"з": z
|
|
80
|
+
"и": i
|
|
81
|
+
"ј": j
|
|
82
|
+
"к": k
|
|
83
|
+
"л": l
|
|
84
|
+
"м": m
|
|
85
|
+
"н": n
|
|
86
|
+
"о": o
|
|
87
|
+
"п": p
|
|
88
|
+
"р": r
|
|
89
|
+
"с": s
|
|
90
|
+
"т": t
|
|
91
|
+
"ћ": "ć"
|
|
92
|
+
"у": u
|
|
93
|
+
"ф": f
|
|
94
|
+
"х": h
|
|
95
|
+
"ц": c
|
|
96
|
+
"ч": "č"
|
|
97
|
+
"ш": "š"
|
|
98
|
+
extras_in_word: ""
|
|
99
|
+
non_native_letters: ""
|
|
100
|
+
|
|
101
|
+
- source: cnr-latn
|
|
102
|
+
target: cnr-cyrl
|
|
103
|
+
digraphs:
|
|
104
|
+
lj: љ
|
|
105
|
+
nj: њ
|
|
106
|
+
dž: џ
|
|
107
|
+
pre_char:
|
|
108
|
+
"Ð": "Đ"
|
|
109
|
+
"ð": "đ"
|
|
110
|
+
singles:
|
|
111
|
+
a: а
|
|
112
|
+
b: б
|
|
113
|
+
c: ц
|
|
114
|
+
"č": ч
|
|
115
|
+
"ć": ћ
|
|
116
|
+
d: д
|
|
117
|
+
"đ": ђ
|
|
118
|
+
e: е
|
|
119
|
+
f: ф
|
|
120
|
+
g: г
|
|
121
|
+
h: х
|
|
122
|
+
i: и
|
|
123
|
+
j: ј
|
|
124
|
+
k: к
|
|
125
|
+
l: л
|
|
126
|
+
m: м
|
|
127
|
+
n: н
|
|
128
|
+
o: о
|
|
129
|
+
p: п
|
|
130
|
+
r: р
|
|
131
|
+
s: с
|
|
132
|
+
"ś": "с́"
|
|
133
|
+
"š": ш
|
|
134
|
+
t: т
|
|
135
|
+
u: у
|
|
136
|
+
v: в
|
|
137
|
+
z: з
|
|
138
|
+
"ź": "з́"
|
|
139
|
+
"ž": ж
|
|
140
|
+
extras_in_word: "čČćĆđĐšŠžŽśŚźŹ"
|
|
141
|
+
non_native_letters: "wxyqWXYQ"
|
|
142
|
+
# Words that look like Roman numerals but are Montenegrin
|
|
143
|
+
# pronouns/particles, compared case-insensitively.
|
|
144
|
+
never_roman: ["MI", "LI", "VI", "CI"]
|
|
145
|
+
|
|
146
|
+
- source: cnr-cyrl
|
|
147
|
+
target: cnr-latn
|
|
148
|
+
digraphs:
|
|
149
|
+
"љ": lj
|
|
150
|
+
"њ": nj
|
|
151
|
+
"џ": dž
|
|
152
|
+
# с́ / з́ are two codepoints (base + U+0301). Handled as digraphs so the
|
|
153
|
+
# combining mark is consumed with its base letter and does not leak.
|
|
154
|
+
"с́": "ś"
|
|
155
|
+
"з́": "ź"
|
|
156
|
+
singles:
|
|
157
|
+
"а": a
|
|
158
|
+
"б": b
|
|
159
|
+
"в": v
|
|
160
|
+
"г": g
|
|
161
|
+
"д": d
|
|
162
|
+
"ђ": "đ"
|
|
163
|
+
"е": e
|
|
164
|
+
"ж": "ž"
|
|
165
|
+
"з": z
|
|
166
|
+
"и": i
|
|
167
|
+
"ј": j
|
|
168
|
+
"к": k
|
|
169
|
+
"л": l
|
|
170
|
+
"м": m
|
|
171
|
+
"н": n
|
|
172
|
+
"о": o
|
|
173
|
+
"п": p
|
|
174
|
+
"р": r
|
|
175
|
+
"с": s
|
|
176
|
+
"т": t
|
|
177
|
+
"ћ": "ć"
|
|
178
|
+
"у": u
|
|
179
|
+
"ф": f
|
|
180
|
+
"х": h
|
|
181
|
+
"ц": c
|
|
182
|
+
"ч": "č"
|
|
183
|
+
"ш": "š"
|
|
184
|
+
extras_in_word: "́"
|
|
185
|
+
non_native_letters: ""
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Char-by-char letter map, digraph-first."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
_DIGRAPH_WIDTH = 2
|
|
8
|
+
_SINGLE_WIDTH = 1
|
|
9
|
+
_LOOKUP_WIDTHS = (_DIGRAPH_WIDTH, _SINGLE_WIDTH)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class LetterMap:
|
|
14
|
+
digraphs: dict[str, str]
|
|
15
|
+
singles: dict[str, str]
|
|
16
|
+
|
|
17
|
+
def convert(self, lowered: str) -> str:
|
|
18
|
+
result: list[str] = []
|
|
19
|
+
i = 0
|
|
20
|
+
while i < len(lowered):
|
|
21
|
+
replacement, width = self._match_at(lowered, i)
|
|
22
|
+
result.append(replacement)
|
|
23
|
+
i += width
|
|
24
|
+
return "".join(result)
|
|
25
|
+
|
|
26
|
+
def _match_at(self, lowered: str, i: int) -> tuple[str, int]:
|
|
27
|
+
for width in _LOOKUP_WIDTHS:
|
|
28
|
+
if i + width > len(lowered):
|
|
29
|
+
continue
|
|
30
|
+
candidate = lowered[i : i + width]
|
|
31
|
+
if candidate in self.digraphs:
|
|
32
|
+
return self.digraphs[candidate], width
|
|
33
|
+
ch = lowered[i]
|
|
34
|
+
return self.singles.get(ch, ch), _SINGLE_WIDTH
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Stash paired quotes and URL/email/handle tokens so they round-trip untouched."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
_QUOTED_RE = re.compile(
|
|
9
|
+
r"„[^„”“]*?[”“\"]"
|
|
10
|
+
r"|«[^«»]*?»"
|
|
11
|
+
r"|“[^“”]*?”"
|
|
12
|
+
r"|\"[^\"]*?\"",
|
|
13
|
+
re.DOTALL,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_STOP = r"\s<>"
|
|
17
|
+
_TOKEN_RE = re.compile(
|
|
18
|
+
rf"[a-zA-Z][a-zA-Z0-9+.\-]*://[^{_STOP}]+"
|
|
19
|
+
rf"|www\.[^{_STOP}]+"
|
|
20
|
+
rf"|[^{_STOP}@]+@[^{_STOP}@]+\.[^{_STOP}@]+"
|
|
21
|
+
rf"|#[^{_STOP}#@]+"
|
|
22
|
+
rf"|@[^{_STOP}#@]+",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ProtectedRegions:
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._slots: dict[str, str] = {}
|
|
29
|
+
self._counter = 0
|
|
30
|
+
self._prefix = uuid.uuid4().hex
|
|
31
|
+
|
|
32
|
+
def stash_all(self, text: str) -> str:
|
|
33
|
+
text = _TOKEN_RE.sub(self._stash, text)
|
|
34
|
+
return _QUOTED_RE.sub(self._stash, text)
|
|
35
|
+
|
|
36
|
+
def restore(self, text: str) -> str:
|
|
37
|
+
for key, original in self._slots.items():
|
|
38
|
+
text = text.replace(key, original)
|
|
39
|
+
return text
|
|
40
|
+
|
|
41
|
+
def _stash(self, match: re.Match[str]) -> str:
|
|
42
|
+
key = f"\x00Q_{self._prefix}_{self._counter}\x00"
|
|
43
|
+
self._slots[key] = match.group(0)
|
|
44
|
+
self._counter += 1
|
|
45
|
+
return key
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Roman-numeral detection for the transliteration passthrough."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
_CANONICAL = re.compile(r"^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$")
|
|
8
|
+
|
|
9
|
+
_MIN_NUMERAL_LEN = 2
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_numeral(word: str) -> bool:
|
|
13
|
+
return len(word) >= _MIN_NUMERAL_LEN and bool(_CANONICAL.match(word))
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""One source→target script mapping, loaded from ``data/rules.yaml``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
from typing import TypedDict
|
|
8
|
+
|
|
9
|
+
from . import case
|
|
10
|
+
from .letters import LetterMap
|
|
11
|
+
from .protection import ProtectedRegions
|
|
12
|
+
from .skip import SkipPolicy
|
|
13
|
+
|
|
14
|
+
_MIXED_CASE_CUTOFF = 2
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RuleData(TypedDict, total=False):
|
|
18
|
+
source: str
|
|
19
|
+
target: str
|
|
20
|
+
digraphs: dict[str, str]
|
|
21
|
+
pre_char: dict[str, str]
|
|
22
|
+
singles: dict[str, str]
|
|
23
|
+
extras_in_word: str
|
|
24
|
+
non_native_letters: str
|
|
25
|
+
never_roman: list[str]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Rule:
|
|
29
|
+
def __init__(self, data: RuleData) -> None:
|
|
30
|
+
self.letters = LetterMap(
|
|
31
|
+
digraphs={k.lower(): v for k, v in data.get("digraphs", {}).items()},
|
|
32
|
+
singles=data.get("singles") or {},
|
|
33
|
+
)
|
|
34
|
+
self.skip = SkipPolicy(
|
|
35
|
+
non_native_letters=set(data.get("non_native_letters") or ""),
|
|
36
|
+
never_roman={w.upper() for w in data.get("never_roman") or []},
|
|
37
|
+
)
|
|
38
|
+
self.pre_char: dict[str, str] = data.get("pre_char") or {}
|
|
39
|
+
extras = data.get("extras_in_word") or ""
|
|
40
|
+
self.word_split_re = re.compile(rf"(\s+|[^\w{re.escape(extras)}]+)")
|
|
41
|
+
|
|
42
|
+
def apply(self, text: str) -> str:
|
|
43
|
+
text = unicodedata.normalize("NFC", text)
|
|
44
|
+
|
|
45
|
+
protection = ProtectedRegions()
|
|
46
|
+
protected = protection.stash_all(text)
|
|
47
|
+
|
|
48
|
+
rendered = "".join(self._convert_part(p) for p in self.word_split_re.split(protected) if p)
|
|
49
|
+
return protection.restore(rendered)
|
|
50
|
+
|
|
51
|
+
def _convert_part(self, part: str) -> str:
|
|
52
|
+
if any(ch.isalpha() for ch in part):
|
|
53
|
+
return self._convert_word(part)
|
|
54
|
+
return part
|
|
55
|
+
|
|
56
|
+
def _convert_word(self, word: str) -> str:
|
|
57
|
+
if self.skip.is_foreign(word) or self.skip.is_roman_numeral(word):
|
|
58
|
+
return word
|
|
59
|
+
|
|
60
|
+
word = self._normalise_pre_char(word)
|
|
61
|
+
|
|
62
|
+
pattern = case.detect(word)
|
|
63
|
+
if pattern is case.CasePattern.MIXED and len(word) > _MIXED_CASE_CUTOFF:
|
|
64
|
+
return word
|
|
65
|
+
|
|
66
|
+
return case.apply(self.letters.convert(word.lower()), pattern)
|
|
67
|
+
|
|
68
|
+
def _normalise_pre_char(self, word: str) -> str:
|
|
69
|
+
if not self.pre_char:
|
|
70
|
+
return word
|
|
71
|
+
return "".join(self.pre_char.get(ch, ch) for ch in word)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Word-level policies that pass a token through unchanged."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from . import roman
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class SkipPolicy:
|
|
12
|
+
non_native_letters: set[str]
|
|
13
|
+
never_roman: set[str]
|
|
14
|
+
|
|
15
|
+
def is_foreign(self, word: str) -> bool:
|
|
16
|
+
return bool(self.non_native_letters and self.non_native_letters.intersection(word))
|
|
17
|
+
|
|
18
|
+
def is_roman_numeral(self, word: str) -> bool:
|
|
19
|
+
return roman.is_numeral(word) and word.upper() not in self.never_roman
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Serbian (`srp`) script conversion, Cyrillic ↔ Latin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .table import SRP_CYR_TO_LAT, SRP_LAT_TO_CYR
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def to_cyr(text: str) -> str:
|
|
9
|
+
"""Serbian Latin → Cyrillic."""
|
|
10
|
+
return SRP_LAT_TO_CYR.apply(text)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_lat(text: str) -> str:
|
|
14
|
+
"""Serbian Cyrillic → Latin."""
|
|
15
|
+
return SRP_CYR_TO_LAT.apply(text)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""YAML-driven table of ready-built rules, one per source→target pair."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TypedDict
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from .rule import Rule, RuleData
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _RulesFile(TypedDict):
|
|
14
|
+
rules: list[RuleData]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load() -> dict[tuple[str, str], Rule]:
|
|
18
|
+
path = Path(__file__).parent / "data" / "rules.yaml"
|
|
19
|
+
with path.open(encoding="utf-8") as f:
|
|
20
|
+
data: _RulesFile = yaml.safe_load(f)
|
|
21
|
+
return {(entry["source"], entry["target"]): Rule(entry) for entry in data["rules"]}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_TABLE = _load()
|
|
25
|
+
|
|
26
|
+
SRP_LAT_TO_CYR = _TABLE["srp-latn", "srp-cyrl"]
|
|
27
|
+
SRP_CYR_TO_LAT = _TABLE["srp-cyrl", "srp-latn"]
|
|
28
|
+
CNR_LAT_TO_CYR = _TABLE["cnr-latn", "cnr-cyrl"]
|
|
29
|
+
CNR_CYR_TO_LAT = _TABLE["cnr-cyrl", "cnr-latn"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: serbian-translit
|
|
3
|
+
Version: 0.4.2
|
|
4
|
+
Summary: Deterministic Serbian and Montenegrin script conversion (Cyrillic ↔ Latin).
|
|
5
|
+
Author: Apakabarlabs
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/apakabarlabs/serbian-translit-python
|
|
8
|
+
Project-URL: Documentation, https://apakabarlabs.github.io/serbian-translit-python/serbian_translit.html
|
|
9
|
+
Project-URL: Changelog, https://github.com/apakabarlabs/serbian-translit-python/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Issues, https://github.com/apakabarlabs/serbian-translit-python/issues
|
|
11
|
+
Keywords: serbian,montenegrin,transliteration,script,conversion,cyrillic,latin,bcms
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Natural Language :: Serbian
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: PyYAML>=6.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
31
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
32
|
+
Requires-Dist: types-PyYAML>=6.0; extra == "dev"
|
|
33
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
34
|
+
Requires-Dist: twine>=6.0; extra == "dev"
|
|
35
|
+
Requires-Dist: pdoc>=16.0; extra == "dev"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
[](https://github.com/apakabarlabs/serbian-translit-python/actions/workflows/tests.yml)
|
|
39
|
+
[](https://apakabarlabs.github.io/serbian-translit-python/serbian_translit.html)
|
|
40
|
+
|
|
41
|
+
# serbian-translit
|
|
42
|
+
|
|
43
|
+
Deterministic Serbian and Montenegrin script conversion, Cyrillic ↔ Latin. Case preservation, digraph handling, quoted-region protection, Roman-numeral and non-native-word filtering.
|
|
44
|
+
|
|
45
|
+
Both official scripts of Serbian (and Montenegrin) map one-to-one at the letter level: `љ↔lj`, `њ↔nj`, `џ↔dž`, plus `с́↔ś`, `з́↔ź` for Montenegrin. The library plays the pairing from a YAML table; there is no per-language code path in the engine.
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
Install the released package from PyPI:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install serbian-translit
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from serbian_translit import srp, cnr
|
|
59
|
+
|
|
60
|
+
srp.to_cyr("Njujork") # 'Њујорк'
|
|
61
|
+
srp.to_cyr("LJUBAV") # 'ЉУБАВ'
|
|
62
|
+
srp.to_cyr("New York") # 'New York' (word skipped, has non-native letters)
|
|
63
|
+
srp.to_cyr('grupa „AC/DC"') # 'група „AC/DC"' (quoted region preserved)
|
|
64
|
+
srp.to_lat("Њујорк") # 'Njujork'
|
|
65
|
+
|
|
66
|
+
cnr.to_cyr("śever") # 'с́евер' (с + U+0301)
|
|
67
|
+
cnr.to_lat("с́евер") # 'śever'
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Behaviour
|
|
71
|
+
|
|
72
|
+
- **Digraphs** `lj`, `nj`, `dž` (Latin) ↔ `љ`, `њ`, `џ` (Cyrillic) with case preservation (`Nj` in title-case position, `NJ` inside all-caps).
|
|
73
|
+
- **Montenegrin extras** `ś`, `ź` ↔ `с́`, `з́` (base letter + combining acute U+0301; no precomposed codepoints exist).
|
|
74
|
+
- **Đ variants** `Đ` (U+0110), `đ` (U+0111), `Ð` (U+00D0 Eth), `ð` (U+00F0 eth) all map to `Ђ`/`ђ`.
|
|
75
|
+
- **Roman numerals** (`II`, `XIV`, `XX`) stay in Latin regardless of direction.
|
|
76
|
+
- **Words with non-native letters** (Latin `w`, `x`, `y`, `q`) are skipped whole; treated as foreign inclusions.
|
|
77
|
+
- **Quoted regions** (`"…"`, `„…"`, `“…”`, `«…»`) are preserved verbatim so brand names and foreign quotes survive round-trip.
|
|
78
|
+
- **Non-alphabetic content** (numbers, punctuation, whitespace) is left unchanged.
|
|
79
|
+
|
|
80
|
+
## Rules and tests
|
|
81
|
+
|
|
82
|
+
Rules live in [`serbian_translit/data/rules.yaml`](serbian_translit/data/rules.yaml); test cases in [`tests/tests.yaml`](tests/tests.yaml). Both files are the source of truth shared with the [Swift](https://github.com/apakabarlabs/serbian-translit-swift) and (upcoming) Kotlin ports so behaviour stays identical across languages.
|
|
83
|
+
|
|
84
|
+
## Documentation
|
|
85
|
+
|
|
86
|
+
The [API reference](https://apakabarlabs.github.io/serbian-translit-python/serbian_translit.html) is generated from the public Python API and deployed by GitHub Actions.
|
|
87
|
+
|
|
88
|
+
## Lines of Code
|
|
89
|
+
|
|
90
|
+
<picture>
|
|
91
|
+
<source media="(prefers-color-scheme: dark)" srcset=".github/loc-history-dark.svg">
|
|
92
|
+
<source media="(prefers-color-scheme: light)" srcset=".github/loc-history-light.svg">
|
|
93
|
+
<img alt="Lines of Code graph" src=".github/loc-history-light.svg">
|
|
94
|
+
</picture>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
serbian_translit/__init__.py
|
|
5
|
+
serbian_translit/case.py
|
|
6
|
+
serbian_translit/cnr.py
|
|
7
|
+
serbian_translit/letters.py
|
|
8
|
+
serbian_translit/protection.py
|
|
9
|
+
serbian_translit/roman.py
|
|
10
|
+
serbian_translit/rule.py
|
|
11
|
+
serbian_translit/skip.py
|
|
12
|
+
serbian_translit/srp.py
|
|
13
|
+
serbian_translit/table.py
|
|
14
|
+
serbian_translit.egg-info/PKG-INFO
|
|
15
|
+
serbian_translit.egg-info/SOURCES.txt
|
|
16
|
+
serbian_translit.egg-info/dependency_links.txt
|
|
17
|
+
serbian_translit.egg-info/requires.txt
|
|
18
|
+
serbian_translit.egg-info/top_level.txt
|
|
19
|
+
serbian_translit/data/rules.yaml
|
|
20
|
+
tests/test_transliterate.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
serbian_translit
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from serbian_translit import cnr, srp
|
|
8
|
+
|
|
9
|
+
_ROUTES: dict[tuple[str, str], Callable[[str], str]] = {
|
|
10
|
+
("srp-latn", "srp-cyrl"): srp.to_cyr,
|
|
11
|
+
("srp-cyrl", "srp-latn"): srp.to_lat,
|
|
12
|
+
("cnr-latn", "cnr-cyrl"): cnr.to_cyr,
|
|
13
|
+
("cnr-cyrl", "cnr-latn"): cnr.to_lat,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_test_cases() -> list[tuple[str, str, str, str, str]]:
|
|
18
|
+
path = Path(__file__).parent / "tests.yaml"
|
|
19
|
+
with path.open(encoding="utf-8") as f:
|
|
20
|
+
data = yaml.safe_load(f)
|
|
21
|
+
return [
|
|
22
|
+
(section["section"], section["source"], section["target"], case["text"], case["want"])
|
|
23
|
+
for section in data["tests"]
|
|
24
|
+
for case in section["cases"]
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.mark.parametrize(("section", "source", "target", "text", "want"), load_test_cases())
|
|
29
|
+
def test_transliterate(section: str, source: str, target: str, text: str, want: str) -> None:
|
|
30
|
+
func = _ROUTES[(source, target)]
|
|
31
|
+
result = func(text)
|
|
32
|
+
assert result == want, f"[{section}] {source} → {target} ('{text}'): got '{result}', want '{want}'"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.parametrize(
|
|
36
|
+
"func",
|
|
37
|
+
[srp.to_cyr, srp.to_lat, cnr.to_cyr, cnr.to_lat],
|
|
38
|
+
ids=["srp.to_cyr", "srp.to_lat", "cnr.to_cyr", "cnr.to_lat"],
|
|
39
|
+
)
|
|
40
|
+
def test_empty_input_returns_empty_string(func: Callable[[str], str]) -> None:
|
|
41
|
+
assert func("") == ""
|