kannada-tokenizer 0.1.0__py3-none-any.whl

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,6 @@
1
+ """Kannada tokenizer with sandhi splitting for Information Retrieval."""
2
+
3
+ from kannada_tokenizer.tokenizer import tokenize
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["tokenize"]
@@ -0,0 +1,65 @@
1
+ """Command-line interface for the Kannada tokenizer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from .tokenizer import tokenize, tokenize_words
9
+
10
+
11
+ def _build_parser() -> argparse.ArgumentParser:
12
+ """Build and return the argument parser."""
13
+ parser = argparse.ArgumentParser(
14
+ prog="kannada-tokenize",
15
+ description="Tokenize Kannada text with optional sandhi splitting.",
16
+ )
17
+ parser.add_argument(
18
+ "text",
19
+ nargs="?",
20
+ default=None,
21
+ help="Kannada text to tokenize. Reads from stdin if omitted.",
22
+ )
23
+ parser.add_argument(
24
+ "--no-sandhi",
25
+ action="store_true",
26
+ default=False,
27
+ help="Disable sandhi splitting (word-level tokenization only).",
28
+ )
29
+ parser.add_argument(
30
+ "--separator",
31
+ "-s",
32
+ default="\n",
33
+ help="Token separator in output (default: newline).",
34
+ )
35
+ return parser
36
+
37
+
38
+ def main(argv: list[str] | None = None) -> None:
39
+ """Entry point for the ``kannada-tokenize`` CLI.
40
+
41
+ Args:
42
+ argv: Optional argument list (defaults to ``sys.argv[1:]``).
43
+ """
44
+ parser = _build_parser()
45
+ args = parser.parse_args(argv)
46
+
47
+ # Determine input source
48
+ if args.text is not None:
49
+ text: str = args.text
50
+ elif not sys.stdin.isatty():
51
+ text = sys.stdin.read()
52
+ else:
53
+ parser.print_help()
54
+ sys.exit(1)
55
+
56
+ # Select tokenization strategy
57
+ tok_fn = tokenize_words if args.no_sandhi else tokenize
58
+ tokens: list[str] = tok_fn(text)
59
+
60
+ if tokens:
61
+ print(args.separator.join(tokens))
62
+
63
+
64
+ if __name__ == "__main__":
65
+ main()
@@ -0,0 +1,499 @@
1
+ """
2
+ sandhi.py — Rule-based Kannada sandhi splitting for Information Retrieval.
3
+
4
+ Sandhi (ಸಂಧಿ) is the euphonic combination that occurs at morpheme and word
5
+ boundaries in Kannada. This module implements *reverse* sandhi rules: given
6
+ a combined surface form it tries to recover the original components.
7
+
8
+ Design decisions
9
+ ~~~~~~~~~~~~~~~~
10
+ * **ISO 15919 encoding only.** The upstream transliteration module normalises
11
+ all input to ISO 15919 before this module is called.
12
+ * **Zero external dependencies.** Pure-Python, stdlib only.
13
+ * **Longest-match / fewest-parts heuristic.** When multiple candidate splits
14
+ are possible we prefer the one that produces the fewest, longest tokens.
15
+ This is a good proxy for "most likely intended reading" in an IR context
16
+ where recall matters more than philological precision.
17
+
18
+ Kannada sandhi differs from Sanskrit sandhi in several important ways:
19
+
20
+ 1. **Lōpa Sandhi (ಲೋಪ ಸಂಧಿ)** — Vowel elision: when two vowels meet at a
21
+ word boundary, one (usually the first) is dropped.
22
+ 2. **Āgama Sandhi (ಆಗಮ ಸಂಧಿ)** — Insertion: a consonant ('y' or 'v') is
23
+ inserted between vowels to prevent hiatus.
24
+ 3. **Ādeśa Sandhi (ಆದೇಶ ಸಂಧಿ)** — Substitution: a sound at the junction is
25
+ replaced by another (Guṇa-like changes in tatsama words).
26
+ 4. **Consonant Sandhi** — Voicing assimilation and gemination at word
27
+ boundaries, common in Dravidian Kannada.
28
+
29
+ The rules are represented as 4-tuples:
30
+
31
+ (combined_pattern, left_part, right_part, category)
32
+
33
+ ``combined_pattern`` is the string that appears at the junction in the
34
+ surface form. ``left_part`` is what the *end* of the first word should be
35
+ restored to, and ``right_part`` is what the *start* of the second word
36
+ should be restored to. ``category`` is one of ``'lōpa'``, ``'āgama'``,
37
+ ``'ādeśa'``, or ``'consonant'``.
38
+
39
+ References
40
+ ~~~~~~~~~~
41
+ * Kittel, *A Grammar of the Kannada Language*
42
+ * Narasimhachar, *Kannada Vyākaraṇa*
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ from typing import List, Tuple
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Constants — vowel / consonant inventories (ISO 15919)
51
+ # ---------------------------------------------------------------------------
52
+
53
+ VOWELS: set[str] = {
54
+ "a", "ā", "i", "ī", "u", "ū",
55
+ "e", "ē", "ai", "o", "ō", "au",
56
+ }
57
+
58
+ SHORT_VOWELS: set[str] = {"a", "i", "u", "e", "o"}
59
+
60
+ LONG_VOWELS: set[str] = {"ā", "ī", "ū", "ē", "ō"}
61
+
62
+ DIPHTHONGS: set[str] = {"ai", "au"}
63
+
64
+ VOICED_CONSONANTS: set[str] = {
65
+ "g", "gh", "j", "jh", "ḍ", "ḍh", "d", "dh", "b", "bh",
66
+ "ṅ", "ñ", "ṇ", "n", "m",
67
+ "y", "r", "l", "v", "ḷ",
68
+ "h",
69
+ }
70
+
71
+ UNVOICED_CONSONANTS: set[str] = {
72
+ "k", "kh", "c", "ch", "ṭ", "ṭh", "t", "th", "p", "ph",
73
+ "s", "ś", "ṣ",
74
+ }
75
+
76
+ STOPS_VOICED: dict[str, str] = {
77
+ # unvoiced stop → voiced counterpart
78
+ "k": "g", "kh": "gh",
79
+ "c": "j", "ch": "jh",
80
+ "ṭ": "ḍ", "ṭh": "ḍh",
81
+ "t": "d", "th": "dh",
82
+ "p": "b", "ph": "bh",
83
+ }
84
+
85
+ NASALS_FOR_CLASS: dict[str, str] = {
86
+ # class stop → class nasal
87
+ "k": "ṅ", "kh": "ṅ", "g": "ṅ", "gh": "ṅ",
88
+ "c": "ñ", "ch": "ñ", "j": "ñ", "jh": "ñ",
89
+ "ṭ": "ṇ", "ṭh": "ṇ", "ḍ": "ṇ", "ḍh": "ṇ",
90
+ "t": "n", "th": "n", "d": "n", "dh": "n",
91
+ "p": "m", "ph": "m", "b": "m", "bh": "m",
92
+ }
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # SANDHI RULES — reverse (splitting) direction
96
+ # ---------------------------------------------------------------------------
97
+ #
98
+ # Each tuple is: (combined_pattern, left_part, right_part, category)
99
+ #
100
+ # ``combined_pattern`` is what we *see* at the junction in the surface form.
101
+ # ``left_part`` is the restored tail of the first word.
102
+ # ``right_part`` is the restored head of the second word.
103
+ # ``category`` is one of 'lōpa', 'āgama', 'ādeśa', 'consonant'.
104
+ #
105
+ # Rules are ordered longest-pattern-first so that a simple linear scan with
106
+ # ``str.find`` will naturally prefer the most specific match.
107
+
108
+ # ---- 1. Lōpa Sandhi (ಲೋಪ ಸಂಧಿ) — Vowel Elision ----------------------------
109
+ #
110
+ # When two vowels meet at a word boundary, one is dropped (usually the
111
+ # first vowel). Reversing this means: where we see a single vowel at a
112
+ # potential junction, we try restoring the elided vowel.
113
+ #
114
+ # Common patterns:
115
+ # a + a → a (first 'a' dropped)
116
+ # a + ā → ā
117
+ # a + u → u
118
+ # a + i → i
119
+ # a + e → e
120
+ # a + ō → ō
121
+ #
122
+ # General principle: short vowel before a different vowel is dropped.
123
+
124
+ _LOPA_SANDHI_RULES: List[Tuple[str, str, str, str]] = []
125
+
126
+ # a + a → a (identical vowel merger — most common)
127
+ _LOPA_SANDHI_RULES.append(("a", "a", "a", "lōpa"))
128
+
129
+ # a + long/different vowel → the second vowel survives
130
+ _LOPA_PAIRS: list[tuple[str, str]] = [
131
+ # (surface_vowel, restored_right_start)
132
+ ("ā", "ā"),
133
+ ("i", "i"),
134
+ ("ī", "ī"),
135
+ ("u", "u"),
136
+ ("ū", "ū"),
137
+ ("e", "e"),
138
+ ("ē", "ē"),
139
+ ("o", "o"),
140
+ ("ō", "ō"),
141
+ ("ai", "ai"),
142
+ ("au", "au"),
143
+ ]
144
+ for _surface, _right in _LOPA_PAIRS:
145
+ # 'a' was elided before _right: reverse means surface _surface → 'a' + _right
146
+ _LOPA_SANDHI_RULES.append((_surface, "a", _right, "lōpa"))
147
+
148
+ # i/ī elision before dissimilar vowels (less common but attested)
149
+ for _short_v, _long_v in [("i", "ī"), ("u", "ū")]:
150
+ for _following in ["a", "ā", "e", "ē", "o", "ō"]:
151
+ _LOPA_SANDHI_RULES.append((_following, _short_v, _following, "lōpa"))
152
+
153
+
154
+ # ---- 2. Āgama Sandhi (ಆಗಮ ಸಂಧಿ) — Insertion --------------------------------
155
+ #
156
+ # A consonant (usually 'y' or 'v') is inserted between vowels to avoid
157
+ # hiatus. Similar to Sanskrit yān sandhi.
158
+ #
159
+ # Patterns:
160
+ # i/ī + vowel → i/ī + y + vowel → reverse: 'y' at junction → boundary
161
+ # u/ū + vowel → u/ū + v + vowel → reverse: 'v' at junction → boundary
162
+ #
163
+ # We store these with an empty right_part; the split logic fills in the
164
+ # actual vowel from the surface form.
165
+
166
+ _AGAMA_SANDHI_RULES: List[Tuple[str, str, str, str]] = []
167
+
168
+ # y-āgama: after i/ī
169
+ for _src in ("i", "ī"):
170
+ _AGAMA_SANDHI_RULES.append(("y", _src, "", "āgama"))
171
+
172
+ # v-āgama: after u/ū
173
+ for _src in ("u", "ū"):
174
+ _AGAMA_SANDHI_RULES.append(("v", _src, "", "āgama"))
175
+
176
+
177
+ # ---- 3. Ādeśa Sandhi (ಆದೇಶ ಸಂಧಿ) — Substitution ----------------------------
178
+ #
179
+ # A sound at the junction is replaced by another. This covers Guṇa-like
180
+ # changes that appear in tatsama (Sanskrit-borrowed) words in Kannada.
181
+ #
182
+ # Guṇa patterns (from Sanskrit influence):
183
+ # a/ā + i/ī → e/ē
184
+ # a/ā + u/ū → o/ō
185
+ # a/ā + ṛ → ar
186
+
187
+ _ADESA_SANDHI_RULES: List[Tuple[str, str, str, str]] = []
188
+
189
+ # Guṇa: a/ā + i/ī → e/ē
190
+ for _a in ("a", "ā"):
191
+ for _i in ("i", "ī"):
192
+ _ADESA_SANDHI_RULES.append(("e", _a, _i, "ādeśa"))
193
+ _ADESA_SANDHI_RULES.append(("ē", _a, _i, "ādeśa"))
194
+
195
+ # Guṇa: a/ā + u/ū → o/ō
196
+ for _a in ("a", "ā"):
197
+ for _u in ("u", "ū"):
198
+ _ADESA_SANDHI_RULES.append(("o", _a, _u, "ādeśa"))
199
+ _ADESA_SANDHI_RULES.append(("ō", _a, _u, "ādeśa"))
200
+
201
+ # Guṇa: a/ā + ṛ → ar (in tatsama words)
202
+ _ADESA_SANDHI_RULES.append(("ar", "a", "ṛ", "ādeśa"))
203
+ _ADESA_SANDHI_RULES.append(("ar", "ā", "ṛ", "ādeśa"))
204
+
205
+ # Vṛddhi-like: ā + i/ī → ai; ā + u/ū → au (in tatsama words)
206
+ for _i in ("i", "ī"):
207
+ _ADESA_SANDHI_RULES.append(("ai", "ā", _i, "ādeśa"))
208
+ for _u in ("u", "ū"):
209
+ _ADESA_SANDHI_RULES.append(("au", "ā", _u, "ādeśa"))
210
+
211
+
212
+ # ---- 4. Consonant Sandhi ----------------------------------------------------
213
+ #
214
+ # 4a. Voicing assimilation — an originally unvoiced final stop becomes
215
+ # voiced before a voiced consonant / vowel in the next word.
216
+ #
217
+ # 4b. Gemination — consonant doubling at word boundaries, very common in
218
+ # Dravidian Kannada (e.g., compound words often geminate the initial
219
+ # consonant of the second member).
220
+ #
221
+ # 4c. Nasal assimilation — a final nasal assimilates to the class of a
222
+ # following stop.
223
+
224
+ _CONSONANT_SANDHI_RULES: List[Tuple[str, str, str, str]] = []
225
+
226
+ # 4a. Voicing assimilation — voiced stop at end could be unvoiced original
227
+ for _unv, _voi in STOPS_VOICED.items():
228
+ _CONSONANT_SANDHI_RULES.append((_voi, _unv, "", "consonant"))
229
+
230
+ # 4b. Gemination — doubled consonant at junction → split before second
231
+ # This is a very productive pattern in Kannada compounds.
232
+ _GEMINATION_CONSONANTS: list[str] = [
233
+ "k", "g", "c", "j", "ṭ", "ḍ", "t", "d", "p", "b",
234
+ "m", "n", "ṇ", "ṅ", "ñ",
235
+ "y", "r", "l", "v", "ḷ",
236
+ "s", "ś", "ṣ", "h",
237
+ ]
238
+ for _c in _GEMINATION_CONSONANTS:
239
+ # doubled consonant → first word ends with single, second word starts
240
+ # with single (the gemination was introduced at the boundary)
241
+ _CONSONANT_SANDHI_RULES.append((_c + _c, _c, _c, "consonant"))
242
+
243
+ # 4c. Nasal assimilation — class nasal before a stop ← original 'm' or 'n'
244
+ for _stop, _nasal in NASALS_FOR_CLASS.items():
245
+ _CONSONANT_SANDHI_RULES.append((_nasal + _stop, "m", _stop, "consonant"))
246
+ _CONSONANT_SANDHI_RULES.append((_nasal + _stop, "n", _stop, "consonant"))
247
+
248
+ # 4d. Final 't' + sibilant combinations (in tatsama words)
249
+ _CONSONANT_SANDHI_RULES.append(("cch", "t", "ś", "consonant"))
250
+ _CONSONANT_SANDHI_RULES.append(("cc", "t", "c", "consonant"))
251
+ _CONSONANT_SANDHI_RULES.append(("jj", "t", "j", "consonant"))
252
+
253
+ # 4e. Final 't' → 'd' before voiced consonant (generic catch-all)
254
+ _CONSONANT_SANDHI_RULES.append(("d", "t", "", "consonant"))
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # Combined rule list — sorted longest pattern first for greedy matching
259
+ # ---------------------------------------------------------------------------
260
+
261
+ SANDHI_RULES: List[Tuple[str, str, str, str]] = sorted(
262
+ _LOPA_SANDHI_RULES
263
+ + _AGAMA_SANDHI_RULES
264
+ + _ADESA_SANDHI_RULES
265
+ + _CONSONANT_SANDHI_RULES,
266
+ key=lambda r: len(r[0]),
267
+ reverse=True,
268
+ )
269
+ """All reverse-sandhi rules, ordered longest ``combined_pattern`` first.
270
+
271
+ Each entry is ``(combined_pattern, left_part, right_part, category)`` where
272
+ *combined_pattern* is the surface string at the junction, *left_part* is
273
+ the restored ending of the first token, *right_part* is the restored
274
+ beginning of the second token (empty string when context-dependent), and
275
+ *category* is one of ``'lōpa'``, ``'āgama'``, ``'ādeśa'``, or
276
+ ``'consonant'``.
277
+ """
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # Candidate generation
282
+ # ---------------------------------------------------------------------------
283
+
284
+ _SINGLE_VOWEL_CHARS: set[str] = {
285
+ "a", "ā", "i", "ī", "u", "ū", "e", "ē", "o", "ō",
286
+ }
287
+
288
+ _CONSONANT_CHARS: set[str] = {
289
+ "k", "g", "c", "j", "ṭ", "ḍ", "t", "d", "p", "b",
290
+ "ṅ", "ñ", "ṇ", "n", "m",
291
+ "y", "r", "l", "v", "ḷ",
292
+ "ś", "ṣ", "s", "h",
293
+ }
294
+
295
+ # Bases that form multi-char aspirated consonants with a following 'h'
296
+ _ASPIRATE_BASES: set[str] = {
297
+ "k", "g", "c", "j", "ṭ", "ḍ", "t", "d", "p", "b",
298
+ }
299
+
300
+ # Categories that are vowel-type (require consonant-before-junction check)
301
+ _VOWEL_TYPE_CATEGORIES: set[str] = {"lōpa", "āgama", "ādeśa"}
302
+
303
+
304
+ def _generate_candidates(word: str) -> list[list[str]]:
305
+ """Generate all possible sandhi-split candidates for *word*.
306
+
307
+ For every position in the word where a rule's ``combined_pattern``
308
+ matches, we create a candidate split ``[left, right]`` by replacing the
309
+ combined pattern with the rule's left and right parts.
310
+
311
+ We also recurse on the *right* fragment so that chains of sandhi
312
+ (common in long compound words) are handled.
313
+
314
+ Returns
315
+ -------
316
+ list[list[str]]
317
+ Each element is a list of ISO 15919 string fragments. If no rule
318
+ applies anywhere in the word the returned list is empty — the
319
+ caller should fall back to ``[word]``.
320
+ """
321
+ candidates: list[list[str]] = []
322
+
323
+ for combined, left_part, right_part, category in SANDHI_RULES:
324
+ clen = len(combined)
325
+ if clen == 0:
326
+ continue
327
+
328
+ # Scan every possible junction position. A junction cannot be at
329
+ # position 0 (nothing on the left) or at position len(word) (nothing
330
+ # on the right). We need at least one character on each side of the
331
+ # combined pattern to form two meaningful tokens.
332
+ for pos in range(1, len(word) - clen + 1):
333
+ # Check if the combined pattern sits at this position
334
+ if word[pos: pos + clen] != combined:
335
+ continue
336
+
337
+ # Context validation for vowel-type sandhi (lōpa, āgama, ādeśa):
338
+ # the character immediately before the junction must be a
339
+ # consonant. Vowel-type sandhi occurs at the boundary between a
340
+ # consonant-final stem and a vowel-initial word; the consonant
341
+ # before the fused/inserted element is the last consonant of
342
+ # word-1. Without this check, rules would spuriously match
343
+ # inside words.
344
+ if category in _VOWEL_TYPE_CATEGORIES:
345
+ char_before = word[pos - 1].lower()
346
+ if char_before not in _CONSONANT_CHARS:
347
+ continue
348
+
349
+ # Guard against splitting inside ISO 15919 multi-char
350
+ # consonants. If char_before is 'h' and the char before
351
+ # *that* is a stop, then we're inside an aspirated consonant
352
+ # (dh, bh, kh, etc.) and this is NOT a valid junction point.
353
+ if (
354
+ char_before == "h"
355
+ and pos >= 2
356
+ and word[pos - 2].lower() in _ASPIRATE_BASES
357
+ ):
358
+ continue
359
+
360
+ # Also need material after the junction for it to be a split
361
+ if not word[pos + clen:]:
362
+ continue
363
+
364
+ # ----- reject trivial / degenerate splits -----
365
+ left_token = word[:pos] + left_part
366
+ right_token = right_part + word[pos + clen:]
367
+
368
+ if not left_token or not right_token:
369
+ continue
370
+
371
+ # Record the direct two-way split
372
+ candidates.append([left_token, right_token])
373
+
374
+ # Recurse on the right fragment to handle compound sandhi
375
+ sub_candidates = _generate_candidates(right_token)
376
+ for sub in sub_candidates:
377
+ candidates.append([left_token] + sub)
378
+
379
+ return candidates
380
+
381
+
382
+ # ---------------------------------------------------------------------------
383
+ # Candidate scoring
384
+ # ---------------------------------------------------------------------------
385
+
386
+ def _score_candidate(parts: list[str]) -> float:
387
+ """Score a candidate split — higher is better.
388
+
389
+ The scoring heuristic balances two goals:
390
+
391
+ 1. **Fewer parts** — every additional split risks an incorrect break, so
392
+ we penalise the number of parts.
393
+ 2. **Longer tokens** — very short (1-char) fragments are suspicious; we
394
+ reward candidates whose shortest token is longer.
395
+
396
+ The formula is intentionally simple so that it is fast and transparent::
397
+
398
+ score = min_token_length * 10 + avg_length * 2 - num_parts * 5
399
+
400
+ A more sophisticated scorer could consult a dictionary or n-gram model,
401
+ but for IR purposes this works well enough.
402
+
403
+ Parameters
404
+ ----------
405
+ parts : list[str]
406
+ The candidate list of tokens.
407
+
408
+ Returns
409
+ -------
410
+ float
411
+ A numeric score; higher values indicate better candidates.
412
+ """
413
+ if not parts:
414
+ return -1.0
415
+
416
+ num_parts = len(parts)
417
+ min_len = min(len(p) for p in parts)
418
+ avg_len = sum(len(p) for p in parts) / num_parts
419
+
420
+ # Prefer fewer parts (penalty) and longer minimum token (reward).
421
+ # The average length provides a secondary signal.
422
+ score = (min_len * 10.0) + (avg_len * 2.0) - (num_parts * 5.0)
423
+ return score
424
+
425
+
426
+ # ---------------------------------------------------------------------------
427
+ # Public API
428
+ # ---------------------------------------------------------------------------
429
+
430
+ def split_sandhi(word: str) -> list[str]:
431
+ """Split a single ISO 15919-encoded Kannada word at sandhi junctions.
432
+
433
+ Uses a rule-based approach: every reverse-sandhi rule is tried at every
434
+ position in the word, candidate splits are scored, and the highest-scoring
435
+ split is returned. If no rule produces a plausible split the word is
436
+ returned unchanged as ``[word]``.
437
+
438
+ Parameters
439
+ ----------
440
+ word : str
441
+ A single token in ISO 15919 transliteration.
442
+
443
+ Returns
444
+ -------
445
+ list[str]
446
+ One or more ISO 15919 tokens resulting from the split. Guaranteed
447
+ to be non-empty.
448
+
449
+ Examples
450
+ --------
451
+ >>> split_sandhi("rāmāyana")
452
+ ['rāma', 'āyana']
453
+ >>> split_sandhi("dēvi") # no split needed (too short / no rule)
454
+ ['dēvi']
455
+ """
456
+ if not word or len(word) <= 1:
457
+ return [word] if word else [""]
458
+
459
+ # Short words (≤5 chars) are very unlikely to contain a real sandhi
460
+ # junction that splits into two meaningful tokens.
461
+ if len(word) <= 5:
462
+ return [word]
463
+
464
+ candidates = _generate_candidates(word)
465
+
466
+ if not candidates:
467
+ return [word]
468
+
469
+ # Filter candidates: every fragment must have at least 3 characters
470
+ # to avoid degenerate splits like 'dha' + 'rma' from 'dharma'.
471
+ _MIN_TOKEN_LEN = 3
472
+ viable: list[list[str]] = []
473
+ for candidate in candidates:
474
+ if any(len(p) < _MIN_TOKEN_LEN for p in candidate):
475
+ continue
476
+ viable.append(candidate)
477
+
478
+ if not viable:
479
+ return [word]
480
+
481
+ # Score each viable candidate and pick the best one.
482
+ best: list[str] = viable[0]
483
+ best_score = _score_candidate(viable[0])
484
+
485
+ for candidate in viable[1:]:
486
+ score = _score_candidate(candidate)
487
+ if score > best_score:
488
+ best_score = score
489
+ best = candidate
490
+
491
+ # Only accept the split if it scores reasonably well compared to
492
+ # keeping the word intact. This prevents spurious splits on normal
493
+ # words like 'kannaḍa' where lōpa rules match but the split is
494
+ # nonsensical. The 0.8 factor biases toward splitting for IR recall.
495
+ unsplit_score = _score_candidate([word])
496
+ if best_score < unsplit_score * 0.8:
497
+ return [word]
498
+
499
+ return best
@@ -0,0 +1,74 @@
1
+ """Main tokenizer module for Kannada text processing.
2
+
3
+ Mirrors the Sanskrit tokenizer architecture, adapted for Kannada script
4
+ (Unicode block U+0C80–U+0CFF) and ISO 15919 transliteration.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from .transliterate import normalize_to_iso15919
12
+ from .sandhi import split_sandhi
13
+
14
+
15
+ # Punctuation pattern: Kannada-specific punctuation, Devanagari dandas,
16
+ # common Latin punctuation, and whitespace.
17
+ _SPLIT_PATTERN = re.compile(
18
+ r"[\s।॥\.\,\;\:\!\?\"\'\(\)\[\]\{\}\-\—\–]+"
19
+ )
20
+
21
+
22
+ def tokenize(text: str) -> list[str]:
23
+ """Tokenize Kannada text with transliteration normalization and sandhi splitting.
24
+
25
+ Pipeline:
26
+ 1. Normalize input to ISO 15919.
27
+ 2. Split on whitespace and punctuation (dandas, periods, commas, etc.).
28
+ 3. Attempt sandhi splitting on each word.
29
+ 4. Lowercase all tokens and filter empties.
30
+
31
+ Args:
32
+ text: Input Kannada text in any supported script/transliteration.
33
+
34
+ Returns:
35
+ A flat list of lowercase token strings.
36
+ """
37
+ if not text or not text.strip():
38
+ return []
39
+
40
+ normalized: str = normalize_to_iso15919(text)
41
+ raw_words: list[str] = _SPLIT_PATTERN.split(normalized)
42
+
43
+ tokens: list[str] = []
44
+ for word in raw_words:
45
+ word = word.strip()
46
+ if not word:
47
+ continue
48
+ # Attempt sandhi splitting; returns a list of sub-tokens
49
+ sub_tokens: list[str] = split_sandhi(word)
50
+ tokens.extend(sub_tokens)
51
+
52
+ # Lowercase and final empty filter
53
+ return [t.lower() for t in tokens if t.strip()]
54
+
55
+
56
+ def tokenize_words(text: str) -> list[str]:
57
+ """Tokenize Kannada text at word boundaries only (no sandhi splitting).
58
+
59
+ Useful when sandhi analysis is not needed or when working with
60
+ pre-split text.
61
+
62
+ Args:
63
+ text: Input Kannada text in any supported script/transliteration.
64
+
65
+ Returns:
66
+ A flat list of lowercase word-level tokens.
67
+ """
68
+ if not text or not text.strip():
69
+ return []
70
+
71
+ normalized: str = normalize_to_iso15919(text)
72
+ raw_words: list[str] = _SPLIT_PATTERN.split(normalized)
73
+
74
+ return [w.lower() for w in raw_words if w.strip()]
@@ -0,0 +1,599 @@
1
+ """
2
+ transliterate.py — Bidirectional Kannada ↔ ISO 15919 transliteration.
3
+
4
+ This module provides pure-Python, zero-dependency conversion between
5
+ Kannada script and ISO 15919 romanization (IAST extended for Dravidian
6
+ languages).
7
+
8
+ Public API
9
+ ----------
10
+ - kannada_to_iso15919(text) — Convert Kannada text to ISO 15919.
11
+ - iso15919_to_kannada(text) — Convert ISO 15919 text to Kannada.
12
+ - is_kannada(text) — Detect whether a string is primarily Kannada.
13
+ - normalize_to_iso15919(text) — Auto-detect script and return ISO 15919.
14
+
15
+ Design notes
16
+ ------------
17
+ * All mapping data lives in plain dicts/tuples at module level so tables can be
18
+ maintained, extended, or serialised without touching logic.
19
+ * Unicode NFC normalisation is applied at every public entry-point.
20
+ * Mixed-script text (e.g. Kannada mixed with ASCII punctuation) is handled
21
+ gracefully: characters outside the relevant script are passed through as-is.
22
+ * Kannada distinguishes short e/o (ಎ/ಒ) from long ē/ō (ಏ/ಓ), unlike
23
+ Devanagari. The ISO 15919 standard uses macrons (ē, ō) for the long forms.
24
+ * Two-part vowel signs (e.g. ೊ = \u0CC6 + \u0CBE) are handled as single
25
+ precomposed characters after NFC normalization.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import unicodedata
31
+ from typing import Dict, List, Tuple
32
+
33
+ # ──────────────────────────────────────────────────────────────────────
34
+ # §1 Constants — Unicode code-point ranges
35
+ # ──────────────────────────────────────────────────────────────────────
36
+
37
+ # Kannada block: U+0C80 – U+0CFF
38
+ _KANNADA_START = 0x0C80
39
+ _KANNADA_END = 0x0CFF
40
+
41
+ # Virama (halant) — suppresses the inherent 'a' vowel
42
+ VIRAMA = "\u0CCD" # ್
43
+
44
+ # Nukta — modifies a consonant to represent a borrowed sound
45
+ NUKTA = "\u0CBC" # ಼
46
+
47
+ # Chandrabindu, Anusvara, Visarga
48
+ CHANDRABINDU = "\u0C81" # ಁ
49
+ ANUSVARA = "\u0C82" # ಂ
50
+ VISARGA = "\u0C83" # ಃ
51
+
52
+ # ──────────────────────────────────────────────────────────────────────
53
+ # §2 Mapping tables — Kannada → ISO 15919
54
+ # ──────────────────────────────────────────────────────────────────────
55
+ #
56
+ # Tables are split into logical groups for clarity. Every group is a
57
+ # plain dict mapping a *single* Kannada character (or short sequence
58
+ # in the case of nukta consonants) to its ISO 15919 equivalent.
59
+ #
60
+ # For consonants the ISO 15919 value does NOT include the inherent 'a';
61
+ # that is inserted by the transliteration engine when no mātrā or
62
+ # virama follows.
63
+ #
64
+ # NOTE: Kannada has a richer vowel inventory than Devanagari — it
65
+ # distinguishes short e/o from long ē/ō. ISO 15919 uses macrons
66
+ # for the long forms.
67
+
68
+ # ----- 2a. Independent vowels (svara) -----
69
+ # These appear at the start of a word or after another vowel.
70
+ VOWELS_INDEPENDENT: Dict[str, str] = {
71
+ "ಅ": "a", # U+0C85
72
+ "ಆ": "ā", # U+0C86
73
+ "ಇ": "i", # U+0C87
74
+ "ಈ": "ī", # U+0C88
75
+ "ಉ": "u", # U+0C89
76
+ "ಊ": "ū", # U+0C8A
77
+ "ಋ": "ṛ", # U+0C8B
78
+ "ೠ": "ṝ", # U+0CE0
79
+ "ಌ": "ḷ", # U+0C8C
80
+ "ೡ": "ḹ", # U+0CE1
81
+ "ಎ": "e", # U+0C8E — short e (Dravidian distinction!)
82
+ "ಏ": "ē", # U+0C8F — long ē
83
+ "ಐ": "ai", # U+0C90
84
+ "ಒ": "o", # U+0C92 — short o (Dravidian distinction!)
85
+ "ಓ": "ō", # U+0C93 — long ō
86
+ "ಔ": "au", # U+0C94
87
+ }
88
+
89
+ # ----- 2b. Dependent vowel signs (mātrā) -----
90
+ # These attach to a preceding consonant and replace the inherent 'a'.
91
+ # Note: there is no mātrā for 'a' because 'a' is the inherent vowel.
92
+ #
93
+ # Some Kannada vowel signs are "two-part":
94
+ # ೊ (U+0CCA) = ೆ (U+0CC6) + ಾ (U+0CBE)
95
+ # ೋ (U+0CCB) = ೇ (U+0CC7) + ಾ (U+0CBE)
96
+ # ೌ (U+0CCC) = ೆ (U+0CC6) + ೕ (U+0CD5)
97
+ # NFC normalization collapses these into the precomposed forms listed below.
98
+ VOWELS_DEPENDENT: Dict[str, str] = {
99
+ "\u0CBE": "ā", # ಾ
100
+ "\u0CBF": "i", # ಿ
101
+ "\u0CC0": "ī", # ೀ
102
+ "\u0CC1": "u", # ು
103
+ "\u0CC2": "ū", # ೂ
104
+ "\u0CC3": "ṛ", # ೃ
105
+ "\u0CC4": "ṝ", # ೄ
106
+ "\u0CE2": "ḷ", # ೢ
107
+ "\u0CE3": "ḹ", # ೣ
108
+ "\u0CC6": "e", # ೆ — short e
109
+ "\u0CC7": "ē", # ೇ — long ē
110
+ "\u0CC8": "ai", # ೈ
111
+ "\u0CCA": "o", # ೊ — short o (precomposed two-part sign)
112
+ "\u0CCB": "ō", # ೋ — long ō (precomposed two-part sign)
113
+ "\u0CCC": "au", # ೌ
114
+ }
115
+
116
+ # ----- 2c. Consonants (vyañjana) -----
117
+ # Arranged by the traditional varga (class) system, plus Dravidian-specific
118
+ # consonants at the end.
119
+ CONSONANTS: Dict[str, str] = {
120
+ # -- Velar (kaṇṭhya) --
121
+ "ಕ": "k", # U+0C95
122
+ "ಖ": "kh", # U+0C96
123
+ "ಗ": "g", # U+0C97
124
+ "ಘ": "gh", # U+0C98
125
+ "ಙ": "ṅ", # U+0C99
126
+ # -- Palatal (tālavya) --
127
+ "ಚ": "c", # U+0C9A
128
+ "ಛ": "ch", # U+0C9B
129
+ "ಜ": "j", # U+0C9C
130
+ "ಝ": "jh", # U+0C9D
131
+ "ಞ": "ñ", # U+0C9E
132
+ # -- Retroflex (mūrdhanya) --
133
+ "ಟ": "ṭ", # U+0C9F
134
+ "ಠ": "ṭh", # U+0CA0
135
+ "ಡ": "ḍ", # U+0CA1
136
+ "ಢ": "ḍh", # U+0CA2
137
+ "ಣ": "ṇ", # U+0CA3
138
+ # -- Dental (dantya) --
139
+ "ತ": "t", # U+0CA4
140
+ "ಥ": "th", # U+0CA5
141
+ "ದ": "d", # U+0CA6
142
+ "ಧ": "dh", # U+0CA7
143
+ "ನ": "n", # U+0CA8
144
+ # -- Labial (oṣṭhya) --
145
+ "ಪ": "p", # U+0CAA
146
+ "ಫ": "ph", # U+0CAB
147
+ "ಬ": "b", # U+0CAC
148
+ "ಭ": "bh", # U+0CAD
149
+ "ಮ": "m", # U+0CAE
150
+ # -- Semi-vowels (antaḥstha) --
151
+ "ಯ": "y", # U+0CAF
152
+ "ರ": "r", # U+0CB0
153
+ "ಲ": "l", # U+0CB2
154
+ "ವ": "v", # U+0CB5
155
+ # -- Sibilants (ūṣman) --
156
+ "ಶ": "ś", # U+0CB6
157
+ "ಷ": "ṣ", # U+0CB7
158
+ "ಸ": "s", # U+0CB8
159
+ # -- Glottal --
160
+ "ಹ": "h", # U+0CB9
161
+ # -- Dravidian-specific --
162
+ "ಳ": "ḻ", # U+0CB3 — retroflex lateral (unique to Dravidian!)
163
+ "ೞ": "ẕ", # U+0CDE — retroflex approximant (archaic Kannada)
164
+ }
165
+
166
+ # ----- 2d. Nukta consonants (borrowed sounds) -----
167
+ # Each is the base consonant + nukta (U+0CBC).
168
+ # Less commonly used in Kannada than in Devanagari, but supported for
169
+ # loanword representation.
170
+ NUKTA_CONSONANTS: Dict[str, str] = {
171
+ "ಕ಼": "q", # ಕ + ಼
172
+ "ಖ಼": "x", # ಖ + ಼
173
+ "ಗ಼": "ġ", # ಗ + ಼
174
+ "ಜ಼": "z", # ಜ + ಼
175
+ "ಫ಼": "f", # ಫ + ಼
176
+ }
177
+
178
+ # ----- 2e. Modifiers & signs -----
179
+ MODIFIERS: Dict[str, str] = {
180
+ ANUSVARA: "ṃ",
181
+ VISARGA: "ḥ",
182
+ CHANDRABINDU: "m̐", # nasalisation mark
183
+ }
184
+
185
+ # ----- 2f. Kannada digits -----
186
+ DIGITS: Dict[str, str] = {
187
+ "೦": "0", # U+0CE6
188
+ "೧": "1", # U+0CE7
189
+ "೨": "2", # U+0CE8
190
+ "೩": "3", # U+0CE9
191
+ "೪": "4", # U+0CEA
192
+ "೫": "5", # U+0CEB
193
+ "೬": "6", # U+0CEC
194
+ "೭": "7", # U+0CED
195
+ "೮": "8", # U+0CEE
196
+ "೯": "9", # U+0CEF
197
+ }
198
+
199
+ # ----- 2g. Punctuation -----
200
+ # Danda and double danda are shared across Indic scripts (Devanagari block).
201
+ PUNCTUATION: Dict[str, str] = {
202
+ "\u0964": ".", # । danda → full stop
203
+ "\u0965": ".", # ॥ double danda → full stop
204
+ }
205
+
206
+ # ──────────────────────────────────────────────────────────────────────
207
+ # §3 Reverse mapping tables — ISO 15919 → Kannada
208
+ # ──────────────────────────────────────────────────────────────────────
209
+ #
210
+ # Built programmatically from the forward tables where possible, with
211
+ # hand-tuned additions for multi-character ISO 15919 sequences that must
212
+ # be matched longest-first to avoid ambiguity (e.g. "kh" before "k").
213
+
214
+ def _invert(d: Dict[str, str]) -> Dict[str, str]:
215
+ """Return {v: k for k, v in d.items()}, preserving first occurrence."""
216
+ inv: Dict[str, str] = {}
217
+ for k, v in d.items():
218
+ if v not in inv:
219
+ inv[v] = k
220
+ return inv
221
+
222
+
223
+ # Independent vowels: ISO 15919 → Kannada independent vowel
224
+ _ISO15919_TO_VOWEL_INDEPENDENT: Dict[str, str] = _invert(VOWELS_INDEPENDENT)
225
+
226
+ # Dependent vowels: ISO 15919 → Kannada mātrā
227
+ # 'a' maps to empty string because the inherent vowel needs no mātrā.
228
+ _ISO15919_TO_VOWEL_DEPENDENT: Dict[str, str] = _invert(VOWELS_DEPENDENT)
229
+ _ISO15919_TO_VOWEL_DEPENDENT["a"] = "" # inherent vowel — no visible mātrā
230
+
231
+ # Consonants: ISO 15919 → Kannada base consonant
232
+ _ISO15919_TO_CONSONANT: Dict[str, str] = _invert(CONSONANTS)
233
+
234
+ # Nukta consonants
235
+ _ISO15919_TO_NUKTA: Dict[str, str] = _invert(NUKTA_CONSONANTS)
236
+
237
+ # Modifiers
238
+ _ISO15919_TO_MODIFIER: Dict[str, str] = _invert(MODIFIERS)
239
+
240
+ # Digits
241
+ _ISO15919_TO_DIGIT: Dict[str, str] = _invert(DIGITS)
242
+
243
+ # All ISO 15919 vowel strings (used for lookahead when deciding inherent 'a').
244
+ _ALL_ISO15919_VOWELS: set = set(VOWELS_INDEPENDENT.values())
245
+
246
+ # Set of all Kannada dependent-vowel (mātrā) code-points.
247
+ _MATRA_CODEPOINTS: set = set(VOWELS_DEPENDENT.keys())
248
+
249
+ # Set of all Kannada independent-vowel characters.
250
+ _INDEPENDENT_VOWEL_CODEPOINTS: set = set(VOWELS_INDEPENDENT.keys())
251
+
252
+ # Set of all Kannada consonant characters (without nukta).
253
+ _CONSONANT_CODEPOINTS: set = set(CONSONANTS.keys())
254
+
255
+ # ──────────────────────────────────────────────────────────────────────
256
+ # §4 Helper — sorted ISO 15919 tokens for longest-match parsing
257
+ # ──────────────────────────────────────────────────────────────────────
258
+
259
+ def _build_sorted_iso15919_tokens() -> List[Tuple[str, str, str]]:
260
+ """
261
+ Return a list of (iso15919_token, kannada, category) tuples sorted by
262
+ descending token length so that the parser always tries the longest
263
+ match first (e.g. "kh" before "k", "ai" before "a").
264
+
265
+ Categories: 'consonant', 'vowel', 'modifier', 'digit', 'nukta'.
266
+ """
267
+ tokens: List[Tuple[str, str, str]] = []
268
+
269
+ for iso, kan in _ISO15919_TO_CONSONANT.items():
270
+ tokens.append((iso, kan, "consonant"))
271
+ for iso, kan in _ISO15919_TO_NUKTA.items():
272
+ tokens.append((iso, kan, "nukta"))
273
+ for iso, kan in _ISO15919_TO_VOWEL_INDEPENDENT.items():
274
+ tokens.append((iso, kan, "vowel"))
275
+ for iso, kan in _ISO15919_TO_MODIFIER.items():
276
+ tokens.append((iso, kan, "modifier"))
277
+ for iso, kan in _ISO15919_TO_DIGIT.items():
278
+ tokens.append((iso, kan, "digit"))
279
+
280
+ # Sort descending by length so longest tokens are tried first
281
+ tokens.sort(key=lambda t: len(t[0]), reverse=True)
282
+ return tokens
283
+
284
+
285
+ _ISO15919_TOKENS: List[Tuple[str, str, str]] = _build_sorted_iso15919_tokens()
286
+
287
+ # ──────────────────────────────────────────────────────────────────────
288
+ # §5 Core: Kannada → ISO 15919
289
+ # ──────────────────────────────────────────────────────────────────────
290
+
291
+ def _is_kannada_char(ch: str) -> bool:
292
+ """Return True if *ch* falls within the Kannada Unicode block."""
293
+ cp = ord(ch)
294
+ return _KANNADA_START <= cp <= _KANNADA_END
295
+
296
+
297
+ def kannada_to_iso15919(text: str) -> str:
298
+ """
299
+ Convert a Kannada string to ISO 15919.
300
+
301
+ The algorithm walks the string character-by-character, maintaining a
302
+ flag ``pending_a`` that tracks whether the inherent vowel 'a' should
303
+ be emitted after a consonant. The inherent vowel is suppressed when
304
+ a virama or a dependent vowel (mātrā) follows.
305
+
306
+ Non-Kannada characters (Latin letters, ASCII punctuation, …) are
307
+ passed through unchanged, which makes the function safe for mixed-
308
+ script input.
309
+
310
+ Parameters
311
+ ----------
312
+ text : str
313
+ Input string, possibly containing Kannada characters.
314
+
315
+ Returns
316
+ -------
317
+ str
318
+ ISO 15919 transliteration of *text*.
319
+ """
320
+ # Always work with NFC-normalised text so that two-part vowel signs
321
+ # (e.g. ೊ = ೆ + ಾ) are collapsed into their precomposed forms,
322
+ # and nukta sequences (base + U+0CBC) are in canonical form.
323
+ text = unicodedata.normalize("NFC", text)
324
+
325
+ out: List[str] = []
326
+ i = 0
327
+ n = len(text)
328
+ pending_a = False # True when we've just emitted a consonant and may need 'a'
329
+
330
+ while i < n:
331
+ ch = text[i]
332
+
333
+ # ── Nukta consonants (two-char sequences: base + nukta) ──
334
+ # Check BEFORE single consonants so "ಕ಼" is matched as a unit.
335
+ if i + 1 < n and text[i + 1] == NUKTA:
336
+ pair = text[i : i + 2]
337
+ if pair in NUKTA_CONSONANTS:
338
+ # Flush any pending inherent 'a' from a previous consonant
339
+ if pending_a:
340
+ out.append("a")
341
+ pending_a = False
342
+ out.append(NUKTA_CONSONANTS[pair])
343
+ pending_a = True
344
+ i += 2
345
+ continue
346
+
347
+ # ── Virama (halant) — kills the inherent vowel ──
348
+ if ch == VIRAMA:
349
+ # Cancel the pending inherent 'a'
350
+ pending_a = False
351
+ i += 1
352
+ continue
353
+
354
+ # ── Dependent vowels (mātrā) — replace inherent 'a' ──
355
+ if ch in VOWELS_DEPENDENT:
356
+ # A mātrā always cancels the inherent 'a' and provides its
357
+ # own vowel sound.
358
+ pending_a = False
359
+ out.append(VOWELS_DEPENDENT[ch])
360
+ i += 1
361
+ continue
362
+
363
+ # ── Consonants ──
364
+ if ch in CONSONANTS:
365
+ if pending_a:
366
+ out.append("a")
367
+ out.append(CONSONANTS[ch])
368
+ pending_a = True
369
+ i += 1
370
+ continue
371
+
372
+ # ── Independent vowels ──
373
+ if ch in VOWELS_INDEPENDENT:
374
+ if pending_a:
375
+ out.append("a")
376
+ pending_a = False
377
+ out.append(VOWELS_INDEPENDENT[ch])
378
+ i += 1
379
+ continue
380
+
381
+ # ── Modifiers (anusvara, visarga, chandrabindu) ──
382
+ if ch in MODIFIERS:
383
+ # Modifiers attach to the preceding syllable; flush 'a' first.
384
+ if pending_a:
385
+ out.append("a")
386
+ pending_a = False
387
+ out.append(MODIFIERS[ch])
388
+ i += 1
389
+ continue
390
+
391
+ # ── Digits ──
392
+ if ch in DIGITS:
393
+ if pending_a:
394
+ out.append("a")
395
+ pending_a = False
396
+ out.append(DIGITS[ch])
397
+ i += 1
398
+ continue
399
+
400
+ # ── Punctuation ──
401
+ if ch in PUNCTUATION:
402
+ if pending_a:
403
+ out.append("a")
404
+ pending_a = False
405
+ out.append(PUNCTUATION[ch])
406
+ i += 1
407
+ continue
408
+
409
+ # ── Nukta by itself (shouldn't happen in well-formed text) ──
410
+ if ch == NUKTA:
411
+ i += 1
412
+ continue
413
+
414
+ # ── Anything else (Latin, punctuation, whitespace …) ──
415
+ if pending_a:
416
+ out.append("a")
417
+ pending_a = False
418
+ out.append(ch)
419
+ i += 1
420
+
421
+ # If the string ends with a consonant that still has a pending 'a'
422
+ if pending_a:
423
+ out.append("a")
424
+
425
+ return "".join(out)
426
+
427
+
428
+ # ──────────────────────────────────────────────────────────────────────
429
+ # §6 Core: ISO 15919 → Kannada
430
+ # ──────────────────────────────────────────────────────────────────────
431
+
432
+ def _is_iso15919_vowel_at(text: str, pos: int) -> Tuple[bool, str, int]:
433
+ """
434
+ Check whether an ISO 15919 vowel token starts at *pos* in *text*.
435
+
436
+ Returns (matched, iso15919_vowel_string, length) on success,
437
+ or (False, '', 0) otherwise.
438
+
439
+ Uses longest-match semantics ('ai' before 'a', 'au' before 'a', etc.).
440
+ """
441
+ # Check two-char vowels first, then one-char.
442
+ for length in (2, 1):
443
+ candidate = text[pos : pos + length].lower()
444
+ if candidate in _ISO15919_TO_VOWEL_INDEPENDENT:
445
+ return True, candidate, length
446
+ return False, "", 0
447
+
448
+
449
+ def iso15919_to_kannada(text: str) -> str:
450
+ """
451
+ Convert an ISO 15919 string to Kannada.
452
+
453
+ The parser scans *text* from left to right using longest-match
454
+ tokenisation. It tracks whether the current context is "after a
455
+ consonant" so it can choose between independent vowels and mātrā
456
+ forms, and inserts virama between consecutive consonants.
457
+
458
+ Parameters
459
+ ----------
460
+ text : str
461
+ Input string in ISO 15919 (or mixed ISO 15919 / other scripts).
462
+
463
+ Returns
464
+ -------
465
+ str
466
+ Kannada transliteration of *text*.
467
+ """
468
+ text = unicodedata.normalize("NFC", text)
469
+
470
+ out: List[str] = []
471
+ i = 0
472
+ n = len(text)
473
+ after_consonant = False # True when the last emitted char was a consonant
474
+
475
+ while i < n:
476
+ matched = False
477
+
478
+ # Try every token (longest first) against the current position.
479
+ for iso_tok, kan, category in _ISO15919_TOKENS:
480
+ tok_len = len(iso_tok)
481
+ # Case-insensitive comparison for the ISO 15919 token
482
+ if text[i : i + tok_len].lower() == iso_tok.lower():
483
+ if category in ("consonant", "nukta"):
484
+ if after_consonant:
485
+ # Previous consonant needs virama before this one
486
+ out.append(VIRAMA)
487
+ out.append(kan)
488
+ after_consonant = True
489
+ elif category == "vowel":
490
+ if after_consonant:
491
+ # Use the dependent (mātrā) form and suppress the
492
+ # inherent 'a'.
493
+ matra = _ISO15919_TO_VOWEL_DEPENDENT.get(iso_tok.lower(), "")
494
+ out.append(matra) # "" for 'a' → no visible mātrā
495
+ after_consonant = False
496
+ else:
497
+ # Word-initial or after another vowel → independent form
498
+ out.append(kan)
499
+ elif category == "modifier":
500
+ # Modifiers attach after the syllable
501
+ if after_consonant:
502
+ # Implicit 'a' is assumed before the modifier
503
+ after_consonant = False
504
+ out.append(kan)
505
+ elif category == "digit":
506
+ if after_consonant:
507
+ after_consonant = False
508
+ out.append(kan)
509
+
510
+ i += tok_len
511
+ matched = True
512
+ break
513
+
514
+ if not matched:
515
+ # Not an ISO 15919 token — pass through (spaces, ASCII punctuation, …)
516
+ if after_consonant:
517
+ after_consonant = False
518
+ out.append(text[i])
519
+ i += 1
520
+
521
+ # If the string ends mid-consonant, we must add a virama to suppress
522
+ # the inherent 'a'. ISO 15919 is explicit about final vowels, so a bare
523
+ # consonant at end-of-string means the halant form.
524
+ if after_consonant:
525
+ out.append(VIRAMA)
526
+
527
+ return "".join(out)
528
+
529
+
530
+ # ──────────────────────────────────────────────────────────────────────
531
+ # §7 Detection: is_kannada
532
+ # ──────────────────────────────────────────────────────────────────────
533
+
534
+ def is_kannada(text: str, threshold: float = 0.5) -> bool:
535
+ """
536
+ Return ``True`` if *text* is primarily written in Kannada script.
537
+
538
+ The heuristic counts how many *non-whitespace* characters fall inside
539
+ the Kannada Unicode block and returns ``True`` when the ratio meets
540
+ or exceeds *threshold* (default 50 %).
541
+
542
+ Parameters
543
+ ----------
544
+ text : str
545
+ The text to inspect.
546
+ threshold : float
547
+ Minimum fraction of Kannada characters (among scriptable
548
+ characters) to consider the text "Kannada". Defaults to 0.5.
549
+
550
+ Returns
551
+ -------
552
+ bool
553
+ """
554
+ if not text:
555
+ return False
556
+
557
+ text = unicodedata.normalize("NFC", text)
558
+
559
+ total = 0
560
+ kannada_count = 0
561
+ for ch in text:
562
+ # Skip whitespace for the ratio
563
+ if ch.isspace():
564
+ continue
565
+ total += 1
566
+ if _is_kannada_char(ch):
567
+ kannada_count += 1
568
+
569
+ if total == 0:
570
+ return False
571
+ return (kannada_count / total) >= threshold
572
+
573
+
574
+ # ──────────────────────────────────────────────────────────────────────
575
+ # §8 Convenience: normalize_to_iso15919
576
+ # ──────────────────────────────────────────────────────────────────────
577
+
578
+ def normalize_to_iso15919(text: str) -> str:
579
+ """
580
+ Auto-detect the script of *text* and return its ISO 15919 representation.
581
+
582
+ - If *text* is detected as Kannada, it is transliterated to ISO 15919.
583
+ - Otherwise it is returned as-is (assumed to already be ISO 15919 or some
584
+ other Latin-based notation).
585
+
586
+ Parameters
587
+ ----------
588
+ text : str
589
+ Input string in Kannada or ISO 15919.
590
+
591
+ Returns
592
+ -------
593
+ str
594
+ ISO 15919 representation of *text*.
595
+ """
596
+ text = unicodedata.normalize("NFC", text)
597
+ if is_kannada(text):
598
+ return kannada_to_iso15919(text)
599
+ return text
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: kannada-tokenizer
3
+ Version: 0.1.0
4
+ Summary: Kannada tokenizer with sandhi splitting for Information Retrieval.
5
+ Author: Hemanth HM
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Topic :: Text Processing :: Linguistic
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+
12
+ # kannada-tokenizer
13
+
14
+ Tokenize Kannada text with sandhi splitting for Information Retrieval.
15
+
16
+ ```bash
17
+ pip install kannada-tokenizer
18
+ ```
19
+
20
+ ## Quick start
21
+
22
+ ```python
23
+ from kannada_tokenizer import tokenize
24
+
25
+ tokenize("ಕನ್ನಡ ಧರ್ಮ")
26
+ # ['kannaḍa', 'dharma']
27
+
28
+ tokenize("vidyālaya")
29
+ # ['vidyālaya']
30
+
31
+ tokenize("mahōtsava")
32
+ # ['maha', 'ōtsava']
33
+ ```
34
+
35
+ `tokenize()` normalizes to ISO 15919, splits on whitespace and punctuation, then applies reverse sandhi rules. Accepts both Kannada script and romanized input.
36
+
37
+ ## Sandhi splitting
38
+
39
+ ```python
40
+ from kannada_tokenizer.sandhi import split_sandhi
41
+
42
+ split_sandhi("rāmāyana") # lōpa sandhi: ā → a + ā
43
+ # ['rāma', 'āyana']
44
+
45
+ split_sandhi("kannaḍa") # no junction found
46
+ # ['kannaḍa']
47
+ ```
48
+
49
+ Rule-based engine covering lōpa sandhi (vowel elision), āgama sandhi (y/v insertion), ādeśa sandhi (guṇa-like substitution), and consonant sandhi (voicing, gemination, nasals). Uses longest-match heuristic when splits are ambiguous.
50
+
51
+ ## Transliteration
52
+
53
+ ```python
54
+ from kannada_tokenizer.transliterate import (
55
+ kannada_to_iso15919,
56
+ iso15919_to_kannada,
57
+ is_kannada,
58
+ )
59
+
60
+ kannada_to_iso15919("ಬೆಂಗಳೂರು")
61
+ # 'beṃgaḻūru'
62
+
63
+ iso15919_to_kannada("kannaḍa")
64
+ # 'ಕನ್ನಡ'
65
+
66
+ is_kannada("ಕನ್ನಡ")
67
+ # True
68
+ ```
69
+
70
+ Handles Kannada's short/long e/o distinction (ಎ→e vs ಏ→ē) and the retroflex lateral ಳ→ḻ unique to Dravidian.
71
+
72
+ ## Word-level tokenization
73
+
74
+ ```python
75
+ from kannada_tokenizer.tokenizer import tokenize_words
76
+
77
+ tokenize_words("vidyālaya namaḥ")
78
+ # ['vidyālaya', 'namaḥ']
79
+ ```
80
+
81
+ `tokenize_words()` splits on whitespace and punctuation only — no sandhi splitting.
82
+
83
+ ## CLI
84
+
85
+ ```bash
86
+ kannada-tokenize "ಕನ್ನಡ ಧರ್ಮ"
87
+ # kannaḍa
88
+ # dharma
89
+
90
+ echo "vidyālaya" | kannada-tokenize
91
+ # vidyālaya
92
+
93
+ kannada-tokenize --no-sandhi "mahōtsava"
94
+ # mahōtsava
95
+
96
+ kannada-tokenize -s " " "dharma yōga"
97
+ # dharma yōga
98
+ ```
99
+
100
+ - `--no-sandhi` — word-level only, skip sandhi splitting
101
+ - `--separator SEP` — output separator (default: newline)
102
+
103
+ ## License
104
+
105
+ MIT © [Hemanth.HM](https://h3manth.com)
@@ -0,0 +1,10 @@
1
+ kannada_tokenizer/__init__.py,sha256=b60TojvmvfbcIKpAgow0jn_qHPSS6RaDzdta5yxsHzs,169
2
+ kannada_tokenizer/cli.py,sha256=Td9zrG3z8GWSJRUxH6b29duEQQ22QhHjiE6bPVWwl7Y,1630
3
+ kannada_tokenizer/sandhi.py,sha256=8_uq9aZpKy8BqFnitEazjtnZgxf4yZOfEmMQ6Tw5Xek,18292
4
+ kannada_tokenizer/tokenizer.py,sha256=grjTDyVzd_nM668lB_GzN-y3Nr99gBgjCfnn3nqIUEE,2173
5
+ kannada_tokenizer/transliterate.py,sha256=8Himvzbnr9BXjDzT2FVgOKPvlElA7ZhXMLDCRLGhe-U,22702
6
+ kannada_tokenizer-0.1.0.dist-info/METADATA,sha256=qOq_HJ4eKOIy5LEyNTQc8U50ZEuJQXTxj0drdha-5pE,2449
7
+ kannada_tokenizer-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
8
+ kannada_tokenizer-0.1.0.dist-info/entry_points.txt,sha256=DyNcEFJI9f5BeV3gG0l1H4VB7_4ujcpDbvx9OwphPHY,64
9
+ kannada_tokenizer-0.1.0.dist-info/top_level.txt,sha256=w0MkiLbHMVQDYf32CrM0hZSLgI1fjabI2BXAh_u_Bwo,18
10
+ kannada_tokenizer-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kannada-tokenize = kannada_tokenizer.cli:main
@@ -0,0 +1 @@
1
+ kannada_tokenizer