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.
@@ -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
+ [![Tests](https://github.com/apakabarlabs/serbian-translit-python/actions/workflows/tests.yml/badge.svg)](https://github.com/apakabarlabs/serbian-translit-python/actions/workflows/tests.yml)
39
+ [![Documentation](https://github.com/apakabarlabs/serbian-translit-python/actions/workflows/documentation.yml/badge.svg)](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
+ [![Tests](https://github.com/apakabarlabs/serbian-translit-python/actions/workflows/tests.yml/badge.svg)](https://github.com/apakabarlabs/serbian-translit-python/actions/workflows/tests.yml)
2
+ [![Documentation](https://github.com/apakabarlabs/serbian-translit-python/actions/workflows/documentation.yml/badge.svg)](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,4 @@
1
+ from . import cnr, srp
2
+
3
+ __version__ = "0.4.2"
4
+ __all__ = ["cnr", "srp"]
@@ -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
+ [![Tests](https://github.com/apakabarlabs/serbian-translit-python/actions/workflows/tests.yml/badge.svg)](https://github.com/apakabarlabs/serbian-translit-python/actions/workflows/tests.yml)
39
+ [![Documentation](https://github.com/apakabarlabs/serbian-translit-python/actions/workflows/documentation.yml/badge.svg)](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,11 @@
1
+ PyYAML>=6.0
2
+
3
+ [dev]
4
+ pytest>=8.0
5
+ pytest-cov>=5.0
6
+ ruff>=0.5
7
+ mypy>=1.10
8
+ types-PyYAML>=6.0
9
+ build>=1.0
10
+ twine>=6.0
11
+ pdoc>=16.0
@@ -0,0 +1 @@
1
+ serbian_translit
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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("") == ""