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,134 @@
1
+ # This file was autogenerated by uv via the following command:
2
+ # uv pip compile pyproject.toml -o requirements.lock --python-version 3.14 --universal
3
+ annotated-doc==0.0.5
4
+ # via fastapi
5
+ annotated-types==0.8.0
6
+ # via pydantic
7
+ anyio==4.15.1
8
+ # via
9
+ # starlette
10
+ # watchfiles
11
+ certifi==2026.7.22
12
+ # via requests
13
+ cffi==2.1.1 ; platform_python_implementation != 'PyPy'
14
+ # via cryptography
15
+ charset-normalizer==3.5.2
16
+ # via requests
17
+ click==8.5.0
18
+ # via
19
+ # india-document-ocr (pyproject.toml)
20
+ # uvicorn
21
+ colorama==0.4.6 ; sys_platform == 'win32'
22
+ # via
23
+ # colorlog
24
+ # tqdm
25
+ colorlog==6.12.0
26
+ # via rapidocr
27
+ cryptography==50.0.2
28
+ # via india-document-ocr (pyproject.toml)
29
+ fastapi==0.143.0
30
+ # via india-document-ocr (pyproject.toml)
31
+ flatbuffers==25.12.19
32
+ # via onnxruntime
33
+ h11==0.16.0
34
+ # via uvicorn
35
+ httptools==0.9.0
36
+ # via uvicorn
37
+ idna==3.20
38
+ # via
39
+ # india-document-ocr (pyproject.toml)
40
+ # anyio
41
+ # requests
42
+ numpy==2.5.4
43
+ # via
44
+ # india-document-ocr (pyproject.toml)
45
+ # onnxruntime
46
+ # opencv-python
47
+ # rapidocr
48
+ # shapely
49
+ omegaconf==2.4.0
50
+ # via rapidocr
51
+ onnxruntime==1.31.0
52
+ # via india-document-ocr (pyproject.toml)
53
+ opencv-python==4.13.0.92
54
+ # via
55
+ # india-document-ocr (pyproject.toml)
56
+ # rapidocr
57
+ opentelemetry-api==1.45.1
58
+ # via fastapi
59
+ packaging==26.3
60
+ # via onnxruntime
61
+ pillow==12.3.0
62
+ # via
63
+ # india-document-ocr (pyproject.toml)
64
+ # pillow-heif
65
+ # rapidocr
66
+ pillow-heif==1.8.1
67
+ # via india-document-ocr (pyproject.toml)
68
+ protobuf==7.36.2
69
+ # via onnxruntime
70
+ pyclipper==1.4.0
71
+ # via rapidocr
72
+ pycountry==26.2.16
73
+ # via india-document-ocr (pyproject.toml)
74
+ pycparser==3.11 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
75
+ # via cffi
76
+ pydantic==2.14.1
77
+ # via fastapi
78
+ pydantic-core==2.50.1
79
+ # via pydantic
80
+ pypdfium2==5.14.0
81
+ # via india-document-ocr (pyproject.toml)
82
+ python-dotenv==1.2.4
83
+ # via uvicorn
84
+ python-multipart==0.0.32
85
+ # via india-document-ocr (pyproject.toml)
86
+ pyyaml==6.0.3
87
+ # via
88
+ # omegaconf
89
+ # rapidocr
90
+ # uvicorn
91
+ rapidfuzz==3.14.6
92
+ # via india-document-ocr (pyproject.toml)
93
+ rapidocr==3.9.2
94
+ # via india-document-ocr (pyproject.toml)
95
+ requests==2.34.2
96
+ # via
97
+ # india-document-ocr (pyproject.toml)
98
+ # rapidocr
99
+ shapely==2.2.0
100
+ # via rapidocr
101
+ six==1.17.0
102
+ # via rapidocr
103
+ starlette==1.7.0
104
+ # via
105
+ # india-document-ocr (pyproject.toml)
106
+ # fastapi
107
+ tqdm==4.70.1
108
+ # via rapidocr
109
+ typing-extensions==4.16.0
110
+ # via
111
+ # anyio
112
+ # fastapi
113
+ # opentelemetry-api
114
+ # pydantic
115
+ # pydantic-core
116
+ # typing-inspection
117
+ typing-inspection==0.4.4
118
+ # via
119
+ # fastapi
120
+ # pydantic
121
+ urllib3==2.8.0
122
+ # via
123
+ # india-document-ocr (pyproject.toml)
124
+ # requests
125
+ uvicorn==0.54.0
126
+ # via india-document-ocr (pyproject.toml)
127
+ uvloop==0.23.0 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
128
+ # via uvicorn
129
+ watchfiles==1.3.0
130
+ # via uvicorn
131
+ websockets==17.2
132
+ # via uvicorn
133
+ zxing-cpp==3.1.1
134
+ # via india-document-ocr (pyproject.toml)
package/package.json CHANGED
@@ -1,6 +1,78 @@
1
1
  {
2
2
  "name": "document-ocr",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "3.1.3",
4
+ "description": "Local and hosted OCR SDK for passports, Indian KYC and experimental US identity, immigration and tax documents",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js",
10
+ "require": "./dist/index.cjs"
11
+ }
12
+ },
13
+ "main": "./dist/index.cjs",
14
+ "module": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "files": [
17
+ "dist",
18
+ "python",
19
+ "scripts",
20
+ "README.md",
21
+ "hosting"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/piyush-gambhir/document-ocr"
26
+ },
27
+ "homepage": "https://github.com/piyush-gambhir/document-ocr#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/piyush-gambhir/document-ocr/issues"
30
+ },
31
+ "author": "Piyush Gambhir",
32
+ "keywords": [
33
+ "passport",
34
+ "ocr",
35
+ "mrz",
36
+ "rapidocr",
37
+ "onnx",
38
+ "aadhaar",
39
+ "pan",
40
+ "kyc",
41
+ "nrega",
42
+ "npr",
43
+ "document-scanning",
44
+ "standalone"
45
+ ],
46
+ "scripts": {
47
+ "postinstall": "node scripts/postinstall.mjs",
48
+ "sync-python": "bash scripts/sync-python.sh",
49
+ "build": "bash scripts/sync-python.sh && node scripts/sync-hosting.mjs && tsup",
50
+ "dev": "tsup --watch",
51
+ "typecheck": "node node_modules/@typescript/native/bin/tsc --noEmit",
52
+ "test": "vitest run ../../tests/typescript",
53
+ "typecheck:compat": "node node_modules/typescript/bin/tsc --noEmit"
54
+ },
55
+ "optionalDependencies": {
56
+ "@aws-sdk/client-lambda": "^3.1156.0"
57
+ },
58
+ "devDependencies": {
59
+ "@types/node": "^26.6.5",
60
+ "tsup": "^9.0.0",
61
+ "typescript": "~6.0.3",
62
+ "vitest": "^5.0.5",
63
+ "@typescript/native": "npm:typescript@^7.0.2"
64
+ },
65
+ "overrides": {
66
+ "esbuild": "^0.28.2"
67
+ },
68
+ "engines": {
69
+ "node": ">=18"
70
+ },
71
+ "license": "MIT",
72
+ "publishConfig": {
73
+ "access": "public"
74
+ },
75
+ "bin": {
76
+ "document-ocr": "./scripts/dococr.mjs"
77
+ }
78
+ }
File without changes
@@ -0,0 +1,298 @@
1
+ """
2
+ Extractor for Indian Aadhaar cards (front and back).
3
+
4
+ Front fields: Aadhaar number, name, date/year of birth, gender.
5
+ Back fields: address, pincode.
6
+
7
+ Aadhaar has no Latin field labels for the name (it sits above the DOB line), so
8
+ the name is found spatially relative to the DOB/gender block. The Aadhaar number
9
+ and its Verhoeff checksum are handled in core.validators.
10
+
11
+ Robustness notes:
12
+ * VID (a 16-digit Virtual ID, grouped 4-4-4-4) is masked out of the text
13
+ before the 12-digit Aadhaar is extracted, otherwise the VID's first twelve
14
+ digits would be mis-captured as the Aadhaar number.
15
+ * Masked Aadhaar ("XXXX XXXX 9012") is recognised: the visible tail is
16
+ surfaced via ``aadhaar_masked`` / ``aadhaar_last4`` and ``checksum_valid``
17
+ stays False (we never crash on a missing/partial number).
18
+ * Gender is normalised across English and Hindi (Devanagari) spellings.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from dataclasses import dataclass
25
+ from typing import Optional
26
+
27
+ from .ocr_engine import TextRegion
28
+ from .validator import find_label_value, find_visual_field
29
+ from .validators import extract_aadhaar_number, is_valid_aadhaar
30
+
31
+ _DATE_RE = re.compile(r"\b(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{4})\b")
32
+ _YEAR_RE = re.compile(r"\b(19\d{2}|20\d{2})\b")
33
+ _PINCODE_RE = re.compile(r"\b(\d{6})\b")
34
+
35
+ # A Virtual ID is 16 digits, printed grouped 4-4-4-4 (optionally spaced) — or as
36
+ # a bare 16-digit run. Either form must be removed before Aadhaar extraction.
37
+ _VID_RE = re.compile(r"\b\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\b")
38
+ _VID_LABEL_RE = re.compile(r"\bVID\b", re.IGNORECASE)
39
+
40
+ # Masked Aadhaar: leading groups are X'd out and only the final 4 digits show,
41
+ # e.g. "XXXX XXXX 9012" / "xxxxxxxx9012" / "**** **** 9012".
42
+ _MASKED_AADHAAR_RE = re.compile(
43
+ r"(?:[Xx*]{4}[\s-]?){2}(\d{4})\b"
44
+ )
45
+
46
+ _GENDER_WORDS = {
47
+ "MALE": "MALE",
48
+ "FEMALE": "FEMALE",
49
+ "TRANSGENDER": "TRANSGENDER",
50
+ }
51
+
52
+ # Hindi (Devanagari) gender words map to the same canonical values.
53
+ _GENDER_HINDI = {
54
+ "पुरुष": "MALE",
55
+ "महिला": "FEMALE",
56
+ "स्त्री": "FEMALE",
57
+ "किन्नर": "TRANSGENDER",
58
+ "ट्रांसजेंडर": "TRANSGENDER",
59
+ }
60
+
61
+ # Header / label noise that must never be mistaken for the holder name.
62
+ _NAME_STOPWORDS = {
63
+ "GOVERNMENT", "GOVT", "INDIA", "BHARAT", "AADHAAR", "AADHAR", "UNIQUE",
64
+ "IDENTIFICATION", "AUTHORITY", "UIDAI", "MALE", "FEMALE", "TRANSGENDER",
65
+ "DOB", "YEAR", "BIRTH", "YOB", "ADDRESS", "MOBILE", "VID", "ENROLLMENT",
66
+ "ENROLMENT", "GENDER", "DATE", "NUMBER", "CARD", "DOWNLOAD", "ISSUE",
67
+ "ISSUED", "HELP", "EMAIL", "WWW", "PHONE", "MERA", "AADHAR", "VALID",
68
+ }
69
+
70
+ # Care-of prefixes that introduce the address on the back.
71
+ _CARE_OF_RE = re.compile(r"\b([SCDWscdw])\s*/\s*[Oo]\b")
72
+
73
+
74
+ @dataclass
75
+ class AadhaarFields:
76
+ aadhaar_number: Optional[str] = None
77
+ name: Optional[str] = None
78
+ date_of_birth: Optional[str] = None
79
+ year_of_birth: Optional[str] = None
80
+ gender: Optional[str] = None
81
+ address: Optional[str] = None
82
+ pincode: Optional[str] = None
83
+ checksum_valid: bool = False
84
+ # --- additive fields (do not rename existing ones) ---
85
+ aadhaar_masked: bool = False
86
+ aadhaar_last4: Optional[str] = None
87
+ vid: Optional[str] = None
88
+
89
+
90
+ def _devanagari_ratio(text: str) -> float:
91
+ """Fraction of alphabetic characters that fall in the Devanagari block."""
92
+ letters = [c for c in text if c.isalpha()]
93
+ if not letters:
94
+ return 0.0
95
+ devanagari = sum(1 for c in letters if "ऀ" <= c <= "ॿ")
96
+ return devanagari / len(letters)
97
+
98
+
99
+ def _is_name_like(text: str) -> bool:
100
+ stripped = text.strip()
101
+ if len(stripped) < 3 or any(ch.isdigit() for ch in stripped):
102
+ return False
103
+ letters = [c for c in stripped if c.isalpha()]
104
+ if not letters:
105
+ return False
106
+ # Mostly Latin letters (Aadhaar prints the name in English too).
107
+ if _devanagari_ratio(stripped) > 0.5:
108
+ return False
109
+ if sum(1 for c in letters if ord(c) > 127) / len(letters) > 0.5:
110
+ return False
111
+ # A care-of address line ("S/O ...") is not the holder name.
112
+ if _CARE_OF_RE.search(stripped):
113
+ return False
114
+ words = re.sub(r"[^A-Z ]", " ", stripped.upper()).split()
115
+ if not words:
116
+ return False
117
+ return not any(w in _NAME_STOPWORDS for w in words)
118
+
119
+
120
+ def _find_gender(regions: list[TextRegion]) -> Optional[str]:
121
+ for region in regions:
122
+ text = region.text
123
+ # English / Latin gender words, matched as whole words.
124
+ upper = re.sub(r"[^A-Z]", " ", text.upper())
125
+ padded = f" {upper} "
126
+ for word, value in _GENDER_WORDS.items():
127
+ if f" {word} " in padded:
128
+ return value
129
+ # Hindi (Devanagari) gender words.
130
+ for word, value in _GENDER_HINDI.items():
131
+ if word in text:
132
+ return value
133
+ return None
134
+
135
+
136
+ def _find_dob_region(regions: list[TextRegion]) -> Optional[TextRegion]:
137
+ """Locate the region carrying the date/year of birth.
138
+
139
+ Prefer a region that both mentions a DOB/birth label *and* carries a date;
140
+ fall back to any birth-labelled region, then to any bare date region.
141
+ """
142
+ labelled_with_date: Optional[TextRegion] = None
143
+ labelled: Optional[TextRegion] = None
144
+ bare_date: Optional[TextRegion] = None
145
+
146
+ for region in regions:
147
+ upper = region.text.upper()
148
+ has_birth_label = (
149
+ "DOB" in upper
150
+ or "YEAR OF BIRTH" in upper
151
+ or "YOB" in upper
152
+ or "BIRTH" in upper
153
+ or "जन्म" in region.text # Hindi "janm" (birth)
154
+ )
155
+ has_date = bool(_DATE_RE.search(region.text))
156
+ if has_birth_label and has_date and labelled_with_date is None:
157
+ labelled_with_date = region
158
+ elif has_birth_label and labelled is None:
159
+ labelled = region
160
+ elif has_date and bare_date is None:
161
+ bare_date = region
162
+
163
+ return labelled_with_date or labelled or bare_date
164
+
165
+
166
+ def _find_name(
167
+ regions: list[TextRegion],
168
+ dob_region: Optional[TextRegion],
169
+ *,
170
+ is_front: bool,
171
+ ) -> Optional[str]:
172
+ candidates = [r for r in regions if _is_name_like(r.text)]
173
+ if not candidates:
174
+ return None
175
+ if dob_region is not None:
176
+ dob_top = min(p[1] for p in dob_region.bbox)
177
+ dob_left = min(p[0] for p in dob_region.bbox)
178
+ above = [r for r in candidates if max(p[1] for p in r.bbox) <= dob_top + 5]
179
+ if above:
180
+ # Closest line above the DOB, breaking ties by horizontal proximity.
181
+ above.sort(
182
+ key=lambda r: (
183
+ dob_top - max(p[1] for p in r.bbox),
184
+ abs(min(p[0] for p in r.bbox) - dob_left),
185
+ )
186
+ )
187
+ return above[0].text.strip()
188
+ # No DOB anchor: only guess a name on a front page (a back/address page has
189
+ # no holder name, and address/locality lines must not be fabricated as one).
190
+ if not is_front:
191
+ return None
192
+ # Fall back to the topmost name-like line (the "Government of India" band is
193
+ # already filtered out by the stopword list).
194
+ candidates.sort(key=lambda r: min(p[1] for p in r.bbox))
195
+ return candidates[0].text.strip()
196
+
197
+
198
+ def _find_address(regions: list[TextRegion]) -> Optional[str]:
199
+ label = find_visual_field(regions, ["ADDRESS", "पता"])
200
+ if label is None:
201
+ return None
202
+ label_bottom = max(p[1] for p in label.bbox)
203
+ label_top = min(p[1] for p in label.bbox)
204
+ lines = []
205
+ for region in regions:
206
+ if region is label:
207
+ continue
208
+ top = min(p[1] for p in region.bbox)
209
+ bottom = max(p[1] for p in region.bbox)
210
+ # Keep lines that begin at/below the label and within a reasonable span.
211
+ # Also allow a line that shares the label's row (inline "Address: ...").
212
+ on_label_row = bottom > label_top and top < label_bottom
213
+ if not on_label_row and (top < label_bottom - 10 or top - label_bottom > 300):
214
+ continue
215
+ text = region.text.strip()
216
+ if _devanagari_ratio(text) > 0.5:
217
+ continue # skip mostly-Devanagari noise lines
218
+ # Skip a standalone Aadhaar/VID number line printed below the address.
219
+ if not any(c.isalpha() for c in text) and sum(c.isdigit() for c in text) >= 8:
220
+ continue
221
+ if len(text) >= 2:
222
+ lines.append((top, text))
223
+ lines.sort(key=lambda x: x[0])
224
+ if not lines:
225
+ # Address may be on the same line as the label ("Address: ...").
226
+ inline = find_label_value(regions, ["ADDRESS"])
227
+ return inline
228
+ return ", ".join(text for _, text in lines[:6])
229
+
230
+
231
+ def _strip_vid(text: str) -> tuple[str, Optional[str]]:
232
+ """Remove 16-digit VID sequences from ``text`` and return (cleaned, vid).
233
+
234
+ Both spaced ("9148 6541 8231 2156") and bare ("9148654182312156") 16-digit
235
+ forms are stripped so the downstream 12-digit Aadhaar matcher cannot slice
236
+ the VID's leading twelve digits. The first VID found is surfaced.
237
+ """
238
+ found: Optional[str] = None
239
+
240
+ def _replace(match: re.Match[str]) -> str:
241
+ nonlocal found
242
+ if found is None:
243
+ digits = re.sub(r"\D", "", match.group(0))
244
+ found = f"{digits[0:4]} {digits[4:8]} {digits[8:12]} {digits[12:16]}"
245
+ return " "
246
+
247
+ cleaned = _VID_RE.sub(_replace, text)
248
+ return cleaned, found
249
+
250
+
251
+ def extract_aadhaar(regions: list[TextRegion]) -> AadhaarFields:
252
+ fields = AadhaarFields()
253
+ joined = " ".join(r.text for r in regions if r.text.strip())
254
+
255
+ # Strip any 16-digit VID first so it can't be misread as the Aadhaar number.
256
+ cleaned, fields.vid = _strip_vid(joined)
257
+
258
+ fields.aadhaar_number = extract_aadhaar_number(cleaned)
259
+ fields.checksum_valid = is_valid_aadhaar(cleaned)
260
+
261
+ # Masked Aadhaar handling: when no full number is present but a masked form
262
+ # ("XXXX XXXX 9012") is, surface the visible tail without claiming validity.
263
+ if fields.aadhaar_number is None:
264
+ masked = _MASKED_AADHAAR_RE.search(cleaned)
265
+ if masked:
266
+ fields.aadhaar_masked = True
267
+ fields.aadhaar_last4 = masked.group(1)
268
+ elif fields.aadhaar_number:
269
+ fields.aadhaar_last4 = fields.aadhaar_number.split()[-1]
270
+
271
+ fields.gender = _find_gender(regions)
272
+
273
+ dob_region = _find_dob_region(regions)
274
+ if dob_region is not None:
275
+ date_match = _DATE_RE.search(dob_region.text)
276
+ if date_match:
277
+ fields.date_of_birth = date_match.group(1)
278
+ else:
279
+ year_match = _YEAR_RE.search(dob_region.text)
280
+ if year_match:
281
+ fields.year_of_birth = year_match.group(1)
282
+
283
+ # A page is "front-like" when it carries a DOB/year or a gender word, and is
284
+ # not an address (back) page. The name heuristic only fabricates a fallback
285
+ # name on front-like pages.
286
+ has_address_label = find_visual_field(regions, ["ADDRESS", "पता"]) is not None
287
+ is_front = (
288
+ dob_region is not None or fields.gender is not None
289
+ ) and not has_address_label
290
+ fields.name = _find_name(regions, dob_region, is_front=is_front)
291
+
292
+ address = _find_address(regions)
293
+ if address:
294
+ fields.address = address
295
+ pincode = _PINCODE_RE.search(address)
296
+ fields.pincode = pincode.group(1) if pincode else None
297
+
298
+ return fields