document-ocr 0.0.0-stage → 3.1.3

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -2
  3. package/dist/index.cjs +659 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +293 -0
  6. package/dist/index.d.ts +293 -0
  7. package/dist/index.js +616 -0
  8. package/dist/index.js.map +1 -0
  9. package/hosting/.dockerignore +17 -0
  10. package/hosting/.gcloudignore +14 -0
  11. package/hosting/FEATURES.md +172 -0
  12. package/hosting/HOSTING.md +268 -0
  13. package/hosting/LICENSE +21 -0
  14. package/hosting/README.md +369 -0
  15. package/hosting/core/__init__.py +0 -0
  16. package/hosting/core/aadhaar_extractor.py +298 -0
  17. package/hosting/core/back_page_extractor.py +492 -0
  18. package/hosting/core/barcodes.py +125 -0
  19. package/hosting/core/card_recovery.py +244 -0
  20. package/hosting/core/document_bundle.py +82 -0
  21. package/hosting/core/document_classifier.py +233 -0
  22. package/hosting/core/document_input.py +135 -0
  23. package/hosting/core/document_registry.py +143 -0
  24. package/hosting/core/driving_licence_extractor.py +510 -0
  25. package/hosting/core/ead_recovery.py +54 -0
  26. package/hosting/core/evidence.py +54 -0
  27. package/hosting/core/form_recovery.py +61 -0
  28. package/hosting/core/jobs.py +232 -0
  29. package/hosting/core/kyc_ocr.py +132 -0
  30. package/hosting/core/kyc_validation.py +411 -0
  31. package/hosting/core/model_setup.py +13 -0
  32. package/hosting/core/mrz_parser.py +336 -0
  33. package/hosting/core/mrz_recovery.py +193 -0
  34. package/hosting/core/npr_extractor.py +410 -0
  35. package/hosting/core/nrega_extractor.py +827 -0
  36. package/hosting/core/ocr_engine.py +207 -0
  37. package/hosting/core/page_classifier.py +137 -0
  38. package/hosting/core/pan_extractor.py +183 -0
  39. package/hosting/core/pipeline.py +712 -0
  40. package/hosting/core/preprocessor.py +297 -0
  41. package/hosting/core/recovery_budget.py +67 -0
  42. package/hosting/core/redaction.py +48 -0
  43. package/hosting/core/review.html +244 -0
  44. package/hosting/core/structured_documents.py +422 -0
  45. package/hosting/core/travel_mrz.py +164 -0
  46. package/hosting/core/travel_recovery.py +187 -0
  47. package/hosting/core/validator.py +396 -0
  48. package/hosting/core/validators.py +179 -0
  49. package/hosting/core/voter_id_extractor.py +363 -0
  50. package/hosting/deploy/cloudflare/deploy.sh +44 -0
  51. package/hosting/deploy/cloudflare/package-lock.json +3088 -0
  52. package/hosting/deploy/cloudflare/package.json +29 -0
  53. package/hosting/deploy/cloudflare/src/gateway.ts +42 -0
  54. package/hosting/deploy/cloudflare/src/index.ts +21 -0
  55. package/hosting/deploy/cloudflare/test/gateway.test.ts +59 -0
  56. package/hosting/deploy/cloudflare/tsconfig.json +12 -0
  57. package/hosting/deploy/cloudflare/worker-configuration.d.ts +16253 -0
  58. package/hosting/deploy/cloudflare/wrangler.jsonc +26 -0
  59. package/hosting/deploy/cloudrun/cloudbuild.yaml +18 -0
  60. package/hosting/deploy/cloudrun/deploy.sh +71 -0
  61. package/hosting/deploy/cloudrun/render_service.py +47 -0
  62. package/hosting/deploy/common.sh +14 -0
  63. package/hosting/deploy/docker/Dockerfile +16 -0
  64. package/hosting/deploy/docker/server.py +313 -0
  65. package/hosting/deploy/lambda/Dockerfile.lambda +11 -0
  66. package/hosting/deploy/lambda/deploy.sh +22 -0
  67. package/hosting/deploy/lambda/handler.py +132 -0
  68. package/hosting/deploy/lambda/template.yaml +59 -0
  69. package/hosting/deploy/local/dev.sh +18 -0
  70. package/hosting/deploy/server/Caddyfile +5 -0
  71. package/hosting/deploy/server/compose.jobs.yaml +36 -0
  72. package/hosting/deploy/server/compose.yaml +38 -0
  73. package/hosting/deploy/server/deploy.sh +23 -0
  74. package/hosting/infra/cloudrun/main.tf +104 -0
  75. package/hosting/infra/cloudrun/terraform.tfvars.example +7 -0
  76. package/hosting/infra/cloudrun/variables.tf +55 -0
  77. package/hosting/infra/lambda/main.tf +63 -0
  78. package/hosting/infra/lambda/terraform.tfvars.example +6 -0
  79. package/hosting/infra/lambda/variables.tf +37 -0
  80. package/hosting/pyproject.toml +65 -0
  81. package/hosting/requirements.lock +134 -0
  82. package/package.json +76 -4
  83. package/python/core/__init__.py +0 -0
  84. package/python/core/aadhaar_extractor.py +298 -0
  85. package/python/core/back_page_extractor.py +492 -0
  86. package/python/core/barcodes.py +125 -0
  87. package/python/core/card_recovery.py +244 -0
  88. package/python/core/document_bundle.py +82 -0
  89. package/python/core/document_classifier.py +233 -0
  90. package/python/core/document_input.py +135 -0
  91. package/python/core/document_registry.py +143 -0
  92. package/python/core/driving_licence_extractor.py +510 -0
  93. package/python/core/ead_recovery.py +54 -0
  94. package/python/core/evidence.py +54 -0
  95. package/python/core/form_recovery.py +61 -0
  96. package/python/core/jobs.py +232 -0
  97. package/python/core/kyc_ocr.py +132 -0
  98. package/python/core/kyc_validation.py +411 -0
  99. package/python/core/model_setup.py +13 -0
  100. package/python/core/mrz_parser.py +336 -0
  101. package/python/core/mrz_recovery.py +193 -0
  102. package/python/core/npr_extractor.py +410 -0
  103. package/python/core/nrega_extractor.py +827 -0
  104. package/python/core/ocr_engine.py +207 -0
  105. package/python/core/page_classifier.py +137 -0
  106. package/python/core/pan_extractor.py +183 -0
  107. package/python/core/pipeline.py +712 -0
  108. package/python/core/preprocessor.py +297 -0
  109. package/python/core/recovery_budget.py +67 -0
  110. package/python/core/redaction.py +48 -0
  111. package/python/core/review.html +244 -0
  112. package/python/core/structured_documents.py +422 -0
  113. package/python/core/travel_mrz.py +164 -0
  114. package/python/core/travel_recovery.py +187 -0
  115. package/python/core/validator.py +396 -0
  116. package/python/core/validators.py +179 -0
  117. package/python/core/voter_id_extractor.py +363 -0
  118. package/python/pyproject.toml +34 -0
  119. package/python/requirements.lock +134 -0
  120. package/python/server.py +313 -0
  121. package/scripts/dococr.mjs +163 -0
  122. package/scripts/postinstall.mjs +173 -0
  123. package/scripts/sync-hosting.mjs +35 -0
  124. package/scripts/sync-python.sh +20 -0
@@ -0,0 +1,207 @@
1
+ """
2
+ RapidOCR wrapper. Accepts a preprocessed image, returns detected text regions.
3
+
4
+ Uses PP-OCRv5 models via ONNX Runtime — no PaddlePaddle dependency.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import threading
11
+ from dataclasses import dataclass
12
+ from typing import Optional
13
+
14
+ import numpy as np
15
+
16
+ logger = logging.getLogger("document-ocr.ocr_engine")
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Types
20
+ # ---------------------------------------------------------------------------
21
+
22
+ @dataclass
23
+ class TextRegion:
24
+ text: str
25
+ bbox: list[list[int]] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
26
+ confidence: float
27
+
28
+
29
+ class OCRModelInitError(RuntimeError):
30
+ """Raised when the RapidOCR models cannot be initialised.
31
+
32
+ Surfaces a clear, actionable error instead of letting the first request
33
+ hang until timeout when the model source (ModelScope) is unreachable or
34
+ the local model cache cannot be written (e.g. disk full / read-only FS).
35
+ """
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Singleton OCR instance
40
+ # ---------------------------------------------------------------------------
41
+
42
+ _lock = threading.Lock()
43
+ _ocr_instances: dict[str, object] = {}
44
+
45
+
46
+ # RapidOCR's default detector resize enlarges an input until its short side is
47
+ # 736 px, which turns a thin MRZ band into a detector tensor larger than the
48
+ # whole page. Bands still need some enlargement: on the public benchmark a
49
+ # photographed MRZ band failed without it and at 400 px, and read correctly at
50
+ # 480 px and above. 576 keeps a margin and is ~40% less detector work than 736.
51
+ _BAND_DETECTOR_PARAMS = {"Det.limit_type": "min", "Det.limit_side_len": 576}
52
+
53
+
54
+ def _get_ocr(lang: str = "en", *, band: bool = False):
55
+ """Get or create a cached RapidOCR instance.
56
+
57
+ ``band=True`` returns a separate instance tuned for thin, already-cropped
58
+ text bands (see ``_BAND_DETECTOR_PARAMS``); it shares model files, not state.
59
+
60
+ Raises:
61
+ OCRModelInitError: if RapidOCR model initialisation fails. A failed
62
+ instance is never cached, so a subsequent call can retry once the
63
+ underlying problem (network / disk) is resolved.
64
+ """
65
+ key = f"{lang}:band" if band else lang
66
+ if key in _ocr_instances:
67
+ return _ocr_instances[key]
68
+
69
+ try:
70
+ from rapidocr import LangRec, ModelType, OCRVersion, RapidOCR
71
+ except Exception as exc: # pragma: no cover - import failure is environmental
72
+ logger.exception("Failed to import rapidocr")
73
+ raise OCRModelInitError(f"MODEL_INIT_FAILED: rapidocr import failed: {exc}") from exc
74
+
75
+ # Map string language codes to RapidOCR Enum values
76
+ _lang_map = {
77
+ "en": LangRec.EN,
78
+ "latin": LangRec.LATIN,
79
+ "ch": LangRec.CH,
80
+ "chinese_cht": LangRec.CHINESE_CHT,
81
+ "japan": LangRec.JAPAN,
82
+ "korean": LangRec.KOREAN,
83
+ "arabic": LangRec.ARABIC,
84
+ "cyrillic": LangRec.CYRILLIC,
85
+ "devanagari": LangRec.DEVANAGARI,
86
+ "ka": LangRec.KA,
87
+ "ta": LangRec.TA,
88
+ "te": LangRec.TE,
89
+ }
90
+
91
+ with _lock:
92
+ # Re-check under the lock — another thread may have built it.
93
+ if key in _ocr_instances:
94
+ return _ocr_instances[key]
95
+
96
+ rec_lang = _lang_map.get(lang, LangRec.EN)
97
+ try:
98
+ instance = RapidOCR(params={
99
+ "Global.use_cls": False,
100
+ "Det.model_type": ModelType.MOBILE,
101
+ "Det.ocr_version": OCRVersion.PPOCRV5,
102
+ "Rec.lang_type": rec_lang,
103
+ "Rec.model_type": ModelType.MOBILE,
104
+ "Rec.ocr_version": OCRVersion.PPOCRV5,
105
+ **(_BAND_DETECTOR_PARAMS if band else {}),
106
+ })
107
+ except Exception as exc:
108
+ # Do NOT cache — leave the slot empty so a later call can retry.
109
+ logger.exception("RapidOCR model initialisation failed (lang=%s)", key)
110
+ raise OCRModelInitError(
111
+ f"MODEL_INIT_FAILED: could not initialise OCR models for lang={key}: {exc}"
112
+ ) from exc
113
+
114
+ _ocr_instances[key] = instance
115
+
116
+ return _ocr_instances[key]
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Result parsing
121
+ # ---------------------------------------------------------------------------
122
+
123
+ def _parse_rapidocr_results(result) -> list[TextRegion]:
124
+ """Parse RapidOCR output into TextRegion list."""
125
+ regions: list[TextRegion] = []
126
+
127
+ if result.boxes is None or result.txts is None:
128
+ return regions
129
+
130
+ for box, txt, score in zip(result.boxes, result.txts, result.scores):
131
+ text = str(txt).strip()
132
+ if text:
133
+ bbox = [[int(pt[0]), int(pt[1])] for pt in box]
134
+ regions.append(TextRegion(text=text, bbox=bbox, confidence=float(score)))
135
+
136
+ return regions
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Script detection
141
+ # ---------------------------------------------------------------------------
142
+
143
+ def _is_likely_non_latin(regions: list[TextRegion]) -> bool:
144
+ """Heuristic: if > 40% of characters in detected text are non-ASCII, re-run
145
+ with multilingual model."""
146
+ all_text = "".join(r.text for r in regions)
147
+ if not all_text:
148
+ return False
149
+ non_ascii = sum(1 for c in all_text if ord(c) > 127)
150
+ return (non_ascii / len(all_text)) > 0.4
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # Public API
155
+ # ---------------------------------------------------------------------------
156
+
157
+ def run_line_ocr(image: np.ndarray) -> list[TextRegion]:
158
+ """Recognize one already-localized Latin text line without running detection.
159
+
160
+ Calling the recognizer directly avoids changing the cached RapidOCR object's
161
+ persistent ``use_det`` flag, which would break subsequent full-page scans.
162
+ """
163
+ result = _get_ocr("en").recognize_txt([image])
164
+ if not result.txts or not result.scores or not result.txts[0].strip():
165
+ return []
166
+ height, width = image.shape[:2]
167
+ return [TextRegion(result.txts[0].strip(),
168
+ [[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]],
169
+ float(result.scores[0]))]
170
+
171
+
172
+ def run_band_ocr(image: np.ndarray) -> list[TextRegion]:
173
+ """Detect and recognize Latin text in a thin, already-cropped band.
174
+
175
+ Used for MRZ band re-reads, which the default reader enlarged to a detector
176
+ input several times the band's own size.
177
+ """
178
+ return _parse_rapidocr_results(_get_ocr("en", band=True)(image))
179
+
180
+
181
+ def run_ocr(
182
+ image: np.ndarray,
183
+ *,
184
+ lang: Optional[str] = None,
185
+ ) -> list[TextRegion]:
186
+ """
187
+ Run OCR on a preprocessed image.
188
+
189
+ Args:
190
+ image: BGR numpy array (preprocessed).
191
+ lang: Force a language. If None, starts with 'en' and falls back to
192
+ 'latin' if non-Latin script is detected.
193
+
194
+ Returns:
195
+ List of TextRegion with text, bounding box, and confidence.
196
+ """
197
+ use_lang = lang or "en"
198
+ ocr = _get_ocr(use_lang)
199
+
200
+ result = ocr(image)
201
+ regions = _parse_rapidocr_results(result)
202
+
203
+ # Auto-detect non-Latin and retry with multilingual
204
+ if lang is None and _is_likely_non_latin(regions):
205
+ return run_ocr(image, lang="latin")
206
+
207
+ return regions
@@ -0,0 +1,137 @@
1
+ """
2
+ Fast passport page classification.
3
+
4
+ Uses a lightweight OCR probe to distinguish passport biodata pages from
5
+ supplementary/non-biodata pages before the heavier extraction pipeline runs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+
13
+ from .mrz_parser import _find_mrz_lines, parse_mrz
14
+ from .ocr_engine import TextRegion
15
+
16
+ _BIODATA_HINTS = [
17
+ "SURNAME",
18
+ "GIVEN NAME",
19
+ "PASSPORT NO",
20
+ "PASSPORT NUMBER",
21
+ "DATE OF BIRTH",
22
+ "NATIONALITY",
23
+ "PLACE OF BIRTH",
24
+ "DATE OF ISSUE",
25
+ "DATE OF EXPIRY",
26
+ "SEX",
27
+ "COUNTRY CODE",
28
+ ]
29
+
30
+ _NON_BIODATA_HINTS = [
31
+ "NAME OF FATHER",
32
+ "LEGAL GUARDIAN",
33
+ "NAME OF MOTHER",
34
+ "NAME OF SPOUSE",
35
+ "ADDRESS",
36
+ "FILE NO",
37
+ "OLD PASSPORT",
38
+ "PLACE OF ISSUE",
39
+ ]
40
+
41
+
42
+ @dataclass
43
+ class PageClassification:
44
+ document_type: str
45
+ page_type: str
46
+ confidence: float
47
+ reasons: list[str] = field(default_factory=list)
48
+ probe_text: list[str] = field(default_factory=list)
49
+
50
+
51
+ def classify_passport_page(regions: list[TextRegion]) -> PageClassification:
52
+ """Classify a page using a low-cost OCR probe."""
53
+ probe_text = [region.text.strip() for region in regions if region.text.strip()][:12]
54
+ normalised_regions = [_normalise_text(region.text) for region in regions if region.text.strip()]
55
+
56
+ has_mrz = _has_mrz_like_lines(regions)
57
+ biodata_matches = _count_matches(normalised_regions, _BIODATA_HINTS)
58
+ non_biodata_matches = _count_matches(normalised_regions, _NON_BIODATA_HINTS)
59
+
60
+ if has_mrz or biodata_matches >= 2:
61
+ confidence = 0.55 + (0.12 * min(biodata_matches, 3)) + (0.18 if has_mrz else 0.0)
62
+ reasons = ["MRZ_DETECTED"] if has_mrz else []
63
+ if biodata_matches:
64
+ reasons.append(f"BIODATA_HINTS_{biodata_matches}")
65
+ return PageClassification(
66
+ document_type="passport",
67
+ page_type="passport_biodata",
68
+ confidence=round(min(confidence, 0.99), 3),
69
+ reasons=reasons,
70
+ probe_text=probe_text,
71
+ )
72
+
73
+ if non_biodata_matches >= 2:
74
+ confidence = 0.62 + (0.1 * min(non_biodata_matches, 3))
75
+ return PageClassification(
76
+ document_type="passport",
77
+ page_type="passport_non_biodata",
78
+ confidence=round(min(confidence, 0.98), 3),
79
+ reasons=[f"NON_BIODATA_HINTS_{non_biodata_matches}"],
80
+ probe_text=probe_text,
81
+ )
82
+
83
+ if biodata_matches or non_biodata_matches:
84
+ return PageClassification(
85
+ document_type="passport",
86
+ page_type="unknown",
87
+ confidence=0.45,
88
+ reasons=["WEAK_PASSPORT_HINTS"],
89
+ probe_text=probe_text,
90
+ )
91
+
92
+ return PageClassification(
93
+ document_type="unknown",
94
+ page_type="unknown",
95
+ confidence=0.2,
96
+ reasons=["NO_PASSPORT_HINTS"],
97
+ probe_text=probe_text,
98
+ )
99
+
100
+
101
+ def _count_matches(normalised_regions: list[str], hints: list[str]) -> int:
102
+ matched_hints: set[str] = set()
103
+ for hint in hints:
104
+ hint_text = _normalise_text(hint)
105
+ padded_hint = f" {hint_text} "
106
+ for region_text in normalised_regions:
107
+ if padded_hint in f" {region_text} ":
108
+ matched_hints.add(hint_text)
109
+ break
110
+ return len(matched_hints)
111
+
112
+
113
+ def _normalise_text(text: str) -> str:
114
+ text = text.upper()
115
+ text = re.sub(r"[^A-Z0-9]+", " ", text)
116
+ return re.sub(r"\s+", " ", text).strip()
117
+
118
+
119
+ def _has_mrz_like_lines(regions: list[TextRegion]) -> bool:
120
+ if not regions:
121
+ return False
122
+
123
+ page_bottom = max((max(point[1] for point in region.bbox) for region in regions if region.bbox), default=0)
124
+ bottom_threshold = page_bottom * 0.6
125
+ bottom_regions = [
126
+ region for region in regions
127
+ if region.bbox and max(point[1] for point in region.bbox) >= bottom_threshold
128
+ ]
129
+ # Optional data may occupy all of line two: it need not contain fillers.
130
+ # Require a paired TD3 header instead of counting arbitrary long strings.
131
+ if _find_mrz_lines(bottom_regions) is not None:
132
+ return True
133
+ # A passport may sit high in a larger photograph, with background text
134
+ # below it. Strong MRZ evidence should not depend on its position in that
135
+ # photograph. Keep checksum validation for this location-independent path.
136
+ mrz = parse_mrz(regions)
137
+ return bool(mrz and mrz.overall_checksum_valid)
@@ -0,0 +1,183 @@
1
+ """
2
+ Extractor for Indian PAN (Permanent Account Number) cards.
3
+
4
+ PAN card fields: PAN number, holder name, father's name, date of birth.
5
+ Layout has shifted across generations (old NSDL card, e-PAN, QR PAN), but the
6
+ labels ("Name", "Father's Name", "Date of Birth") and the PAN format are stable,
7
+ so extraction is label- and format-driven rather than position-driven.
8
+
9
+ Robustness goals (see tests):
10
+ * The PAN number is recognised by *shape* (and preferably holder-type
11
+ validity), never confused with a date or another KYC identifier.
12
+ * ``name`` must never collapse into ``father_name`` — the "Name" label is a
13
+ substring of "Father's Name". We rely on ``find_visual_field``'s
14
+ exact-match priority and additionally de-duplicate the two values.
15
+ * Both the old NSDL "value below the label" layout and the newer e-PAN
16
+ "Label : value on the same row" layout are handled.
17
+ * Bilingual Hindi/English noise rows and missing labels are tolerated.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from dataclasses import dataclass
24
+ from typing import Optional
25
+
26
+ from .ocr_engine import TextRegion
27
+ from .validator import find_label_value, find_visual_field
28
+ from .validators import is_valid_pan, normalize_pan
29
+
30
+ # A printed PAN-card date: DD/MM/YYYY or DD-MM-YYYY (also tolerates "." and a
31
+ # 2-digit year). The PAN itself never contains these separators, so a date can
32
+ # never be mistaken for a PAN and vice-versa.
33
+ _DATE_RE = re.compile(r"\b(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4})\b")
34
+
35
+ # Label vocabularies. ``find_visual_field`` normalises OCR noise (case,
36
+ # punctuation, "&" -> "AND") and favours exact phrase matches, so we list the
37
+ # most specific spellings first.
38
+ _NAME_LABELS = [
39
+ "NAME",
40
+ "NAME OF THE CARDHOLDER",
41
+ "CARDHOLDER NAME",
42
+ "CARD HOLDER NAME",
43
+ ]
44
+ _FATHER_LABELS = [
45
+ "FATHER S NAME",
46
+ "FATHERS NAME",
47
+ "FATHER NAME",
48
+ "NAME OF FATHER",
49
+ "PARENT S NAME",
50
+ "PARENTS NAME",
51
+ "NAME OF PARENT",
52
+ ]
53
+ _DOB_LABELS = [
54
+ "DATE OF BIRTH",
55
+ "DATE OF BIRTH INCORPORATION",
56
+ "DATE OF BIRTH OR INCORPORATION",
57
+ "DOB",
58
+ ]
59
+
60
+
61
+ @dataclass
62
+ class PanFields:
63
+ pan_number: Optional[str] = None
64
+ name: Optional[str] = None
65
+ father_name: Optional[str] = None
66
+ date_of_birth: Optional[str] = None
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # PAN number
71
+ # ---------------------------------------------------------------------------
72
+
73
+ def _find_pan_number(regions: list[TextRegion]) -> Optional[str]:
74
+ """Locate the PAN by shape, preferring a holder-type-valid token.
75
+
76
+ Strategy, best-first:
77
+ 1. A region adjacent to a "Permanent Account Number" / "PAN" label whose
78
+ text yields a *valid* PAN (correct 4th holder-type character).
79
+ 2. Any region yielding a *valid* PAN.
80
+ 3. Any region yielding a merely PAN-*shaped* token (tolerates OCR noise
81
+ in the holder-type position).
82
+
83
+ ``normalize_pan`` uses ``re.search`` over the alnum-compacted text, so dates
84
+ (which carry separators) and other KYC ids (e.g. a 15-char DL number) can
85
+ never be sliced into a false PAN.
86
+ """
87
+ labelled = find_label_value(
88
+ regions, ["PERMANENT ACCOUNT NUMBER", "PERMANENT ACCOUNT NUMBER CARD", "PAN"]
89
+ )
90
+ if labelled and is_valid_pan(labelled):
91
+ pan = normalize_pan(labelled)
92
+ if pan:
93
+ return pan
94
+
95
+ shaped_fallback: Optional[str] = None
96
+ for region in regions:
97
+ pan = normalize_pan(region.text)
98
+ if pan is None:
99
+ continue
100
+ if is_valid_pan(region.text):
101
+ return pan
102
+ if shaped_fallback is None:
103
+ shaped_fallback = pan
104
+ return shaped_fallback
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Date of birth
109
+ # ---------------------------------------------------------------------------
110
+
111
+ def _find_date(regions: list[TextRegion]) -> Optional[str]:
112
+ """Prefer a date adjacent to a DOB label; fall back to the first date.
113
+
114
+ DOB is left as the printed string (DD/MM/YYYY or DD-MM-YYYY) — see the
115
+ module/report notes: no ISO normalisation, to keep downstream values and
116
+ the TypeScript types unchanged.
117
+ """
118
+ labelled = find_label_value(regions, _DOB_LABELS)
119
+ if labelled:
120
+ m = _DATE_RE.search(labelled)
121
+ if m:
122
+ return m.group(1)
123
+
124
+ for region in regions:
125
+ m = _DATE_RE.search(region.text)
126
+ if m:
127
+ return m.group(1)
128
+ return None
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Name / father's name
133
+ # ---------------------------------------------------------------------------
134
+
135
+ def _clean_value(text: Optional[str]) -> Optional[str]:
136
+ """Strip leftover label punctuation/whitespace from a resolved value."""
137
+ if text is None:
138
+ return None
139
+ # Drop a leading "value-side" colon that occasionally rides along when the
140
+ # label and value were merged or mis-split by OCR (e.g. ": ROHIT SHARMA").
141
+ cleaned = text.strip().lstrip(":").strip()
142
+ return cleaned or None
143
+
144
+
145
+ def _resolve_names(regions: list[TextRegion]) -> tuple[Optional[str], Optional[str]]:
146
+ """Resolve (name, father_name) ensuring ``name`` never collapses into it.
147
+
148
+ The "Name" label is a substring of "Father's Name", so a naive lookup can
149
+ bind ``name`` to the father's value when the dedicated "Name" label is
150
+ absent or mis-OCR'd. We therefore:
151
+ * locate the father's-name label region first and exclude it (and its
152
+ resolved value) when resolving the plain "Name" label, and
153
+ * if both labels resolve to the *same* value, treat the plain-name lookup
154
+ as a false positive and drop it.
155
+ """
156
+ father_value = _clean_value(find_label_value(regions, _FATHER_LABELS))
157
+
158
+ name_value = _clean_value(find_label_value(regions, _NAME_LABELS))
159
+
160
+ # Guard against the substring collapse: if the "Name" lookup latched onto
161
+ # the father's-name label/value, discard it rather than duplicate.
162
+ if name_value is not None and name_value == father_value:
163
+ # Make sure we did not simply read the same label region. Re-resolve the
164
+ # plain-name label and confirm it is a distinct region from the father
165
+ # label before trusting the duplicate; otherwise drop the name.
166
+ name_label = find_visual_field(regions, _NAME_LABELS)
167
+ father_label = find_visual_field(regions, _FATHER_LABELS)
168
+ if name_label is None or name_label is father_label:
169
+ name_value = None
170
+
171
+ return name_value, father_value
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Public API
176
+ # ---------------------------------------------------------------------------
177
+
178
+ def extract_pan(regions: list[TextRegion]) -> PanFields:
179
+ fields = PanFields()
180
+ fields.pan_number = _find_pan_number(regions)
181
+ fields.name, fields.father_name = _resolve_names(regions)
182
+ fields.date_of_birth = _find_date(regions)
183
+ return fields