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,179 @@
1
+ """
2
+ Format and checksum validators for Indian KYC document identifiers.
3
+
4
+ Kept separate from the passport MRZ checksum (core/mrz_parser.icao_check_digit)
5
+ because these documents use different identifier schemes:
6
+
7
+ * PAN — fixed alphanumeric format (no checksum that is publicly verifiable)
8
+ * Aadhaar — 12 digits, last digit a Verhoeff checksum over the first 11
9
+ * EPIC — 3 letters + 7 digits (Voter ID)
10
+ * DL — state code + RTO + year + serial (format varies; loose check)
11
+ * NREGA — state-prefixed hierarchical job-card number (format only)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # PAN — Permanent Account Number
20
+ # Format: 5 letters + 4 digits + 1 letter, e.g. ABCDE1234F.
21
+ # The 4th letter encodes holder type (P=individual, C=company, ...).
22
+ # ---------------------------------------------------------------------------
23
+
24
+ _PAN_RE = re.compile(
25
+ r"(?<![A-Z0-9])(?:[A-Z][ \t-]*){5}(?:[0-9][ \t-]*){4}[A-Z](?![A-Z0-9])"
26
+ )
27
+ _PAN_HOLDER_TYPES = set("ABCFGHLJPTK")
28
+
29
+
30
+ def _extract_identifier(text: str, pattern: re.Pattern[str]) -> str | None:
31
+ """Extract a whole identifier, allowing OCR spaces and printed hyphens.
32
+
33
+ Matching before removing separators preserves token boundaries, so an
34
+ overlong identifier cannot be silently truncated into a valid one.
35
+ """
36
+ match = pattern.search(text.upper())
37
+ return re.sub(r"[ \t-]", "", match.group(0)) if match else None
38
+
39
+
40
+ def normalize_pan(text: str) -> str | None:
41
+ """Extract a PAN-shaped token from noisy OCR text, or None."""
42
+ return _extract_identifier(text, _PAN_RE)
43
+
44
+
45
+ def is_valid_pan(text: str) -> bool:
46
+ pan = normalize_pan(text)
47
+ if pan is None:
48
+ return False
49
+ return pan[3] in _PAN_HOLDER_TYPES
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # EPIC — Voter ID (Elector's Photo Identity Card)
54
+ # Format: 3 letters + 7 digits, e.g. ABC1234567.
55
+ # ---------------------------------------------------------------------------
56
+
57
+ _EPIC_RE = re.compile(
58
+ r"(?<![A-Z0-9])(?:[A-Z][ \t-]*){3}(?:[0-9][ \t-]*){6}[0-9](?![A-Z0-9])"
59
+ )
60
+
61
+
62
+ def normalize_epic(text: str) -> str | None:
63
+ return _extract_identifier(text, _EPIC_RE)
64
+
65
+
66
+ def is_valid_epic(text: str) -> bool:
67
+ return normalize_epic(text) is not None
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Driving Licence
72
+ # Format varies by state, but most follow: 2-letter state code, 2-digit RTO,
73
+ # optional space, then 11 digits (often YYYY + 7-digit serial), e.g.
74
+ # MH1220110012345, DL0420110149646, HR-06 19850034761.
75
+ # We accept a loose shape and surface the compact form.
76
+ # ---------------------------------------------------------------------------
77
+
78
+ _DL_RE = re.compile(
79
+ r"(?<![A-Z0-9])(?:[A-Z][ \t-]*){2}(?:[0-9][ \t-]*){12}[0-9](?![A-Z0-9])"
80
+ )
81
+
82
+
83
+ def normalize_dl(text: str) -> str | None:
84
+ return _extract_identifier(text, _DL_RE)
85
+
86
+
87
+ def is_valid_dl(text: str) -> bool:
88
+ return normalize_dl(text) is not None
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # NREGA / MGNREGA job-card number
93
+ # State-issued identifiers vary in component widths, but the common public
94
+ # form is a two-letter state prefix followed by at least three numeric
95
+ # hierarchy components, for example RJ-27-001-002-0008147/00.
96
+ # ---------------------------------------------------------------------------
97
+
98
+ _NREGA_JOB_CARD_RE = re.compile(
99
+ r"^[A-Z]{2}-[0-9]{1,10}(?:[-/][0-9]{1,10}){2,7}$"
100
+ )
101
+
102
+
103
+ def normalize_nrega_job_card(text: str) -> str | None:
104
+ """Return a canonical NREGA job-card number, or ``None``.
105
+
106
+ This is a conservative shape check, not an issuer lookup or authenticity
107
+ check. OCR character correction belongs in the extractor, where it can be
108
+ applied only to a labelled job-card-number candidate.
109
+ """
110
+ compact = text.upper().strip()
111
+ compact = compact.replace("–", "-").replace("—", "-")
112
+ compact = re.sub(r"\s+", "", compact)
113
+ return compact if _NREGA_JOB_CARD_RE.fullmatch(compact) else None
114
+
115
+
116
+ def is_valid_nrega_job_card(text: str) -> bool:
117
+ return normalize_nrega_job_card(text) is not None
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Aadhaar — 12 digits, 12th digit is a Verhoeff checksum over the first 11.
122
+ # ---------------------------------------------------------------------------
123
+
124
+ # Verhoeff algorithm tables (dihedral group D5).
125
+ _VERHOEFF_D = (
126
+ (0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
127
+ (1, 2, 3, 4, 0, 6, 7, 8, 9, 5),
128
+ (2, 3, 4, 0, 1, 7, 8, 9, 5, 6),
129
+ (3, 4, 0, 1, 2, 8, 9, 5, 6, 7),
130
+ (4, 0, 1, 2, 3, 9, 5, 6, 7, 8),
131
+ (5, 9, 8, 7, 6, 0, 4, 3, 2, 1),
132
+ (6, 5, 9, 8, 7, 1, 0, 4, 3, 2),
133
+ (7, 6, 5, 9, 8, 2, 1, 0, 4, 3),
134
+ (8, 7, 6, 5, 9, 3, 2, 1, 0, 4),
135
+ (9, 8, 7, 6, 5, 4, 3, 2, 1, 0),
136
+ )
137
+
138
+ _VERHOEFF_P = (
139
+ (0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
140
+ (1, 5, 7, 6, 2, 8, 3, 0, 9, 4),
141
+ (5, 8, 0, 3, 7, 9, 6, 1, 4, 2),
142
+ (8, 9, 1, 6, 0, 4, 3, 5, 2, 7),
143
+ (9, 4, 5, 3, 1, 2, 6, 8, 7, 0),
144
+ (4, 2, 8, 6, 5, 7, 3, 9, 0, 1),
145
+ (2, 7, 9, 3, 8, 0, 6, 4, 1, 5),
146
+ (7, 0, 4, 6, 9, 1, 3, 2, 5, 8),
147
+ )
148
+
149
+
150
+ def verhoeff_validate(number: str) -> bool:
151
+ """True if the digit string passes the Verhoeff checksum (check digit included)."""
152
+ if not number.isascii() or not number.isdigit():
153
+ return False
154
+ c = 0
155
+ for i, digit in enumerate(reversed(number)):
156
+ c = _VERHOEFF_D[c][_VERHOEFF_P[i % 8][int(digit)]]
157
+ return c == 0
158
+
159
+
160
+ _AADHAAR_DIGITS_RE = re.compile(r"\b([0-9]{4})[ \t]*([0-9]{4})[ \t]*([0-9]{4})\b")
161
+
162
+
163
+ def extract_aadhaar_number(text: str) -> str | None:
164
+ """Find a 12-digit Aadhaar number in text and return it grouped 'XXXX XXXX XXXX'."""
165
+ match = _AADHAAR_DIGITS_RE.search(text)
166
+ if not match:
167
+ return None
168
+ return f"{match.group(1)} {match.group(2)} {match.group(3)}"
169
+
170
+
171
+ def is_valid_aadhaar(text: str) -> bool:
172
+ grouped = extract_aadhaar_number(text)
173
+ if grouped is None:
174
+ return False
175
+ digits = grouped.replace(" ", "")
176
+ # Aadhaar never starts with 0 or 1.
177
+ if digits[0] in "01":
178
+ return False
179
+ return verhoeff_validate(digits)
@@ -0,0 +1,363 @@
1
+ """
2
+ Extractor for Indian Voter ID (EPIC / Elector's Photo Identity Card) cards.
3
+
4
+ Fields: EPIC number, elector name, relation (father's/husband's/mother's) name,
5
+ relation type, gender, date of birth or age.
6
+
7
+ Robust against the real-world variation seen on these cards:
8
+
9
+ * Old laminated cards and new PVC cards, bilingual Hindi/English labels.
10
+ * EPIC number anywhere on the card (validated via core.validators.normalize_epic).
11
+ * "Elector's Name" / "Name" for the holder vs "Father's Name" /
12
+ "Husband's Name" / "Mother's Name" for the relation — the two must never
13
+ collapse into one another.
14
+ * Value laid out to the right of the label ("Name : X") or below it.
15
+ * Gender given as M / F / Male / Female or Hindi पुरुष / महिला, possibly only
16
+ as a bare value with a Hindi-only ("लिंग") label.
17
+ * "Date of Birth" when present, otherwise "Age as on 1.1.YYYY" / "Age".
18
+
19
+ The EPIC number format is validated in core.validators. This module only reads
20
+ from core.validator / core.validators helpers — it does not mutate any shared
21
+ state.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from dataclasses import dataclass
28
+ from typing import Optional
29
+
30
+ from .ocr_engine import TextRegion
31
+ from .validator import (
32
+ find_label_value,
33
+ find_visual_field,
34
+ find_visual_value_near,
35
+ find_visual_value_right,
36
+ )
37
+ from .validators import normalize_epic
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Patterns
41
+ # ---------------------------------------------------------------------------
42
+
43
+ # dd/mm/yyyy (also tolerates '.' or '-' separators and 2-digit years).
44
+ _DATE_RE = re.compile(r"\b(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4})\b")
45
+
46
+ # "Age as on 1.1.2024 : 34" / "Age 34" / "Age : 34". We deliberately anchor on
47
+ # the word AGE and take the *last* small integer on the fragment so the year in
48
+ # "as on 1.1.2024" is never mistaken for the age.
49
+ _AGE_LABEL_RE = re.compile(r"\bAGE\b", re.IGNORECASE)
50
+ _SMALL_INT_RE = re.compile(r"\b(\d{1,3})\b")
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Label vocabularies (English; Hindi handled separately/spatially)
54
+ # ---------------------------------------------------------------------------
55
+
56
+ # Holder-name labels. Ordered most-specific first; find_visual_field scores
57
+ # exact/whole-phrase matches above generic substrings, so the specific
58
+ # "ELECTOR'S NAME" wins over a bare "NAME" when both are present.
59
+ _NAME_LABELS = [
60
+ "ELECTOR S NAME",
61
+ "ELECTORS NAME",
62
+ "ELECTOR NAME",
63
+ "NAME OF ELECTOR",
64
+ "NAME",
65
+ ]
66
+
67
+ # Relation labels split by type so we can report relation_type.
68
+ _FATHER_LABELS = [
69
+ "FATHER S NAME", "FATHERS NAME", "FATHER NAME", "NAME OF FATHER", "FATHER",
70
+ ]
71
+ _HUSBAND_LABELS = [
72
+ "HUSBAND S NAME", "HUSBANDS NAME", "HUSBAND NAME", "NAME OF HUSBAND", "HUSBAND",
73
+ ]
74
+ _MOTHER_LABELS = [
75
+ "MOTHER S NAME", "MOTHERS NAME", "MOTHER NAME", "NAME OF MOTHER", "MOTHER",
76
+ ]
77
+
78
+ _GENDER_LABELS = ["SEX", "GENDER"]
79
+ _DOB_LABELS = ["DATE OF BIRTH", "DOB"]
80
+ _AGE_LABELS = ["AGE AS ON", "AGE"]
81
+
82
+ # Tokens that indicate a region is a label / header, not a value, when we fall
83
+ # back to scanning for a bare gender value.
84
+ _GENDER_VALUE_TOKENS = {"MALE", "FEMALE", "TRANSGENDER", "M", "F", "T"}
85
+ _HINDI_MALE = "पुरुष"
86
+ _HINDI_FEMALE = "महिला"
87
+ _HINDI_TRANS = "अन्य"
88
+
89
+
90
+ @dataclass
91
+ class VoterIdFields:
92
+ epic_number: Optional[str] = None
93
+ name: Optional[str] = None
94
+ relation_name: Optional[str] = None
95
+ # NEW: "father" / "husband" / "mother" when the relation label is identifiable.
96
+ relation_type: Optional[str] = None
97
+ gender: Optional[str] = None
98
+ date_of_birth: Optional[str] = None
99
+ age: Optional[str] = None
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Small helpers
104
+ # ---------------------------------------------------------------------------
105
+
106
+ def _ascii_letters(text: str) -> str:
107
+ return re.sub(r"[^A-Z]", "", text.upper())
108
+
109
+
110
+ def _is_relation_label(text: str) -> bool:
111
+ """True if the region text looks like a father/husband/mother label."""
112
+ letters = _ascii_letters(text)
113
+ return any(tok in letters for tok in ("FATHER", "HUSBAND", "MOTHER"))
114
+
115
+
116
+ def _clean_value(text: Optional[str]) -> Optional[str]:
117
+ """Trim a value and strip a leading 'Label :' fragment / stray punctuation."""
118
+ if not text:
119
+ return None
120
+ value = text.strip()
121
+ # Drop a leading "... :" colon-prefixed label fragment that some OCR runs glue
122
+ # onto the value region (e.g. "Name : DEEPAK MEHTA").
123
+ if ":" in value:
124
+ tail = value.split(":")[-1].strip()
125
+ if tail:
126
+ value = tail
127
+ value = value.strip(" :,-")
128
+ return value or None
129
+
130
+
131
+ def _find_epic(regions: list[TextRegion]) -> Optional[str]:
132
+ """First EPIC-shaped token anywhere on the card."""
133
+ for region in regions:
134
+ epic = normalize_epic(region.text)
135
+ if epic:
136
+ return epic
137
+ return None
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Name vs relation disambiguation
142
+ # ---------------------------------------------------------------------------
143
+
144
+ def _find_holder_name(regions: list[TextRegion]) -> Optional[str]:
145
+ """Resolve the elector's own name without collapsing into the relation name.
146
+
147
+ `find_visual_field` matches a bare "NAME" against "FATHER'S NAME" too, so we
148
+ locate the label region ourselves and reject it if it is actually a relation
149
+ label, then read the value to its right (or below).
150
+ """
151
+ label = find_visual_field(regions, _NAME_LABELS)
152
+ if label is None:
153
+ return None
154
+ # If the only "NAME" match is a relation label, there is no usable holder
155
+ # name label — bail rather than returning the relation's value.
156
+ if _is_relation_label(label.text):
157
+ return None
158
+ value = find_label_value(regions, _NAME_LABELS)
159
+ if value is None:
160
+ return None
161
+ # Guard: the resolved value must not itself be a relation label or the
162
+ # relation value bleeding up/down.
163
+ if _is_relation_label(value):
164
+ return None
165
+ return _clean_value(value)
166
+
167
+
168
+ def _find_relation(regions: list[TextRegion]) -> tuple[Optional[str], Optional[str]]:
169
+ """Return (relation_name, relation_type) from father/husband/mother labels."""
170
+ for labels, rtype in (
171
+ (_FATHER_LABELS, "father"),
172
+ (_HUSBAND_LABELS, "husband"),
173
+ (_MOTHER_LABELS, "mother"),
174
+ ):
175
+ label = find_visual_field(regions, labels)
176
+ if label is None:
177
+ continue
178
+ # find_visual_field can match the wrong-family label via a shared "NAME"
179
+ # token; require the chosen label to actually contain the relation word.
180
+ if not _is_relation_label(label.text):
181
+ continue
182
+ letters = _ascii_letters(label.text)
183
+ if rtype.upper() not in letters:
184
+ continue
185
+ value = find_label_value(regions, labels)
186
+ if value is None:
187
+ continue
188
+ cleaned = _clean_value(value)
189
+ if cleaned:
190
+ return cleaned, rtype
191
+ return None, None
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Gender
196
+ # ---------------------------------------------------------------------------
197
+
198
+ def _normalise_gender(value: Optional[str]) -> Optional[str]:
199
+ if not value:
200
+ return None
201
+ text = value.strip()
202
+ upper = text.upper()
203
+ # Whole-word / substring checks on the ASCII form.
204
+ if "FEMALE" in upper:
205
+ return "FEMALE"
206
+ if "MALE" in upper:
207
+ return "MALE"
208
+ if "TRANSGENDER" in upper or "OTHER" in upper:
209
+ return "TRANSGENDER"
210
+ # Hindi.
211
+ if _HINDI_FEMALE in text:
212
+ return "FEMALE"
213
+ if _HINDI_MALE in text:
214
+ return "MALE"
215
+ if _HINDI_TRANS in text:
216
+ return "TRANSGENDER"
217
+ # Bare single-letter codes.
218
+ letters = _ascii_letters(text)
219
+ if letters == "F":
220
+ return "FEMALE"
221
+ if letters == "M":
222
+ return "MALE"
223
+ if letters == "T":
224
+ return "TRANSGENDER"
225
+ return None
226
+
227
+
228
+ def _scan_gender(regions: list[TextRegion]) -> Optional[str]:
229
+ """Fallback: find a region whose text *is* a gender value.
230
+
231
+ Used when the gender label is Hindi-only (so find_visual_field can't see it)
232
+ or the value sits without a recoverable English label.
233
+ """
234
+ for region in regions:
235
+ text = region.text.strip()
236
+ # Hindi value regions.
237
+ if _HINDI_FEMALE in text or _HINDI_MALE in text or _HINDI_TRANS in text:
238
+ g = _normalise_gender(text)
239
+ if g:
240
+ return g
241
+ letters = _ascii_letters(text)
242
+ if letters in _GENDER_VALUE_TOKENS:
243
+ g = _normalise_gender(text)
244
+ if g:
245
+ return g
246
+ return None
247
+
248
+
249
+ def _find_gender(regions: list[TextRegion]) -> Optional[str]:
250
+ # 1) Label-driven (Sex / Gender → value right/below).
251
+ labelled = _normalise_gender(find_label_value(regions, _GENDER_LABELS))
252
+ if labelled:
253
+ return labelled
254
+ # 2) Spatial: a "Sex"/"Gender" label whose value we read directly.
255
+ label = find_visual_field(regions, _GENDER_LABELS)
256
+ if label is not None:
257
+ value = find_visual_value_right(regions, label) or find_visual_value_near(
258
+ regions, label
259
+ )
260
+ if value is not None:
261
+ g = _normalise_gender(value.text)
262
+ if g:
263
+ return g
264
+ # 3) Content scan (Hindi-only label, or bare M/F/पुरुष/महिला token).
265
+ return _scan_gender(regions)
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # Date of birth / age
270
+ # ---------------------------------------------------------------------------
271
+
272
+ def _find_dob(regions: list[TextRegion]) -> Optional[str]:
273
+ """A dd/mm/yyyy date tied to a DOB label, else the first plausible date."""
274
+ labelled = find_label_value(regions, _DOB_LABELS)
275
+ if labelled:
276
+ m = _DATE_RE.search(labelled)
277
+ if m:
278
+ return m.group(1)
279
+ # Spatial read of the DOB label's neighbour (value may be a separate region).
280
+ label = find_visual_field(regions, _DOB_LABELS)
281
+ if label is not None:
282
+ value = find_visual_value_right(regions, label) or find_visual_value_near(
283
+ regions, label
284
+ )
285
+ if value is not None:
286
+ m = _DATE_RE.search(value.text)
287
+ if m:
288
+ return m.group(1)
289
+ return None
290
+
291
+
292
+ def _find_age(regions: list[TextRegion], joined: str) -> Optional[str]:
293
+ """Integer age from an 'Age'/'Age as on ...' label or fragment.
294
+
295
+ Resilient to "Age as on 1.1.2024 : 34" by ignoring the date-like year and
296
+ taking the trailing small integer; also handles a separate value region.
297
+ """
298
+ # Label-driven, value to the right / below.
299
+ label = find_visual_field(regions, _AGE_LABELS)
300
+ if label is not None and "AGE" in _ascii_letters(label.text):
301
+ value = find_visual_value_right(regions, label) or find_visual_value_near(
302
+ regions, label
303
+ )
304
+ if value is not None:
305
+ age = _age_from_fragment(value.text)
306
+ if age:
307
+ return age
308
+ # Age may be glued onto the label region itself ("Age : 34").
309
+ age = _age_from_fragment(label.text)
310
+ if age:
311
+ return age
312
+ # Whole-card fallback: scan each region containing the word AGE.
313
+ for region in regions:
314
+ if _AGE_LABEL_RE.search(region.text):
315
+ age = _age_from_fragment(region.text)
316
+ if age:
317
+ return age
318
+ # Last resort: the joined text (handles age split oddly across regions).
319
+ m = _AGE_LABEL_RE.search(joined)
320
+ if m:
321
+ age = _age_from_fragment(joined[m.start():])
322
+ if age:
323
+ return age
324
+ return None
325
+
326
+
327
+ def _age_from_fragment(text: str) -> Optional[str]:
328
+ """Extract an age integer from a fragment, skipping any embedded date.
329
+
330
+ "Age as on 1.1.2024 : 34" → "34"; "Age 34" → "34"; "34" → "34".
331
+ """
332
+ # Remove any dd/mm/yyyy-style date so its components aren't read as the age.
333
+ without_dates = _DATE_RE.sub(" ", text)
334
+ # Also remove bare 4-digit years that survive (e.g. "as on 2024").
335
+ without_years = re.sub(r"\b(19|20)\d{2}\b", " ", without_dates)
336
+ ints = _SMALL_INT_RE.findall(without_years)
337
+ for candidate in ints:
338
+ n = int(candidate)
339
+ if 1 <= n <= 120:
340
+ return candidate
341
+ return None
342
+
343
+
344
+ # ---------------------------------------------------------------------------
345
+ # Public API
346
+ # ---------------------------------------------------------------------------
347
+
348
+ def extract_voter_id(regions: list[TextRegion]) -> VoterIdFields:
349
+ fields = VoterIdFields()
350
+ joined = " ".join(r.text for r in regions if r.text.strip())
351
+
352
+ fields.epic_number = _find_epic(regions)
353
+ fields.name = _find_holder_name(regions)
354
+ fields.relation_name, fields.relation_type = _find_relation(regions)
355
+ fields.gender = _find_gender(regions)
356
+
357
+ dob = _find_dob(regions)
358
+ if dob:
359
+ fields.date_of_birth = dob
360
+ else:
361
+ fields.age = _find_age(regions, joined)
362
+
363
+ return fields
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ cd "$(dirname "$0")"
4
+ source ../common.sh
5
+ hosting_defaults
6
+ require_command node
7
+ require_command npm
8
+ if [[ ! -x node_modules/.bin/wrangler ]]; then npm ci --ignore-scripts --no-audit --no-fund; fi
9
+ if [[ -z "${IMAGE_URI:-}" ]]; then
10
+ require_command docker
11
+ docker buildx version >/dev/null 2>&1 || { printf 'Docker Buildx is required to build Cloudflare container images.\n' >&2; exit 1; }
12
+ fi
13
+ CONFIG=.wrangler.deploy.json
14
+ SECRETS_FILE=''
15
+ trap 'rm -f "$CONFIG"; if [[ -n "$SECRETS_FILE" ]]; then rm -f "$SECRETS_FILE"; fi' EXIT
16
+ if [[ -n "${API_TOKEN:-}" ]]; then
17
+ SECRETS_FILE="$(mktemp)"
18
+ chmod 600 "$SECRETS_FILE"
19
+ node --input-type=module - "$SECRETS_FILE" <<'JS'
20
+ import fs from 'node:fs'
21
+ fs.writeFileSync(process.argv[2], JSON.stringify({ API_TOKEN: process.env.API_TOKEN }))
22
+ JS
23
+ fi
24
+ node --input-type=module <<'JS'
25
+ import fs from 'node:fs'
26
+ const config = JSON.parse(fs.readFileSync('wrangler.jsonc', 'utf8'))
27
+ config.name = process.env.DOCUMENT_OCR_NAME
28
+ config.vars.DOCUMENT_OCR_KYC_LANGS = process.env.DOCUMENT_OCR_KYC_LANGS
29
+ config.vars.CONTAINER_IDLE_TIMEOUT = process.env.DOCUMENT_OCR_PROFILE === 'warm' ? '1h' : '10m'
30
+ config.vars.CONTAINER_INSTANCES = process.env.DOCUMENT_OCR_MAX_INSTANCES || '2'
31
+ const count = Number(config.vars.CONTAINER_INSTANCES)
32
+ if (!Number.isInteger(count) || count < 1 || count > 100) throw new Error('DOCUMENT_OCR_MAX_INSTANCES must be 1..100')
33
+ config.containers[0].max_instances = count
34
+ config.containers[0].image_vars.DOCUMENT_OCR_KYC_LANGS = process.env.DOCUMENT_OCR_KYC_LANGS
35
+ if (process.env.IMAGE_URI) config.containers[0].image = process.env.IMAGE_URI
36
+ fs.writeFileSync('.wrangler.deploy.json', JSON.stringify(config, null, 2))
37
+ JS
38
+ # A temporary mode-0600 file passes environment secrets without CLI-value leakage.
39
+ # Otherwise use existing Wrangler secrets; required-secret validation fails closed.
40
+ if [[ -n "$SECRETS_FILE" ]]; then
41
+ node_modules/.bin/wrangler deploy --config "$CONFIG" --secrets-file "$SECRETS_FILE" "$@"
42
+ else
43
+ node_modules/.bin/wrangler deploy --config "$CONFIG" "$@"
44
+ fi