sinlib 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {sinlib-0.2.2 → sinlib-0.2.4}/.readthedocs.yaml +1 -1
  2. {sinlib-0.2.2 → sinlib-0.2.4}/PKG-INFO +1 -1
  3. {sinlib-0.2.2 → sinlib-0.2.4}/pyproject.toml +1 -1
  4. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/__init__.py +1 -1
  5. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/spellcheck.py +98 -50
  6. {sinlib-0.2.2 → sinlib-0.2.4}/tests/test_spellcheck.py +15 -12
  7. {sinlib-0.2.2 → sinlib-0.2.4}/website/astro.config.mjs +2 -0
  8. {sinlib-0.2.2 → sinlib-0.2.4}/website/src/content/docs/examples/visualization.md +4 -1
  9. {sinlib-0.2.2 → sinlib-0.2.4}/.github/workflows/python-publish.yml +0 -0
  10. {sinlib-0.2.2 → sinlib-0.2.4}/.github/workflows/test.yml +0 -0
  11. {sinlib-0.2.2 → sinlib-0.2.4}/.gitignore +0 -0
  12. {sinlib-0.2.2 → sinlib-0.2.4}/LICENSE +0 -0
  13. {sinlib-0.2.2 → sinlib-0.2.4}/README.md +0 -0
  14. {sinlib-0.2.2 → sinlib-0.2.4}/requirements-dev.txt +0 -0
  15. {sinlib-0.2.2 → sinlib-0.2.4}/requirements.txt +0 -0
  16. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/encoding.py +0 -0
  17. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/romanize.py +0 -0
  18. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/subword.py +0 -0
  19. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/tokenizer.py +0 -0
  20. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/transliterate.py +0 -0
  21. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/utils/__init__.py +0 -0
  22. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/utils/chars.py +0 -0
  23. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/utils/dataset_utils.py +0 -0
  24. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/utils/model_utils.py +0 -0
  25. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/utils/models/__init__.py +0 -0
  26. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/utils/models/akshara_ngram.json +0 -0
  27. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/utils/models/akshara_vocab.json +0 -0
  28. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/utils/models/bigru_detector.pt +0 -0
  29. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/utils/models/transliterator-checkpoint.pth +0 -0
  30. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/utils/models/transliterator_model.py +0 -0
  31. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/utils/preprocessing.py +0 -0
  32. {sinlib-0.2.2 → sinlib-0.2.4}/src/sinlib/utils/visualize.py +0 -0
  33. {sinlib-0.2.2 → sinlib-0.2.4}/tests/test_combined_detector.py +0 -0
  34. {sinlib-0.2.2 → sinlib-0.2.4}/tests/test_phase3_enhancements.py +0 -0
  35. {sinlib-0.2.2 → sinlib-0.2.4}/tests/test_romanizer.py +0 -0
  36. {sinlib-0.2.2 → sinlib-0.2.4}/tests/test_tokenizer.py +0 -0
  37. {sinlib-0.2.2 → sinlib-0.2.4}/tests/test_tokenizer_batch.py +0 -0
  38. {sinlib-0.2.2 → sinlib-0.2.4}/tests/test_tokenizer_bos.py +0 -0
  39. {sinlib-0.2.2 → sinlib-0.2.4}/tests/test_tokenizer_special_tokens.py +0 -0
  40. {sinlib-0.2.2 → sinlib-0.2.4}/tests/test_transliterator.py +0 -0
  41. {sinlib-0.2.2 → sinlib-0.2.4}/tests/test_visualize.py +0 -0
  42. {sinlib-0.2.2 → sinlib-0.2.4}/website/.gitignore +0 -0
  43. {sinlib-0.2.2 → sinlib-0.2.4}/website/.vscode/extensions.json +0 -0
  44. {sinlib-0.2.2 → sinlib-0.2.4}/website/.vscode/launch.json +0 -0
  45. {sinlib-0.2.2 → sinlib-0.2.4}/website/README.md +0 -0
  46. {sinlib-0.2.2 → sinlib-0.2.4}/website/package-lock.json +0 -0
  47. {sinlib-0.2.2 → sinlib-0.2.4}/website/package.json +0 -0
  48. {sinlib-0.2.2 → sinlib-0.2.4}/website/public/favicon.svg +0 -0
  49. {sinlib-0.2.2 → sinlib-0.2.4}/website/src/assets/houston.webp +0 -0
  50. {sinlib-0.2.2 → sinlib-0.2.4}/website/src/assets/logo-dark.png +0 -0
  51. {sinlib-0.2.2 → sinlib-0.2.4}/website/src/assets/logo-light.png +0 -0
  52. {sinlib-0.2.2 → sinlib-0.2.4}/website/src/content/docs/api/encoding.mdx +0 -0
  53. {sinlib-0.2.2 → sinlib-0.2.4}/website/src/content/docs/api/preprocessing.mdx +0 -0
  54. {sinlib-0.2.2 → sinlib-0.2.4}/website/src/content/docs/api/romanizer.mdx +0 -0
  55. {sinlib-0.2.2 → sinlib-0.2.4}/website/src/content/docs/api/spellcheck.mdx +0 -0
  56. {sinlib-0.2.2 → sinlib-0.2.4}/website/src/content/docs/api/tokenizer.mdx +0 -0
  57. {sinlib-0.2.2 → sinlib-0.2.4}/website/src/content/docs/api/visualization.mdx +0 -0
  58. {sinlib-0.2.2 → sinlib-0.2.4}/website/src/content/docs/examples/tokenization.md +0 -0
  59. {sinlib-0.2.2 → sinlib-0.2.4}/website/src/content/docs/examples/typo-correction.md +0 -0
  60. {sinlib-0.2.2 → sinlib-0.2.4}/website/src/content/docs/guides/spellcheck.mdx +0 -0
  61. {sinlib-0.2.2 → sinlib-0.2.4}/website/src/content/docs/guides/tokenization.mdx +0 -0
  62. {sinlib-0.2.2 → sinlib-0.2.4}/website/src/content/docs/index.mdx +0 -0
  63. {sinlib-0.2.2 → sinlib-0.2.4}/website/src/content.config.ts +0 -0
  64. {sinlib-0.2.2 → sinlib-0.2.4}/website/src/styles/custom.css +0 -0
  65. {sinlib-0.2.2 → sinlib-0.2.4}/website/tsconfig.json +0 -0
  66. {sinlib-0.2.2 → sinlib-0.2.4}/welcome.png +0 -0
@@ -6,6 +6,6 @@ build:
6
6
  nodejs: "20"
7
7
  commands:
8
8
  - cd website && npm ci
9
- - cd website && npm run build
9
+ - cd website && ASTRO_TELEMETRY_DISABLED=1 npm run build
10
10
  - mkdir -p $READTHEDOCS_OUTPUT/html
11
11
  - cp -r website/dist/* $READTHEDOCS_OUTPUT/html/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sinlib
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Sinhala NLP Toolkit
5
5
  Project-URL: Code, https://github.com/Ransaka/sinlib
6
6
  Project-URL: Docs, https://sinlib.readthedocs.io
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sinlib"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  description = "Sinhala NLP Toolkit"
5
5
  authors = [
6
6
  { name = "Ransaka", email = "ransaka.ravihara@gmail.com" }
@@ -54,4 +54,4 @@ __all__: List[str] = [
54
54
  "setup_matplotlib",
55
55
  ]
56
56
 
57
- __version__ = "0.2.2"
57
+ __version__ = "0.2.4"
@@ -3,15 +3,15 @@ from __future__ import annotations
3
3
  import math
4
4
  import warnings
5
5
  import json
6
+ import re
6
7
  from pathlib import Path
7
8
  from difflib import get_close_matches, SequenceMatcher
8
- from functools import lru_cache
9
- from typing import Dict, List, Optional, Set
9
+ from typing import Dict, List, Optional, Set, Tuple
10
10
 
11
11
  import numpy as np
12
12
 
13
13
  from sinlib.tokenizer import Tokenizer
14
- from sinlib.utils.preprocessing import download_hub_file, Filenames
14
+ from sinlib.utils.preprocessing import download_hub_file, Filenames, normalize_sinhala, process_text
15
15
 
16
16
  # Optional PyTorch import for Bi-GRU sequence labeling
17
17
  try:
@@ -42,8 +42,8 @@ class AksharaNGram:
42
42
  return prob
43
43
 
44
44
  def score_word(self, word: str) -> float:
45
- from sinlib.utils.preprocessing import process_text
46
- tokens = process_text(word)
45
+ normalized = normalize_sinhala(word)
46
+ tokens = process_text(normalized)
47
47
  if not tokens:
48
48
  return -999.0
49
49
 
@@ -194,7 +194,7 @@ else:
194
194
  # Phonological Confusion & Keyboard Layout Proximity
195
195
  # ------------------------------------------------------------------
196
196
 
197
- CONFUSING_PAIRS: Set[tuple[str, str]] = {
197
+ CONFUSING_PAIRS: Set[Tuple[str, str]] = {
198
198
  ("න", "ණ"), ("ල", "ළ"), ("ස", "ශ"), ("ස", "ෂ"), ("ශ", "ෂ"), ("ර", "ල"),
199
199
  ("ත", "ථ"), ("ද", "ධ"), ("ට", "ඨ"), ("ඩ", "ඪ"), ("ප", "ඵ"), ("බ", "භ"),
200
200
  ("ක", "ඛ"), ("ග", "ඝ"), ("ච", "ඡ"), ("ජ", "ඣ"),
@@ -258,14 +258,16 @@ def are_characters_keyboard_adjacent(c1: str, c2: str) -> bool:
258
258
  k1_w = WIJESEKERA_MAP.get(c1)
259
259
  k2_w = WIJESEKERA_MAP.get(c2)
260
260
  if k1_w and k2_w:
261
- if is_qwerty_adjacent(k1_w.lower(), k2_w.lower()):
262
- return True
261
+ if k1_w.lower() != k2_w.lower():
262
+ if is_qwerty_adjacent(k1_w.lower(), k2_w.lower()):
263
+ return True
263
264
 
264
265
  k1_p = PHONETIC_MAP.get(c1)
265
266
  k2_p = PHONETIC_MAP.get(c2)
266
267
  if k1_p and k2_p and k1_p != "" and k2_p != "":
267
- if is_qwerty_adjacent(k1_p.lower(), k2_p.lower()):
268
- return True
268
+ if k1_p.lower() != k2_p.lower():
269
+ if is_qwerty_adjacent(k1_p.lower(), k2_p.lower()):
270
+ return True
269
271
 
270
272
  return False
271
273
 
@@ -324,15 +326,16 @@ def weighted_levenshtein(s1: str, s2: str) -> float:
324
326
  class TrieNode:
325
327
  def __init__(self):
326
328
  self.children: Dict[str, TrieNode] = {}
327
- self.word: str = None
329
+ self.word: Optional[str] = None
328
330
 
329
331
  class PhonologicalTrie:
330
332
  def __init__(self):
331
333
  self.root = TrieNode()
334
+ self._sub_cost_cache: Dict[Tuple[str, str], float] = {}
332
335
 
333
336
  def insert(self, word: str):
334
- from sinlib.utils.preprocessing import process_text
335
- tokens = process_text(word)
337
+ normalized = normalize_sinhala(word)
338
+ tokens = process_text(normalized)
336
339
  if not tokens:
337
340
  return
338
341
  curr = self.root
@@ -343,7 +346,25 @@ class PhonologicalTrie:
343
346
  curr.word = word
344
347
 
345
348
  def _get_substitution_cost(self, c1: str, c2: str) -> float:
346
- return weighted_levenshtein(c1, c2)
349
+ if c1 == c2:
350
+ return 0.0
351
+ cache_key = (c1, c2)
352
+ if cache_key in self._sub_cost_cache:
353
+ return self._sub_cost_cache[cache_key]
354
+
355
+ DIACRITICS = {"ා", "ැ", "ෑ", "ි", "ී", "ු", "ූ", "ෘ", "ෲ", "ෙ", "ේ", "ෛ", "ො", "ෝ", "ෞ", "ං", "ඃ", "්"}
356
+ if (c1, c2) in CONFUSING_PAIRS or (c2, c1) in CONFUSING_PAIRS:
357
+ cost = 0.3
358
+ elif are_characters_keyboard_adjacent(c1, c2):
359
+ cost = 0.5
360
+ elif c1 in DIACRITICS and c2 in DIACRITICS:
361
+ cost = 0.4
362
+ else:
363
+ cost = 1.0
364
+
365
+ self._sub_cost_cache[cache_key] = cost
366
+ self._sub_cost_cache[(c2, c1)] = cost
367
+ return cost
347
368
 
348
369
  def _search_recursive(self, node: TrieNode, token: str, previous_row: List[float],
349
370
  search_tokens: List[str], max_cost: float, results: List[Tuple[float, str]]):
@@ -374,8 +395,8 @@ class PhonologicalTrie:
374
395
  self._search_recursive(child_node, next_token, current_row, search_tokens, max_cost, results)
375
396
 
376
397
  def search(self, word: str, max_cost: float = 1.5) -> List[Tuple[float, str]]:
377
- from sinlib.utils.preprocessing import process_text
378
- search_tokens = process_text(word)
398
+ normalized = normalize_sinhala(word)
399
+ search_tokens = process_text(normalized)
379
400
  if not search_tokens:
380
401
  return []
381
402
 
@@ -449,6 +470,8 @@ class TypoDetector:
449
470
  self.bigru_corrector: Optional[BiGRUSeq2Seq] = None
450
471
  self.has_neural_corrector: bool = False
451
472
 
473
+ self._suggestion_cache: Dict[Tuple[str, int, Optional[str], Optional[str]], List[str]] = {}
474
+
452
475
  if not lazy_loading:
453
476
  self._ensure_loaded()
454
477
 
@@ -598,19 +621,20 @@ class TypoDetector:
598
621
  2. Bi-GRU Sequence Labeler validity scores.
599
622
  """
600
623
  self._ensure_loaded()
624
+ normalized_word = normalize_sinhala(word)
601
625
 
602
626
  # 1. Akshara Trigram Score Check via word_ngram_probability
603
- prob = self.word_ngram_probability(word)
627
+ prob = self.word_ngram_probability(normalized_word)
604
628
  if prob < self._threshold:
605
629
  return True
606
630
 
607
631
  # 2. PyTorch Bi-GRU Sequence Labeler Check
608
- if self.has_neural_labeler and self._bigru_detector is not None:
609
- from sinlib.utils.preprocessing import process_text
610
- tokens = process_text(word)
632
+ if self.has_neural_labeler and self._bigru_detector is not None and self._akshara_vocab:
633
+ tokens = process_text(normalized_word)
611
634
  if not tokens:
612
635
  return True
613
- input_ids = [self._akshara_vocab.get(t, self._akshara_vocab["<UNK>"]) for t in tokens]
636
+ unk_id = self._akshara_vocab.get("<UNK>", 0)
637
+ input_ids = [self._akshara_vocab.get(t, unk_id) for t in tokens]
614
638
  input_tensor = torch.tensor([input_ids], dtype=torch.long)
615
639
  with torch.no_grad():
616
640
  probs = self._bigru_detector(input_tensor)[0].tolist()
@@ -620,9 +644,9 @@ class TypoDetector:
620
644
 
621
645
  return False
622
646
 
623
- @lru_cache(maxsize=1000)
624
647
  def word_ngram_probability(self, word: str, n: int = 2) -> float:
625
648
  self._ensure_loaded()
649
+ word = normalize_sinhala(word)
626
650
  if len(self._akshara_ngram.vocab) == 0:
627
651
  token_ids = self._tokenizer.encode(word)
628
652
  prob = 1.0
@@ -635,34 +659,37 @@ class TypoDetector:
635
659
  # Shift log probability score dynamically to map default threshold 1e-8 to -3.2 log-prob
636
660
  return math.pow(10, score - 4.8)
637
661
 
662
+
638
663
  def get_context_neg_log_prob(self, prev_word: Optional[str], candidate: str, next_word: Optional[str]) -> float:
639
664
  """Calculate cumulative negative log probability of candidate using Stupid Backoff."""
640
665
  neg_log = 0.0
641
666
  default_unigram_prob = 1e-8
642
- p_candidate = self.news_unigrams.get(candidate, default_unigram_prob) if self.news_unigrams else default_unigram_prob
667
+ cand_norm = normalize_sinhala(candidate)
668
+ p_candidate = self.news_unigrams.get(cand_norm, self.news_unigrams.get(candidate, default_unigram_prob)) if self.news_unigrams else default_unigram_prob
643
669
 
644
670
  if prev_word:
645
- bigram1 = f"{prev_word} {candidate}"
671
+ prev_norm = normalize_sinhala(prev_word)
672
+ bigram1 = f"{prev_norm} {cand_norm}"
646
673
  if self.news_bigrams and bigram1 in self.news_bigrams:
647
674
  prob1 = self.news_bigrams[bigram1]
648
675
  else:
649
676
  prob1 = 0.4 * p_candidate
650
- neg_log += -math.log10(prob1)
677
+ neg_log += -math.log10(max(prob1, 1e-12))
651
678
 
652
679
  if next_word:
653
- bigram2 = f"{candidate} {next_word}"
680
+ next_norm = normalize_sinhala(next_word)
681
+ bigram2 = f"{cand_norm} {next_norm}"
654
682
  if self.news_bigrams and bigram2 in self.news_bigrams:
655
683
  prob2 = self.news_bigrams[bigram2]
656
684
  else:
657
685
  prob2 = 0.4 * p_candidate
658
- neg_log += -math.log10(prob2)
686
+ neg_log += -math.log10(max(prob2, 1e-12))
659
687
 
660
688
  if not prev_word and not next_word:
661
- neg_log = -math.log10(p_candidate)
689
+ neg_log = -math.log10(max(p_candidate, 1e-12))
662
690
 
663
691
  return neg_log
664
692
 
665
- @lru_cache(maxsize=1000)
666
693
  def suggest_correction(self, word: str, n: int = 3, prev_word: Optional[str] = None,
667
694
  next_word: Optional[str] = None) -> List[str]:
668
695
  """
@@ -670,7 +697,12 @@ class TypoDetector:
670
697
  and Stupid Backoff context ranking.
671
698
  """
672
699
  self._ensure_loaded()
673
-
700
+ word = normalize_sinhala(word)
701
+
702
+ cache_key = (word, n, prev_word, next_word)
703
+ if cache_key in self._suggestion_cache:
704
+ return self._suggestion_cache[cache_key]
705
+
674
706
  candidates = {} # Map candidate_word -> edit_distance_cost
675
707
 
676
708
  # 1. Coarse filter using difflib to guarantee backward/mock compatibility
@@ -687,18 +719,18 @@ class TypoDetector:
687
719
  # 3. Generative Neural Corrector (only when dictionary is fully loaded)
688
720
  if self.has_neural_corrector and self.bigru_corrector is not None and isinstance(self._dictionary, set) and len(self._dictionary) > 100:
689
721
  try:
690
- from sinlib.utils.preprocessing import process_text
691
722
  tokens = process_text(word)
692
723
  if tokens:
693
724
  bos_idx = self._akshara_vocab.get("<BOS>", 1)
694
725
  eos_idx = self._akshara_vocab.get("<EOS>", 2)
695
- input_ids = [self._akshara_vocab.get(t, self._akshara_vocab["<UNK>"]) for t in tokens]
726
+ unk_id = self._akshara_vocab.get("<UNK>", 0)
727
+ input_ids = [self._akshara_vocab.get(t, unk_id) for t in tokens]
696
728
  input_tensor = torch.tensor([input_ids], dtype=torch.long)
697
729
  beams = self.bigru_corrector.beam_decode(input_tensor, bos_idx, eos_idx, beam_width=3, max_len=16)
698
730
 
699
731
  id_to_tok = {v: k for k, v in self._akshara_vocab.items()}
700
732
  for score, seq, _ in beams:
701
- cand_tokens = [id_to_tok[idx] for idx in seq if idx not in (bos_idx, eos_idx, 0, self._akshara_vocab["<UNK>"])]
733
+ cand_tokens = [id_to_tok[idx] for idx in seq if idx not in (bos_idx, eos_idx, 0, unk_id)]
702
734
  cand_word = "".join(cand_tokens)
703
735
  if cand_word:
704
736
  cost = weighted_levenshtein(word, cand_word)
@@ -709,7 +741,10 @@ class TypoDetector:
709
741
  pass
710
742
 
711
743
  if not candidates:
712
- return ["No suggestion"]
744
+ res = ["No suggestion"]
745
+ if len(self._suggestion_cache) < self._cache_size:
746
+ self._suggestion_cache[cache_key] = res
747
+ return res
713
748
 
714
749
  # 4. Contextual Reranking using Stupid Backoff
715
750
  scored = []
@@ -719,7 +754,17 @@ class TypoDetector:
719
754
  scored.append((score, cand))
720
755
 
721
756
  scored.sort(key=lambda x: x[0])
722
- return [cand for _, cand in scored[:n]]
757
+ result = [cand for _, cand in scored[:n]]
758
+ if len(self._suggestion_cache) < self._cache_size:
759
+ self._suggestion_cache[cache_key] = result
760
+ return result
761
+
762
+ def _extract_punctuation(self, token: str) -> Tuple[str, str, str]:
763
+ """Extract leading punctuation, core Sinhala word, and trailing punctuation from a token."""
764
+ match = re.match(r"^([^\w\u0D80-\u0DFF]*)([\w\u0D80-\u0DFF]+)([^\w\u0D80-\u0DFF]*)$", token, re.UNICODE)
765
+ if match:
766
+ return match.group(1), match.group(2), match.group(3)
767
+ return "", token, ""
723
768
 
724
769
  def __call__(self, text: str) -> str:
725
770
  """
@@ -728,38 +773,41 @@ class TypoDetector:
728
773
  """
729
774
  self._ensure_loaded()
730
775
  corrected: List[str] = []
731
- words = text.split() if isinstance(text, str) else [str(text)]
776
+ raw_tokens = text.split() if isinstance(text, str) else [str(text)]
777
+ parsed_tokens = [self._extract_punctuation(t) for t in raw_tokens]
778
+ words = [p[1] for p in parsed_tokens]
732
779
  n_words = len(words)
733
780
 
734
- for idx, word in enumerate(words):
781
+ for idx, (leading, word, trailing) in enumerate(parsed_tokens):
735
782
  try:
736
- if word in self._dictionary:
737
- corrected.append(word)
783
+ if not word:
784
+ corrected.append(leading + trailing)
738
785
  continue
739
786
 
740
- if self.is_word_suspicious(word):
787
+ norm_word = normalize_sinhala(word)
788
+ if norm_word in self._dictionary or word in self._dictionary:
789
+ corrected.append(leading + norm_word + trailing)
790
+ continue
791
+
792
+
793
+ if self.is_word_suspicious(norm_word):
741
794
  prev_word = words[idx - 1] if idx > 0 else None
742
795
  next_word = words[idx + 1] if idx < n_words - 1 else None
743
796
 
744
- suggestions = self.suggest_correction(word, n=5, prev_word=prev_word, next_word=next_word)
797
+ suggestions = self.suggest_correction(norm_word, n=5, prev_word=prev_word, next_word=next_word)
745
798
  if not suggestions or suggestions[0] == "No suggestion":
746
- corrected.append(word)
799
+ corrected.append(leading + word + trailing)
747
800
  else:
748
- corrected.append(suggestions[0])
801
+ corrected.append(leading + suggestions[0] + trailing)
749
802
  else:
750
- warnings.warn(
751
- f"'{word}' is unusual but may not be a typo.",
752
- UserWarning,
753
- stacklevel=2,
754
- )
755
- corrected.append(word)
803
+ corrected.append(leading + word + trailing)
756
804
 
757
805
  except Exception as exc:
758
806
  warnings.warn(
759
807
  f"Error processing word '{word}': {exc}",
760
808
  stacklevel=2,
761
809
  )
762
- corrected.append(word)
810
+ corrected.append(leading + word + trailing)
763
811
 
764
812
  return " ".join(corrected)
765
813
 
@@ -141,15 +141,18 @@ def test_check_spelling_typo(mock_typo_detector):
141
141
  assert result == "correct"
142
142
 
143
143
 
144
- def test_check_spelling_unusual_word(mock_typo_detector):
145
- """Test check_spelling with an unusual but possibly valid word."""
146
- mock_typo_detector._dictionary = ["common"]
147
-
148
- # Mock word_ngram_probability to return a medium probability
149
- mock_typo_detector.word_ngram_probability = lambda word, n=2: 1e-7
150
-
151
- with warnings.catch_warnings(record=True) as w:
152
- result = mock_typo_detector("uncommon")
153
- assert result == "uncommon"
154
- assert len(w) == 1
155
- assert "unusual but may not be a typo" in str(w[0].message)
144
+ def test_check_spelling_with_punctuation(mock_typo_detector):
145
+ """Test that punctuation attached to words is preserved."""
146
+ mock_typo_detector._dictionary = {"ගෙදර", "පාසල"}
147
+ result = mock_typo_detector("මම ගෙදර. පාසල,")
148
+ assert "ගෙදර." in result
149
+ assert "පාසල," in result
150
+
151
+
152
+ def test_unicode_normalization_in_spellcheck(mock_typo_detector):
153
+ """Test that composite variant diacritics are normalized and matched against dictionary."""
154
+ mock_typo_detector._dictionary = {"කොළඹ"}
155
+ mock_typo_detector.trie = None
156
+ # "කොළඹ" uses composite variant ෙ + ා instead of ො
157
+ result = mock_typo_detector("කොළඹ")
158
+ assert result == "කොළඹ"
@@ -69,6 +69,7 @@ export default defineConfig({
69
69
  { label: 'Romanizer', slug: 'api/romanizer' },
70
70
  { label: 'BatchEncoding', slug: 'api/encoding' },
71
71
  { label: 'Preprocessing', slug: 'api/preprocessing' },
72
+ { label: 'Visualization', slug: 'api/visualization' },
72
73
  ],
73
74
  },
74
75
  {
@@ -76,6 +77,7 @@ export default defineConfig({
76
77
  items: [
77
78
  { label: 'Tokenization', slug: 'examples/tokenization' },
78
79
  { label: 'Typo Correction', slug: 'examples/typo-correction' },
80
+ { label: 'Visualization', slug: 'examples/visualization' },
79
81
  ],
80
82
  },
81
83
  ],
@@ -1,4 +1,7 @@
1
- # Matplotlib Sinhala Text Visualization
1
+ ---
2
+ title: Sinhala Text Visualization
3
+ description: Example of Matplotlib Sinhala text visualization with sinlib.
4
+ ---
2
5
 
3
6
  `sinlib` provides a zero-configuration utility `setup_matplotlib()` to resolve and configure Sinhala text rendering in Matplotlib.
4
7
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes