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,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