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,492 @@
1
+ """
2
+ Spatial extraction of Indian passport back page fields.
3
+
4
+ Uses label→value-below-label relationships via bounding boxes,
5
+ making it format-agnostic across old and new passport generations.
6
+
7
+ Indian passport back page fields (consistent across all generations):
8
+ - Name of Father / Legal Guardian
9
+ - Name of Mother
10
+ - Name of Spouse
11
+ - Address
12
+ - File Number
13
+ - Old Passport Number / Date of Issue / Place of Issue (renewals only)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from dataclasses import dataclass
20
+ from typing import Optional
21
+
22
+ from .ocr_engine import TextRegion
23
+ from .validator import find_label_row_left_edge, find_visual_field, find_visual_value_near
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Result type
28
+ # ---------------------------------------------------------------------------
29
+
30
+ @dataclass
31
+ class BackPageFields:
32
+ father_name: Optional[str] = None
33
+ mother_name: Optional[str] = None
34
+ spouse_name: Optional[str] = None
35
+ address: Optional[str] = None
36
+ pincode: Optional[str] = None
37
+ city: Optional[str] = None
38
+ state: Optional[str] = None
39
+ file_number: Optional[str] = None
40
+ old_passport_number: Optional[str] = None
41
+ old_passport_date_of_issue: Optional[str] = None
42
+ old_passport_place_of_issue: Optional[str] = None
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Indian state codes for address parsing
47
+ # ---------------------------------------------------------------------------
48
+
49
+ _STATE_CODES: dict[str, str] = {
50
+ "AP": "Andhra Pradesh", "AR": "Arunachal Pradesh", "AS": "Assam",
51
+ "BR": "Bihar", "CG": "Chhattisgarh", "GA": "Goa", "GJ": "Gujarat",
52
+ "HR": "Haryana", "HP": "Himachal Pradesh", "JH": "Jharkhand",
53
+ "KA": "Karnataka", "KL": "Kerala", "MP": "Madhya Pradesh",
54
+ "MH": "Maharashtra", "MN": "Manipur", "ML": "Meghalaya",
55
+ "MZ": "Mizoram", "NL": "Nagaland", "OD": "Odisha", "OR": "Odisha",
56
+ "PB": "Punjab", "RJ": "Rajasthan", "SK": "Sikkim", "TN": "Tamil Nadu",
57
+ "TS": "Telangana", "TR": "Tripura", "UP": "Uttar Pradesh",
58
+ "UK": "Uttarakhand", "WB": "West Bengal",
59
+ "AN": "Andaman and Nicobar Islands", "CH": "Chandigarh",
60
+ "DN": "Dadra and Nagar Haveli and Daman and Diu",
61
+ "DD": "Dadra and Nagar Haveli and Daman and Diu",
62
+ "DL": "Delhi", "JK": "Jammu and Kashmir", "LA": "Ladakh",
63
+ "LD": "Lakshadweep", "PY": "Puducherry",
64
+ }
65
+
66
+ _STATE_NAMES_UPPER = {name.upper() for name in _STATE_CODES.values()}
67
+ _UPPER_TO_CANONICAL_STATE = {name.upper(): name for name in _STATE_CODES.values()}
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Label keywords (English + common OCR misreads)
72
+ # Bilingual Hindi/English labels appear on all Indian passport formats.
73
+ # OCR mostly reads the English portion; Hindi text is noise-filtered by
74
+ # the label matcher.
75
+ # ---------------------------------------------------------------------------
76
+
77
+ _FATHER_LABELS = [
78
+ "NAME OF FATHER", "FATHER LEGAL GUARDIAN", "NAME OF FATHER LEGAL GUARDIAN",
79
+ "FATHER", "LEGAL GUARDIAN",
80
+ ]
81
+
82
+ _MOTHER_LABELS = [
83
+ "NAME OF MOTHER", "MOTHER",
84
+ ]
85
+
86
+ _SPOUSE_LABELS = [
87
+ "NAME OF SPOUSE", "SPOUSE",
88
+ ]
89
+
90
+ _ADDRESS_LABELS = [
91
+ "ADDRESS",
92
+ ]
93
+
94
+ _FILE_NO_LABELS = [
95
+ "FILE NO", "FILE NUMBER", "FILE NO.",
96
+ ]
97
+
98
+ _OLD_PASSPORT_LABELS = [
99
+ "OLD PASSPORT NO", "OLD PASSPORT NUMBER", "PREVIOUS PASSPORT",
100
+ "OLD PASSPORT NO.", "OLD PASSPORT",
101
+ ]
102
+
103
+ _OLD_PASSPORT_DOI_LABELS = [
104
+ "DATE OF ISSUE",
105
+ ]
106
+
107
+ _OLD_PASSPORT_POI_LABELS = [
108
+ "PLACE OF ISSUE",
109
+ ]
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Multi-line collection
114
+ # ---------------------------------------------------------------------------
115
+
116
+ def _collect_multiline_value(
117
+ regions: list[TextRegion],
118
+ label_region: TextRegion,
119
+ *,
120
+ max_lines: int = 5,
121
+ max_y_gap: int = 50,
122
+ stop_labels: Optional[list[list[str]]] = None,
123
+ ) -> list[TextRegion]:
124
+ """Collect multiple value regions below a label (for address blocks).
125
+
126
+ Keeps collecting lines below the label until we hit a stop label
127
+ (anywhere on a row, regardless of horizontal alignment), exceed
128
+ max_lines, or the vertical gap is too large.
129
+ """
130
+ label_bottom = max(p[1] for p in label_region.bbox)
131
+ label_left = find_label_row_left_edge(regions, label_region)
132
+
133
+ # All stop-label keywords
134
+ stop_keywords: set[str] = set()
135
+ if stop_labels:
136
+ for label_group in stop_labels:
137
+ for kw in label_group:
138
+ stop_keywords.add(kw.upper())
139
+
140
+ def _is_stop_label(text: str) -> bool:
141
+ norm = re.sub(r"[^A-Z0-9 ]", "", text.upper()).strip()
142
+ return any(kw in norm for kw in stop_keywords)
143
+
144
+ # Group regions by row so we can stop on a row that contains a stop label
145
+ # *anywhere* (including columns far from the address column).
146
+ row_for_top: dict[int, list[TextRegion]] = {}
147
+ for region in regions:
148
+ if region is label_region:
149
+ continue
150
+ top = min(p[1] for p in region.bbox)
151
+ if top < label_bottom - 10:
152
+ continue
153
+ if top - label_bottom > 300:
154
+ continue
155
+ # bucket by ~half-line height (assume ≥ 12 px lines)
156
+ bucket = top // 12
157
+ row_for_top.setdefault(bucket, []).append(region)
158
+
159
+ sorted_rows = sorted(row_for_top.items())
160
+
161
+ collected: list[TextRegion] = []
162
+ prev_bottom = label_bottom
163
+ for _, row_regions in sorted_rows:
164
+ if len(collected) >= max_lines:
165
+ break
166
+ # Stop if any region in this row matches a stop label.
167
+ if any(_is_stop_label(r.text) for r in row_regions):
168
+ break
169
+ # Pick the value-aligned region(s) — same column as the label.
170
+ for region in row_regions:
171
+ left = min(p[0] for p in region.bbox)
172
+ if abs(left - label_left) >= 250:
173
+ continue
174
+ top = min(p[1] for p in region.bbox)
175
+ if top - prev_bottom > max_y_gap and collected:
176
+ break
177
+ text = region.text.strip()
178
+ if len(text) < 2:
179
+ continue
180
+ # Drop rows that are mostly non-Latin script (Hindi labels) — the
181
+ # address proper is in Latin script even when adjacent labels are
182
+ # bilingual.
183
+ if _is_mostly_non_latin(text):
184
+ continue
185
+ collected.append(region)
186
+ prev_bottom = max(p[1] for p in region.bbox)
187
+ if len(collected) >= max_lines:
188
+ break
189
+
190
+ return collected
191
+
192
+
193
+ def _is_mostly_non_latin(text: str) -> bool:
194
+ """True if more than half the alphabetic characters are non-ASCII."""
195
+ alpha = [c for c in text if c.isalpha()]
196
+ if not alpha:
197
+ return False
198
+ non_latin = sum(1 for c in alpha if ord(c) > 127)
199
+ return non_latin / len(alpha) > 0.5
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Address parsing
204
+ # ---------------------------------------------------------------------------
205
+
206
+ def _parse_address(address_regions: list[TextRegion]) -> tuple[str, Optional[str], Optional[str], Optional[str]]:
207
+ """Parse address regions into (full_address, pincode, city, state)."""
208
+ lines = [r.text.strip() for r in address_regions if r.text.strip()]
209
+ full_address = ", ".join(lines)
210
+
211
+ # Extract 6-digit pincode from anywhere in the address
212
+ pincode_match = re.search(r"\b(\d{6})\b", full_address)
213
+ pincode = pincode_match.group(1) if pincode_match else None
214
+
215
+ # First pass: comma-tokenized scan. Handles addresses where the state
216
+ # appears as its own token among other comma-separated tokens — e.g.
217
+ # "NEW MAHAVIR NAGAR,DELHI" or "PIN:110018,DELHI,INDIA".
218
+ state, city = _scan_state_and_city_from_tokens(full_address)
219
+
220
+ # Second pass (fallback): per-line regex patterns. These handle line-
221
+ # oriented formats where the state info is at the END of a single line.
222
+ if state is None or city is None:
223
+ for i in range(len(lines) - 1, -1, -1):
224
+ line = lines[i].strip()
225
+
226
+ # Pattern: "CITY - PINCODE STATE_ABBREV"
227
+ m = re.match(r"^([A-Za-z\s.]+?)[\s-]+\d{6}\s+([A-Z]{2,3})$", line)
228
+ if m and m.group(2).upper() in _STATE_CODES:
229
+ city = city or m.group(1).strip()
230
+ state = state or _STATE_CODES[m.group(2).upper()]
231
+ continue
232
+
233
+ # Pattern: "CITY PINCODE STATE_ABBREV"
234
+ m = re.match(r"^([A-Za-z\s.]+?)\s+(\d{6})\s+([A-Z]{2,3})$", line)
235
+ if m and m.group(3).upper() in _STATE_CODES:
236
+ city = city or m.group(1).strip()
237
+ state = state or _STATE_CODES[m.group(3).upper()]
238
+ continue
239
+
240
+ # Pattern: standalone full state name
241
+ if line.upper() in _STATE_NAMES_UPPER:
242
+ state = state or line.strip()
243
+ if i > 0 and not city:
244
+ city = lines[i - 1].strip()
245
+ continue
246
+
247
+ # Pattern: "STATE_FULLNAME - PINCODE" or "STATE_FULLNAME PINCODE"
248
+ m = re.match(r"^([A-Za-z\s]+?)[\s-]*\d{6}$", line)
249
+ if m and m.group(1).strip().upper() in _STATE_NAMES_UPPER:
250
+ state = state or m.group(1).strip()
251
+ if i > 0 and not city:
252
+ city = lines[i - 1].strip()
253
+ continue
254
+
255
+ # Pattern: "CITY - PINCODE" (state on a different line)
256
+ m = re.match(r"^([A-Za-z\s.]+?)[\s-]+\d{6}$", line)
257
+ if m and not city:
258
+ city = m.group(1).strip()
259
+ continue
260
+
261
+ # Normalise the resolved state to canonical title case regardless of which
262
+ # path set it (the per-line fallback patterns above capture the raw, often
263
+ # all-caps, OCR text). Keeps output consistent — e.g. "KARNATAKA" → "Karnataka".
264
+ if state and state.upper() in _UPPER_TO_CANONICAL_STATE:
265
+ state = _UPPER_TO_CANONICAL_STATE[state.upper()]
266
+
267
+ return full_address, pincode, city, state
268
+
269
+
270
+ def _scan_state_and_city_from_tokens(
271
+ full_address: str,
272
+ ) -> tuple[Optional[str], Optional[str]]:
273
+ """Tokenize the full address by punctuation and infer state + city.
274
+
275
+ The state is the first token that matches a known Indian state name or
276
+ 2-3 letter code. The city is the most recent preceding token that isn't
277
+ a state, country, or pincode — so for "NEW MAHAVIR NAGAR,DELHI,INDIA"
278
+ we get state=Delhi, city=NEW MAHAVIR NAGAR.
279
+ """
280
+ tokens: list[str] = []
281
+ for raw in re.split(r"[,;|/]", full_address):
282
+ tok = raw.strip()
283
+ # Strip "PIN:" / "PIN " prefixes commonly stuck on the pincode token
284
+ tok = re.sub(r"^PIN[\s:]*", "", tok, flags=re.IGNORECASE).strip()
285
+ if not tok or re.fullmatch(r"\d{4,6}", tok):
286
+ continue
287
+ tokens.append(tok)
288
+
289
+ # Map UPPER -> canonical title-case name so output is consistent
290
+ # whether the OCR caught the full name or just the 2-letter code.
291
+ upper_to_canonical = {name.upper(): name for name in _STATE_CODES.values()}
292
+
293
+ state: Optional[str] = None
294
+ state_idx = -1
295
+ for i, tok in enumerate(tokens):
296
+ upper = tok.upper()
297
+ if upper in upper_to_canonical:
298
+ state = upper_to_canonical[upper]
299
+ state_idx = i
300
+ break
301
+ if upper in _STATE_CODES:
302
+ state = _STATE_CODES[upper]
303
+ state_idx = i
304
+ break
305
+
306
+ city: Optional[str] = None
307
+ if state_idx > 0:
308
+ for j in range(state_idx - 1, -1, -1):
309
+ prev = tokens[j].strip()
310
+ prev_upper = prev.upper()
311
+ if prev_upper == "INDIA":
312
+ continue
313
+ if prev_upper in _STATE_NAMES_UPPER or prev_upper in _STATE_CODES:
314
+ continue
315
+ city = prev
316
+ break
317
+
318
+ return state, city
319
+
320
+
321
+ # ---------------------------------------------------------------------------
322
+ # Public API
323
+ # ---------------------------------------------------------------------------
324
+
325
+ def extract_back_page(regions: list[TextRegion]) -> BackPageFields:
326
+ """Extract structured fields from a passport back page using spatial layout.
327
+
328
+ Uses label→value-below relationships via bounding boxes. This approach
329
+ is format-agnostic and works across old and new Indian passport generations
330
+ because the field labels are consistent even though the visual layout varies.
331
+ """
332
+ fields = BackPageFields()
333
+
334
+ # --- Single-value fields (find label, get value below) ---
335
+ father_label = find_visual_field(regions, _FATHER_LABELS)
336
+ if father_label:
337
+ val = find_visual_value_near(regions, father_label)
338
+ if val:
339
+ fields.father_name = val.text.strip()
340
+
341
+ mother_label = find_visual_field(regions, _MOTHER_LABELS)
342
+ if mother_label:
343
+ val = find_visual_value_near(regions, mother_label)
344
+ if val:
345
+ fields.mother_name = val.text.strip()
346
+
347
+ spouse_label = find_visual_field(regions, _SPOUSE_LABELS)
348
+ if spouse_label:
349
+ val = find_visual_value_near(regions, spouse_label)
350
+ if val:
351
+ fields.spouse_name = val.text.strip()
352
+
353
+ file_label = find_visual_field(regions, _FILE_NO_LABELS)
354
+ if file_label:
355
+ val = find_visual_value_near(regions, file_label)
356
+ if val:
357
+ fields.file_number = val.text.strip()
358
+
359
+ # --- Address (multi-line) ---
360
+ address_label = find_visual_field(regions, _ADDRESS_LABELS)
361
+ if address_label:
362
+ # Collect lines below "Address" until we hit the next section
363
+ address_regions = _collect_multiline_value(
364
+ regions, address_label,
365
+ max_lines=6,
366
+ stop_labels=[_FILE_NO_LABELS, _OLD_PASSPORT_LABELS],
367
+ )
368
+ if address_regions:
369
+ full_address, pincode, city, state = _parse_address(address_regions)
370
+ fields.address = full_address
371
+ fields.pincode = pincode
372
+ fields.city = city
373
+ fields.state = state
374
+
375
+ # --- Old passport (renewals) ---
376
+ # The Indian passport has a single compound label "Old Passport No. with
377
+ # Date and Place of Issue" sitting above THREE columnar values:
378
+ # passport_number | date_of_issue | place_of_issue. So instead of running
379
+ # `find_visual_value_near` separately for each, we collect the whole row
380
+ # below the label and assign by content shape.
381
+ old_pp_label = find_visual_field(regions, _OLD_PASSPORT_LABELS)
382
+ if old_pp_label:
383
+ pp_no, doi, poi = _extract_old_passport_row(regions, old_pp_label)
384
+ fields.old_passport_number = pp_no
385
+ fields.old_passport_date_of_issue = doi
386
+ fields.old_passport_place_of_issue = poi
387
+
388
+ # Fall back to the separate-label path if we did not find the
389
+ # passport number in the columnar row (e.g. layouts where the labels
390
+ # are split into separate regions).
391
+ if fields.old_passport_number is None:
392
+ val = find_visual_value_near(regions, old_pp_label)
393
+ if val:
394
+ pp_match = re.search(r"[A-Z]\d{7}", val.text.upper())
395
+ fields.old_passport_number = pp_match.group(0) if pp_match else val.text.strip()
396
+ if fields.old_passport_date_of_issue is None:
397
+ old_doi_label = find_visual_field(regions, _OLD_PASSPORT_DOI_LABELS)
398
+ if old_doi_label and old_doi_label is not old_pp_label:
399
+ old_pp_bottom = max(p[1] for p in old_pp_label.bbox)
400
+ doi_top = min(p[1] for p in old_doi_label.bbox)
401
+ if doi_top > old_pp_bottom - 30:
402
+ val = find_visual_value_near(regions, old_doi_label)
403
+ if val:
404
+ fields.old_passport_date_of_issue = val.text.strip()
405
+ if fields.old_passport_place_of_issue is None:
406
+ old_poi_label = find_visual_field(regions, _OLD_PASSPORT_POI_LABELS)
407
+ if old_poi_label and old_poi_label is not old_pp_label:
408
+ old_pp_bottom = max(p[1] for p in old_pp_label.bbox)
409
+ poi_top = min(p[1] for p in old_poi_label.bbox)
410
+ if poi_top > old_pp_bottom - 30:
411
+ val = find_visual_value_near(regions, old_poi_label)
412
+ if val:
413
+ fields.old_passport_place_of_issue = val.text.strip()
414
+
415
+ return fields
416
+
417
+
418
+ # ---------------------------------------------------------------------------
419
+ # Old-passport row extraction (3 columns: passport_no | date | place_of_issue)
420
+ # ---------------------------------------------------------------------------
421
+
422
+ _PASSPORT_NUMBER_RE = re.compile(r"[A-Z]\d{7}")
423
+ _DATE_RE = re.compile(r"^\s*\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4}\s*$")
424
+
425
+
426
+ _OLD_PASSPORT_ROW_LABEL_KEYWORDS = {"FILE", "ADDRESS", "FATHER", "MOTHER", "SPOUSE", "GUARDIAN"}
427
+
428
+
429
+ def _extract_old_passport_row(
430
+ regions: list[TextRegion],
431
+ old_pp_label: TextRegion,
432
+ ) -> tuple[Optional[str], Optional[str], Optional[str]]:
433
+ """Pull (passport_no, date_of_issue, place_of_issue) from the row directly
434
+ below the compound Old-Passport label.
435
+
436
+ The Indian passport back page (and several other formats) places these
437
+ three fields in a single row under one wide label. Treating them as
438
+ independent label/value pairs fails because there are no separate sub-
439
+ labels — the three values must be classified by their content shape:
440
+ a passport number, a date, and a place name.
441
+ """
442
+ label_top = min(p[1] for p in old_pp_label.bbox)
443
+ label_bottom = max(p[1] for p in old_pp_label.bbox)
444
+ label_center_y = (label_top + label_bottom) // 2
445
+
446
+ # Use the label's vertical center as the lower bound, not the bottom —
447
+ # OCR bboxes for labels are routinely taller than the visible glyphs, so
448
+ # the value row often starts a few pixels above the label_bottom while
449
+ # still being clearly the next form row.
450
+ min_y = label_center_y
451
+ max_y = label_bottom + 60
452
+
453
+ row_regions: list[TextRegion] = []
454
+ for region in regions:
455
+ if region is old_pp_label:
456
+ continue
457
+ top = min(p[1] for p in region.bbox)
458
+ if not (min_y <= top <= max_y):
459
+ continue
460
+ text = region.text.strip()
461
+ if len(text) < 2 or _is_mostly_non_latin(text):
462
+ continue
463
+ # Skip neighbouring labels (File No., the next row's bilingual prefix, etc.)
464
+ normalised_words = set(re.sub(r"[^A-Z]+", " ", text.upper()).split())
465
+ if normalised_words & _OLD_PASSPORT_ROW_LABEL_KEYWORDS:
466
+ continue
467
+ row_regions.append(region)
468
+
469
+ if not row_regions:
470
+ return None, None, None
471
+
472
+ row_regions.sort(key=lambda r: min(p[0] for p in r.bbox))
473
+
474
+ pp_no: Optional[str] = None
475
+ doi: Optional[str] = None
476
+ poi: Optional[str] = None
477
+
478
+ for region in row_regions:
479
+ text = region.text.strip()
480
+ upper = text.upper()
481
+ # Passport number must MATCH the whole region exactly — otherwise we
482
+ # would slice "L2072369" out of file-number text like "DL2072369058018".
483
+ if pp_no is None and _PASSPORT_NUMBER_RE.fullmatch(upper):
484
+ pp_no = upper
485
+ continue
486
+ if doi is None and _DATE_RE.match(text):
487
+ doi = text
488
+ continue
489
+ if poi is None and not any(ch.isdigit() for ch in text):
490
+ poi = text
491
+
492
+ return pp_no, doi, poi
@@ -0,0 +1,125 @@
1
+ """Local barcode decoding and bounded AAMVA PDF417 payload parsing.
2
+
3
+ Annex D of the AAMVA 2025 DL/ID standard defines the header/subfile directory.
4
+ Decoding a barcode establishes neither issuer authenticity nor a signature.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib.util
10
+ import re
11
+ from dataclasses import dataclass, field
12
+
13
+ import numpy as np
14
+
15
+
16
+ @dataclass
17
+ class BarcodeResult:
18
+ format: str
19
+ text: str
20
+ bbox: list[list[int]]
21
+ raw_bytes: bytes = b""
22
+
23
+
24
+ def barcode_decoder_available() -> bool:
25
+ return importlib.util.find_spec("zxingcpp") is not None
26
+
27
+
28
+ def decode_barcodes(image: np.ndarray) -> list[BarcodeResult]:
29
+ """Decode PDF417/other barcodes locally; an optional missing backend yields []."""
30
+ if not barcode_decoder_available():
31
+ return []
32
+ import zxingcpp
33
+
34
+ # OCR images are BGR. Grayscale conversion also makes channel ordering
35
+ # irrelevant to the native decoder and avoids an additional cv2 dependency.
36
+ if image.ndim == 3:
37
+ image = np.mean(image[:, :, :3], axis=2).astype(np.uint8)
38
+ results = []
39
+ for barcode in zxingcpp.read_barcodes(np.ascontiguousarray(image)):
40
+ if not barcode.valid:
41
+ continue
42
+ position = barcode.position
43
+ bbox = [[int(point.x), int(point.y)] for point in (position.top_left, position.top_right, position.bottom_right, position.bottom_left)]
44
+ results.append(BarcodeResult(str(barcode.format), barcode.text, bbox, bytes(barcode.bytes)))
45
+ return results
46
+
47
+
48
+ decode = decode_barcodes
49
+
50
+
51
+ @dataclass
52
+ class AAMVAResult:
53
+ document_type: str
54
+ issuer_id: str
55
+ version: int
56
+ jurisdiction_version: int
57
+ elements: dict[str, str]
58
+ errors: list[str] = field(default_factory=list)
59
+
60
+
61
+ _HEADER = re.compile(rb"@\n\x1e\rANSI ([0-9]{6})([0-9]{2})([0-9]{2})([0-9]{2})")
62
+
63
+
64
+ def parse_aamva(barcode: BarcodeResult | str) -> AAMVAResult | None:
65
+ """Parse directory offsets against original bytes, not stripped OCR text.
66
+
67
+ None means this is not an AAMVA payload. Recognizable malformed payloads
68
+ raise ValueError so callers can surface corrupt barcode evidence.
69
+ """
70
+ try:
71
+ if isinstance(barcode, str):
72
+ if not barcode.startswith("@") or "ANSI " not in barcode[:16]:
73
+ return None
74
+ data = barcode.encode("latin-1")
75
+ else:
76
+ if not barcode.raw_bytes and (not barcode.text.startswith("@") or "ANSI " not in barcode.text[:16]):
77
+ return None
78
+ data = barcode.raw_bytes or barcode.text.encode("latin-1")
79
+ except UnicodeEncodeError:
80
+ raise ValueError("INVALID_AAMVA_ENCODING") from None
81
+ if not data.startswith(b"@") or b"ANSI " not in data[:16]:
82
+ return None
83
+ header = _HEADER.match(data)
84
+ if header is None:
85
+ raise ValueError("INVALID_AAMVA_HEADER")
86
+ issuer, version, jurisdiction, count = header.groups()
87
+ version_number = int(version)
88
+ if not 1 <= version_number <= 11:
89
+ raise ValueError("UNSUPPORTED_AAMVA_VERSION")
90
+ count_number = int(count)
91
+ directory_end = header.end() + 10 * count_number
92
+ if not count_number or directory_end > len(data):
93
+ raise ValueError("INVALID_AAMVA_DIRECTORY")
94
+ subfiles: list[tuple[str, int, int]] = []
95
+ for index in range(count_number):
96
+ entry = data[header.end() + 10 * index:header.end() + 10 * (index + 1)]
97
+ if not re.fullmatch(rb"[A-Z]{2}[0-9]{8}", entry):
98
+ raise ValueError("INVALID_AAMVA_DIRECTORY")
99
+ kind, offset, length = entry[:2].decode("ascii"), int(entry[2:6]), int(entry[6:10])
100
+ end = offset + length
101
+ if offset < directory_end or length < 2 or end > len(data) or data[offset:offset + 2] != entry[:2]:
102
+ raise ValueError("INVALID_AAMVA_SUBFILE_BOUNDS")
103
+ if any(offset < old_end and start < end for _, start, old_end in subfiles):
104
+ raise ValueError("OVERLAPPING_AAMVA_SUBFILES")
105
+ subfiles.append((kind, offset, end))
106
+ identities = [(kind, start, end) for kind, start, end in subfiles if kind in ("DL", "ID")]
107
+ if len(identities) != 1:
108
+ raise ValueError("AMBIGUOUS_AAMVA_DOCUMENT_TYPE")
109
+ kind, start, end = identities[0]
110
+ # Annex D.6 specifies ISO 8859-1. Offsets count bytes, including accented
111
+ # Latin characters; UTF-8 encoding would shift the subfile boundaries.
112
+ content = data[start + 2:end].decode("latin-1")
113
+ elements: dict[str, str] = {}
114
+ errors: list[str] = []
115
+ for record in re.split(r"[\r\n]", content):
116
+ if not record:
117
+ continue
118
+ if not re.fullmatch(r"[A-Z]{3}[ -~\xa0-\xff]*", record):
119
+ raise ValueError("INVALID_AAMVA_ELEMENT")
120
+ key, value = record[:3], record[3:].strip()
121
+ if key in elements and elements[key] != value:
122
+ errors.append(f"CONFLICTING_AAMVA_ELEMENT_{key}")
123
+ else:
124
+ elements[key] = value
125
+ return AAMVAResult("us_driver_license" if kind == "DL" else "us_state_id", issuer.decode("ascii"), version_number, int(jurisdiction), elements, errors)