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.
- package/LICENSE +21 -0
- package/README.md +260 -2
- package/dist/index.cjs +659 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +293 -0
- package/dist/index.d.ts +293 -0
- package/dist/index.js +616 -0
- package/dist/index.js.map +1 -0
- package/hosting/.dockerignore +17 -0
- package/hosting/.gcloudignore +14 -0
- package/hosting/FEATURES.md +172 -0
- package/hosting/HOSTING.md +268 -0
- package/hosting/LICENSE +21 -0
- package/hosting/README.md +369 -0
- package/hosting/core/__init__.py +0 -0
- package/hosting/core/aadhaar_extractor.py +298 -0
- package/hosting/core/back_page_extractor.py +492 -0
- package/hosting/core/barcodes.py +125 -0
- package/hosting/core/card_recovery.py +244 -0
- package/hosting/core/document_bundle.py +82 -0
- package/hosting/core/document_classifier.py +233 -0
- package/hosting/core/document_input.py +135 -0
- package/hosting/core/document_registry.py +143 -0
- package/hosting/core/driving_licence_extractor.py +510 -0
- package/hosting/core/ead_recovery.py +54 -0
- package/hosting/core/evidence.py +54 -0
- package/hosting/core/form_recovery.py +61 -0
- package/hosting/core/jobs.py +232 -0
- package/hosting/core/kyc_ocr.py +132 -0
- package/hosting/core/kyc_validation.py +411 -0
- package/hosting/core/model_setup.py +13 -0
- package/hosting/core/mrz_parser.py +336 -0
- package/hosting/core/mrz_recovery.py +193 -0
- package/hosting/core/npr_extractor.py +410 -0
- package/hosting/core/nrega_extractor.py +827 -0
- package/hosting/core/ocr_engine.py +207 -0
- package/hosting/core/page_classifier.py +137 -0
- package/hosting/core/pan_extractor.py +183 -0
- package/hosting/core/pipeline.py +712 -0
- package/hosting/core/preprocessor.py +297 -0
- package/hosting/core/recovery_budget.py +67 -0
- package/hosting/core/redaction.py +48 -0
- package/hosting/core/review.html +244 -0
- package/hosting/core/structured_documents.py +422 -0
- package/hosting/core/travel_mrz.py +164 -0
- package/hosting/core/travel_recovery.py +187 -0
- package/hosting/core/validator.py +396 -0
- package/hosting/core/validators.py +179 -0
- package/hosting/core/voter_id_extractor.py +363 -0
- package/hosting/deploy/cloudflare/deploy.sh +44 -0
- package/hosting/deploy/cloudflare/package-lock.json +3088 -0
- package/hosting/deploy/cloudflare/package.json +29 -0
- package/hosting/deploy/cloudflare/src/gateway.ts +42 -0
- package/hosting/deploy/cloudflare/src/index.ts +21 -0
- package/hosting/deploy/cloudflare/test/gateway.test.ts +59 -0
- package/hosting/deploy/cloudflare/tsconfig.json +12 -0
- package/hosting/deploy/cloudflare/worker-configuration.d.ts +16253 -0
- package/hosting/deploy/cloudflare/wrangler.jsonc +26 -0
- package/hosting/deploy/cloudrun/cloudbuild.yaml +18 -0
- package/hosting/deploy/cloudrun/deploy.sh +71 -0
- package/hosting/deploy/cloudrun/render_service.py +47 -0
- package/hosting/deploy/common.sh +14 -0
- package/hosting/deploy/docker/Dockerfile +16 -0
- package/hosting/deploy/docker/server.py +313 -0
- package/hosting/deploy/lambda/Dockerfile.lambda +11 -0
- package/hosting/deploy/lambda/deploy.sh +22 -0
- package/hosting/deploy/lambda/handler.py +132 -0
- package/hosting/deploy/lambda/template.yaml +59 -0
- package/hosting/deploy/local/dev.sh +18 -0
- package/hosting/deploy/server/Caddyfile +5 -0
- package/hosting/deploy/server/compose.jobs.yaml +36 -0
- package/hosting/deploy/server/compose.yaml +38 -0
- package/hosting/deploy/server/deploy.sh +23 -0
- package/hosting/infra/cloudrun/main.tf +104 -0
- package/hosting/infra/cloudrun/terraform.tfvars.example +7 -0
- package/hosting/infra/cloudrun/variables.tf +55 -0
- package/hosting/infra/lambda/main.tf +63 -0
- package/hosting/infra/lambda/terraform.tfvars.example +6 -0
- package/hosting/infra/lambda/variables.tf +37 -0
- package/hosting/pyproject.toml +65 -0
- package/hosting/requirements.lock +134 -0
- package/package.json +76 -4
- package/python/core/__init__.py +0 -0
- package/python/core/aadhaar_extractor.py +298 -0
- package/python/core/back_page_extractor.py +492 -0
- package/python/core/barcodes.py +125 -0
- package/python/core/card_recovery.py +244 -0
- package/python/core/document_bundle.py +82 -0
- package/python/core/document_classifier.py +233 -0
- package/python/core/document_input.py +135 -0
- package/python/core/document_registry.py +143 -0
- package/python/core/driving_licence_extractor.py +510 -0
- package/python/core/ead_recovery.py +54 -0
- package/python/core/evidence.py +54 -0
- package/python/core/form_recovery.py +61 -0
- package/python/core/jobs.py +232 -0
- package/python/core/kyc_ocr.py +132 -0
- package/python/core/kyc_validation.py +411 -0
- package/python/core/model_setup.py +13 -0
- package/python/core/mrz_parser.py +336 -0
- package/python/core/mrz_recovery.py +193 -0
- package/python/core/npr_extractor.py +410 -0
- package/python/core/nrega_extractor.py +827 -0
- package/python/core/ocr_engine.py +207 -0
- package/python/core/page_classifier.py +137 -0
- package/python/core/pan_extractor.py +183 -0
- package/python/core/pipeline.py +712 -0
- package/python/core/preprocessor.py +297 -0
- package/python/core/recovery_budget.py +67 -0
- package/python/core/redaction.py +48 -0
- package/python/core/review.html +244 -0
- package/python/core/structured_documents.py +422 -0
- package/python/core/travel_mrz.py +164 -0
- package/python/core/travel_recovery.py +187 -0
- package/python/core/validator.py +396 -0
- package/python/core/validators.py +179 -0
- package/python/core/voter_id_extractor.py +363 -0
- package/python/pyproject.toml +34 -0
- package/python/requirements.lock +134 -0
- package/python/server.py +313 -0
- package/scripts/dococr.mjs +163 -0
- package/scripts/postinstall.mjs +173 -0
- package/scripts/sync-hosting.mjs +35 -0
- package/scripts/sync-python.sh +20 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""Conservative extractor for NPR name-and-address letters.
|
|
2
|
+
|
|
3
|
+
The Reserve Bank of India's KYC directions refer to a letter issued by the
|
|
4
|
+
National Population Register (NPR) that contains a person's name and address.
|
|
5
|
+
They do not define a single visual template or a universal reference-number
|
|
6
|
+
format for such letters. This extractor therefore makes deliberately narrow
|
|
7
|
+
claims:
|
|
8
|
+
|
|
9
|
+
* It returns fields only when the OCR contains explicit NPR evidence.
|
|
10
|
+
* Personal data is read only from an adjacent, explicit field label.
|
|
11
|
+
* Reference numbers are preserved as printed; no checksum or standard format
|
|
12
|
+
is claimed.
|
|
13
|
+
* Dates are surfaced only when tied to an issue/letter-date label.
|
|
14
|
+
|
|
15
|
+
This is text extraction, not proof that a letter is authentic or current.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from .ocr_engine import TextRegion
|
|
25
|
+
from .validator import find_visual_value_near, find_visual_value_right
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_DATE_RE = re.compile(
|
|
29
|
+
r"\b("
|
|
30
|
+
r"\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4}"
|
|
31
|
+
r"|\d{4}[/.\-]\d{1,2}[/.\-]\d{1,2}"
|
|
32
|
+
r"|\d{1,2}\s+(?:JAN(?:UARY)?|FEB(?:RUARY)?|MAR(?:CH)?|APR(?:IL)?"
|
|
33
|
+
r"|MAY|JUN(?:E)?|JUL(?:Y)?|AUG(?:UST)?|SEP(?:TEMBER)?"
|
|
34
|
+
r"|OCT(?:OBER)?|NOV(?:EMBER)?|DEC(?:EMBER)?)\s+\d{4}"
|
|
35
|
+
r")\b",
|
|
36
|
+
re.IGNORECASE,
|
|
37
|
+
)
|
|
38
|
+
_PINCODE_RE = re.compile(r"\b([1-9]\d{5})\b")
|
|
39
|
+
|
|
40
|
+
_REFERENCE_LABELS = (
|
|
41
|
+
"REFERENCE NUMBER",
|
|
42
|
+
"REFERENCE NO",
|
|
43
|
+
"REF NUMBER",
|
|
44
|
+
"REF NO",
|
|
45
|
+
"LETTER NUMBER",
|
|
46
|
+
"LETTER NO",
|
|
47
|
+
"FILE NUMBER",
|
|
48
|
+
"FILE NO",
|
|
49
|
+
)
|
|
50
|
+
_REFERENCE_HINDI_LABELS = ("संदर्भ संख्या", "पत्र संख्या")
|
|
51
|
+
|
|
52
|
+
_NAME_LABELS = (
|
|
53
|
+
"NAME OF RESIDENT",
|
|
54
|
+
"RESIDENT NAME",
|
|
55
|
+
"NAME",
|
|
56
|
+
)
|
|
57
|
+
_NAME_HINDI_LABELS = ("निवासी का नाम", "नाम")
|
|
58
|
+
|
|
59
|
+
_ADDRESS_LABELS = (
|
|
60
|
+
"ADDRESS OF RESIDENT",
|
|
61
|
+
"RESIDENT ADDRESS",
|
|
62
|
+
"POSTAL ADDRESS",
|
|
63
|
+
"ADDRESS",
|
|
64
|
+
)
|
|
65
|
+
_ADDRESS_HINDI_LABELS = ("निवासी का पता", "पता")
|
|
66
|
+
|
|
67
|
+
_ISSUE_DATE_LABELS = (
|
|
68
|
+
"DATE OF ISSUE",
|
|
69
|
+
"ISSUE DATE",
|
|
70
|
+
"LETTER DATE",
|
|
71
|
+
)
|
|
72
|
+
_ISSUE_DATE_HINDI_LABELS = ("जारी करने की तिथि", "दिनांक")
|
|
73
|
+
|
|
74
|
+
_PINCODE_LABELS = ("PIN CODE", "PINCODE", "POSTAL CODE")
|
|
75
|
+
_PINCODE_HINDI_LABELS = ("पिन कोड",)
|
|
76
|
+
|
|
77
|
+
# Single-word labels are common prose and also occur in the official document
|
|
78
|
+
# title ("NPR name and address letter"). They are safe only as a standalone
|
|
79
|
+
# label or as the prefix of an explicitly delimited ``Label: value`` field.
|
|
80
|
+
_GENERIC_ENGLISH_LABELS = frozenset({"NAME", "ADDRESS"})
|
|
81
|
+
_GENERIC_HINDI_LABELS = frozenset({"नाम", "पता"})
|
|
82
|
+
|
|
83
|
+
_ALL_ENGLISH_LABELS = (
|
|
84
|
+
*_REFERENCE_LABELS,
|
|
85
|
+
*_NAME_LABELS,
|
|
86
|
+
*_ADDRESS_LABELS,
|
|
87
|
+
*_ISSUE_DATE_LABELS,
|
|
88
|
+
*_PINCODE_LABELS,
|
|
89
|
+
)
|
|
90
|
+
_ALL_HINDI_LABELS = (
|
|
91
|
+
*_REFERENCE_HINDI_LABELS,
|
|
92
|
+
*_NAME_HINDI_LABELS,
|
|
93
|
+
*_ADDRESS_HINDI_LABELS,
|
|
94
|
+
*_ISSUE_DATE_HINDI_LABELS,
|
|
95
|
+
*_PINCODE_HINDI_LABELS,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
_ADDRESS_STOP_PHRASES = (
|
|
99
|
+
"YOURS FAITHFULLY",
|
|
100
|
+
"AUTHORISED SIGNATORY",
|
|
101
|
+
"AUTHORIZED SIGNATORY",
|
|
102
|
+
"SIGNATURE",
|
|
103
|
+
"REGISTRAR GENERAL",
|
|
104
|
+
"CENSUS COMMISSIONER",
|
|
105
|
+
"TELEPHONE",
|
|
106
|
+
"PHONE",
|
|
107
|
+
"EMAIL",
|
|
108
|
+
"WEBSITE",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class NprLetterFields:
|
|
114
|
+
"""Fields conservatively recoverable from an NPR name/address letter."""
|
|
115
|
+
|
|
116
|
+
reference_number: Optional[str] = None
|
|
117
|
+
name: Optional[str] = None
|
|
118
|
+
address: Optional[str] = None
|
|
119
|
+
pincode: Optional[str] = None
|
|
120
|
+
issue_date: Optional[str] = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _normalise_latin(text: str) -> str:
|
|
124
|
+
text = text.upper().replace("&", " AND ")
|
|
125
|
+
text = re.sub(r"[^A-Z0-9]+", " ", text)
|
|
126
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _has_npr_evidence(regions: list[TextRegion]) -> bool:
|
|
130
|
+
"""Require an explicit NPR title, including its common Hindi rendering.
|
|
131
|
+
|
|
132
|
+
The acronym ``NPR`` alone is accepted only when accompanied by the issuing
|
|
133
|
+
authority (Registrar General / Census Commissioner). This avoids treating
|
|
134
|
+
unrelated letters or uses of the acronym as NPR identity documents.
|
|
135
|
+
"""
|
|
136
|
+
joined = " ".join(region.text for region in regions if region.text.strip())
|
|
137
|
+
normalised = _normalise_latin(joined)
|
|
138
|
+
|
|
139
|
+
if "NATIONAL POPULATION REGISTER" in normalised:
|
|
140
|
+
return True
|
|
141
|
+
if "राष्ट्रीय जनसंख्या रजिस्टर" in joined:
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
has_npr_token = bool(re.search(r"\bNPR\b", normalised))
|
|
145
|
+
has_issuer = (
|
|
146
|
+
"REGISTRAR GENERAL" in normalised
|
|
147
|
+
or "CENSUS COMMISSIONER" in normalised
|
|
148
|
+
or "महापंजीयक" in joined
|
|
149
|
+
or "जनगणना आयुक्त" in joined
|
|
150
|
+
)
|
|
151
|
+
return has_npr_token and has_issuer
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _label_match_score(
|
|
155
|
+
text: str,
|
|
156
|
+
english_labels: tuple[str, ...],
|
|
157
|
+
hindi_labels: tuple[str, ...],
|
|
158
|
+
) -> int:
|
|
159
|
+
normalised = _normalise_latin(text)
|
|
160
|
+
padded = f" {normalised} "
|
|
161
|
+
raw_prefix = re.split(r"[::]", text, maxsplit=1)[0]
|
|
162
|
+
normalised_prefix = _normalise_latin(raw_prefix)
|
|
163
|
+
best = 0
|
|
164
|
+
|
|
165
|
+
for label in english_labels:
|
|
166
|
+
if normalised_prefix == label and normalised != label:
|
|
167
|
+
# An inline "Label: value" is stronger evidence than the same word
|
|
168
|
+
# appearing in a title such as "NPR name and address letter".
|
|
169
|
+
best = max(best, 30_000 + len(label))
|
|
170
|
+
elif normalised == label:
|
|
171
|
+
best = max(best, 20_000 + len(label))
|
|
172
|
+
elif (
|
|
173
|
+
label not in _GENERIC_ENGLISH_LABELS
|
|
174
|
+
and f" {label} " in padded
|
|
175
|
+
):
|
|
176
|
+
best = max(best, 10_000 + len(label))
|
|
177
|
+
|
|
178
|
+
for label in hindi_labels:
|
|
179
|
+
if raw_prefix.strip(" :.-/") == label and text.strip(" :.-/") != label:
|
|
180
|
+
best = max(best, 30_000 + len(label))
|
|
181
|
+
elif text.strip(" :.-/") == label:
|
|
182
|
+
best = max(best, 20_000 + len(label))
|
|
183
|
+
elif label not in _GENERIC_HINDI_LABELS and label in text:
|
|
184
|
+
best = max(best, 10_000 + len(label))
|
|
185
|
+
|
|
186
|
+
return best
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _find_label_region(
|
|
190
|
+
regions: list[TextRegion],
|
|
191
|
+
english_labels: tuple[str, ...],
|
|
192
|
+
hindi_labels: tuple[str, ...] = (),
|
|
193
|
+
) -> Optional[TextRegion]:
|
|
194
|
+
best: tuple[int, float, TextRegion] | None = None
|
|
195
|
+
for region in regions:
|
|
196
|
+
score = _label_match_score(region.text, english_labels, hindi_labels)
|
|
197
|
+
if score == 0:
|
|
198
|
+
continue
|
|
199
|
+
candidate = (score, region.confidence, region)
|
|
200
|
+
if best is None or candidate[:2] > best[:2]:
|
|
201
|
+
best = candidate
|
|
202
|
+
return best[2] if best else None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _prefix_is_label(
|
|
206
|
+
prefix: str,
|
|
207
|
+
english_labels: tuple[str, ...],
|
|
208
|
+
hindi_labels: tuple[str, ...],
|
|
209
|
+
) -> bool:
|
|
210
|
+
return _label_match_score(prefix, english_labels, hindi_labels) > 0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _inline_value(
|
|
214
|
+
text: str,
|
|
215
|
+
english_labels: tuple[str, ...],
|
|
216
|
+
hindi_labels: tuple[str, ...] = (),
|
|
217
|
+
) -> Optional[str]:
|
|
218
|
+
"""Read ``Label: value`` without assuming a particular letter layout."""
|
|
219
|
+
pieces = re.split(r"[::]", text, maxsplit=1)
|
|
220
|
+
if len(pieces) != 2:
|
|
221
|
+
pieces = re.split(r"\s+[–—-]\s+", text, maxsplit=1)
|
|
222
|
+
if len(pieces) != 2:
|
|
223
|
+
return None
|
|
224
|
+
prefix, value = pieces
|
|
225
|
+
if not _prefix_is_label(prefix, english_labels, hindi_labels):
|
|
226
|
+
return None
|
|
227
|
+
cleaned = re.sub(r"\s+", " ", value).strip(" \t:;,–—-")
|
|
228
|
+
return cleaned or None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _is_field_label(text: str) -> bool:
|
|
232
|
+
prefix = re.split(r"[::]", text, maxsplit=1)[0]
|
|
233
|
+
return _label_match_score(
|
|
234
|
+
prefix,
|
|
235
|
+
_ALL_ENGLISH_LABELS,
|
|
236
|
+
_ALL_HINDI_LABELS,
|
|
237
|
+
) > 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _clean_scalar(text: Optional[str]) -> Optional[str]:
|
|
241
|
+
if text is None:
|
|
242
|
+
return None
|
|
243
|
+
cleaned = re.sub(r"\s+", " ", text).strip(" \t:;,–—-")
|
|
244
|
+
if not cleaned or _is_field_label(cleaned):
|
|
245
|
+
return None
|
|
246
|
+
return cleaned
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _labelled_value(
|
|
250
|
+
regions: list[TextRegion],
|
|
251
|
+
english_labels: tuple[str, ...],
|
|
252
|
+
hindi_labels: tuple[str, ...] = (),
|
|
253
|
+
) -> Optional[str]:
|
|
254
|
+
label = _find_label_region(regions, english_labels, hindi_labels)
|
|
255
|
+
if label is None:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
inline = _inline_value(label.text, english_labels, hindi_labels)
|
|
259
|
+
if inline:
|
|
260
|
+
return _clean_scalar(inline)
|
|
261
|
+
|
|
262
|
+
value = find_visual_value_right(regions, label) or find_visual_value_near(
|
|
263
|
+
regions, label
|
|
264
|
+
)
|
|
265
|
+
return _clean_scalar(value.text if value is not None else None)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _extract_reference_number(regions: list[TextRegion]) -> Optional[str]:
|
|
269
|
+
value = _labelled_value(
|
|
270
|
+
regions,
|
|
271
|
+
_REFERENCE_LABELS,
|
|
272
|
+
_REFERENCE_HINDI_LABELS,
|
|
273
|
+
)
|
|
274
|
+
if value is None or len(value) > 80 or not any(ch.isalnum() for ch in value):
|
|
275
|
+
return None
|
|
276
|
+
return value
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _extract_name(regions: list[TextRegion]) -> Optional[str]:
|
|
280
|
+
value = _labelled_value(regions, _NAME_LABELS, _NAME_HINDI_LABELS)
|
|
281
|
+
if value is None or len(value) > 160:
|
|
282
|
+
return None
|
|
283
|
+
# A resident's name should contain at least one letter. We deliberately do
|
|
284
|
+
# not impose an English-only character set because NPR letters may be
|
|
285
|
+
# bilingual or use an Indian script.
|
|
286
|
+
if not any(ch.isalpha() for ch in value):
|
|
287
|
+
return None
|
|
288
|
+
return value
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _top(region: TextRegion) -> int:
|
|
292
|
+
return min(point[1] for point in region.bbox)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _bottom(region: TextRegion) -> int:
|
|
296
|
+
return max(point[1] for point in region.bbox)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _left(region: TextRegion) -> int:
|
|
300
|
+
return min(point[0] for point in region.bbox)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _is_address_footer(text: str) -> bool:
|
|
304
|
+
normalised = _normalise_latin(text)
|
|
305
|
+
return any(phrase in normalised for phrase in _ADDRESS_STOP_PHRASES)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _extract_address(regions: list[TextRegion]) -> Optional[str]:
|
|
309
|
+
label = _find_label_region(regions, _ADDRESS_LABELS, _ADDRESS_HINDI_LABELS)
|
|
310
|
+
if label is None:
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
label_bottom = _bottom(label)
|
|
314
|
+
label_left = _left(label)
|
|
315
|
+
parts: list[str] = []
|
|
316
|
+
used_regions: set[int] = set()
|
|
317
|
+
|
|
318
|
+
inline = _inline_value(label.text, _ADDRESS_LABELS, _ADDRESS_HINDI_LABELS)
|
|
319
|
+
if inline:
|
|
320
|
+
parts.append(inline)
|
|
321
|
+
|
|
322
|
+
right = find_visual_value_right(regions, label)
|
|
323
|
+
if right is not None and not _is_field_label(right.text):
|
|
324
|
+
cleaned = _clean_scalar(right.text)
|
|
325
|
+
if cleaned and cleaned not in parts:
|
|
326
|
+
parts.append(cleaned)
|
|
327
|
+
used_regions.add(id(right))
|
|
328
|
+
|
|
329
|
+
# Find the first later field/footer. Address continuation lines must end
|
|
330
|
+
# before it, even if they happen to be geometrically close.
|
|
331
|
+
stop_y: Optional[int] = None
|
|
332
|
+
for region in regions:
|
|
333
|
+
if region is label or _top(region) < label_bottom - 4:
|
|
334
|
+
continue
|
|
335
|
+
if _is_field_label(region.text) or _is_address_footer(region.text):
|
|
336
|
+
top = _top(region)
|
|
337
|
+
if stop_y is None or top < stop_y:
|
|
338
|
+
stop_y = top
|
|
339
|
+
|
|
340
|
+
candidates = sorted(regions, key=lambda region: (_top(region), _left(region)))
|
|
341
|
+
for region in candidates:
|
|
342
|
+
if region is label or id(region) in used_regions:
|
|
343
|
+
continue
|
|
344
|
+
top = _top(region)
|
|
345
|
+
if top < label_bottom - 4:
|
|
346
|
+
continue
|
|
347
|
+
if top - label_bottom > 320:
|
|
348
|
+
break
|
|
349
|
+
if stop_y is not None and top >= stop_y:
|
|
350
|
+
break
|
|
351
|
+
if abs(_left(region) - label_left) > 260:
|
|
352
|
+
continue
|
|
353
|
+
if _is_field_label(region.text) or _is_address_footer(region.text):
|
|
354
|
+
break
|
|
355
|
+
cleaned = _clean_scalar(region.text)
|
|
356
|
+
if cleaned and cleaned not in parts:
|
|
357
|
+
parts.append(cleaned)
|
|
358
|
+
if len(parts) >= 7:
|
|
359
|
+
break
|
|
360
|
+
|
|
361
|
+
if not parts:
|
|
362
|
+
return None
|
|
363
|
+
return ", ".join(parts)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _extract_issue_date(regions: list[TextRegion]) -> Optional[str]:
|
|
367
|
+
value = _labelled_value(
|
|
368
|
+
regions,
|
|
369
|
+
_ISSUE_DATE_LABELS,
|
|
370
|
+
_ISSUE_DATE_HINDI_LABELS,
|
|
371
|
+
)
|
|
372
|
+
if value is None:
|
|
373
|
+
return None
|
|
374
|
+
match = _DATE_RE.search(value)
|
|
375
|
+
return match.group(1) if match else None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _extract_pincode(
|
|
379
|
+
regions: list[TextRegion],
|
|
380
|
+
address: Optional[str],
|
|
381
|
+
) -> Optional[str]:
|
|
382
|
+
if address:
|
|
383
|
+
match = _PINCODE_RE.search(address)
|
|
384
|
+
if match:
|
|
385
|
+
return match.group(1)
|
|
386
|
+
|
|
387
|
+
value = _labelled_value(regions, _PINCODE_LABELS, _PINCODE_HINDI_LABELS)
|
|
388
|
+
if value:
|
|
389
|
+
match = _PINCODE_RE.search(value)
|
|
390
|
+
if match:
|
|
391
|
+
return match.group(1)
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def extract_npr_letter(regions: list[TextRegion]) -> NprLetterFields:
|
|
396
|
+
"""Extract labelled fields from an explicitly identified NPR letter.
|
|
397
|
+
|
|
398
|
+
Empty or non-NPR OCR input returns an empty dataclass. No field is inferred
|
|
399
|
+
from reading order alone.
|
|
400
|
+
"""
|
|
401
|
+
fields = NprLetterFields()
|
|
402
|
+
if not regions or not _has_npr_evidence(regions):
|
|
403
|
+
return fields
|
|
404
|
+
|
|
405
|
+
fields.reference_number = _extract_reference_number(regions)
|
|
406
|
+
fields.name = _extract_name(regions)
|
|
407
|
+
fields.address = _extract_address(regions)
|
|
408
|
+
fields.pincode = _extract_pincode(regions, fields.address)
|
|
409
|
+
fields.issue_date = _extract_issue_date(regions)
|
|
410
|
+
return fields
|