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,135 @@
1
+ """Bounded PDF page loading and native text geometry in rendered-page coordinates.
2
+
3
+ PDFium is not thread-safe. Every PDF operation, including object disposal, runs
4
+ under the same process-wide lock. Native text is extraction evidence, not a
5
+ claim that a PDF is authentic or that its hidden text matches its visible ink.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ import threading
12
+ import math
13
+
14
+ import cv2
15
+ import numpy as np
16
+
17
+ from .ocr_engine import TextRegion
18
+
19
+ PDF_LOCK = threading.RLock()
20
+ MAX_PAGES = 10
21
+ MAX_PAGE_PIXELS = 20_000_000
22
+ MAX_DOCUMENT_PIXELS = 40_000_000
23
+ MAX_TEXT_CHARS = 100_000
24
+ PDF_WIDTH = 1600
25
+
26
+
27
+ @dataclass
28
+ class InputPage:
29
+ image: np.ndarray
30
+ regions: list[TextRegion]
31
+ number: int
32
+ total: int
33
+
34
+
35
+ def input_bytes(source: str | bytes | Path) -> bytes:
36
+ return Path(source).read_bytes() if isinstance(source, (str, Path)) else source
37
+
38
+
39
+ def pdf_pages(data: bytes, *, first_only: bool = False) -> list[InputPage]:
40
+ from .preprocessor import ImageQualityError
41
+ import pypdfium2 as pdfium
42
+
43
+ with PDF_LOCK:
44
+ try:
45
+ doc = pdfium.PdfDocument(data)
46
+ except pdfium.PdfiumError as exc:
47
+ raise ImageQualityError('INVALID_PDF') from exc
48
+ try:
49
+ total = len(doc)
50
+ if not total:
51
+ raise ImageQualityError('PDF_HAS_NO_PAGES')
52
+ if total > MAX_PAGES and not first_only:
53
+ raise ImageQualityError('TOO_MANY_PAGES')
54
+ output = []
55
+ total_pixels = 0
56
+ for index in range(1 if first_only else total):
57
+ page = doc[index]
58
+ textpage = bitmap = None
59
+ try:
60
+ width, height = page.get_size()
61
+ if width <= 0 or height <= 0:
62
+ raise ImageQualityError('INVALID_PDF_PAGE_SIZE')
63
+ scale = PDF_WIDTH / width
64
+ pixels = math.ceil(width * scale) * math.ceil(height * scale)
65
+ if pixels > MAX_PAGE_PIXELS:
66
+ raise ImageQualityError('PDF_PAGE_TOO_LARGE')
67
+ total_pixels += pixels
68
+ if total_pixels > MAX_DOCUMENT_PIXELS:
69
+ raise ImageQualityError('PDF_DOCUMENT_TOO_LARGE')
70
+ bitmap = page.render(scale=scale)
71
+ image = cv2.cvtColor(np.array(bitmap.to_pil().convert('RGB')), cv2.COLOR_RGB2BGR)
72
+ textpage = page.get_textpage()
73
+ regions = _native_regions(textpage, bitmap.get_posconv(page), image.shape[1], image.shape[0])
74
+ # Character boxes are in unrotated PDF space. On a rotated
75
+ # page fall back to rendered OCR until its transform is known.
76
+ if page.get_rotation():
77
+ regions = []
78
+ output.append(InputPage(image, regions, index + 1, total))
79
+ finally:
80
+ if textpage is not None:
81
+ textpage.close()
82
+ if bitmap is not None:
83
+ bitmap.close()
84
+ page.close()
85
+ return output
86
+ except pdfium.PdfiumError as exc:
87
+ raise ImageQualityError('INVALID_PDF') from exc
88
+ finally:
89
+ doc.close()
90
+
91
+
92
+ def _native_regions(textpage, converter, width: int, height: int) -> list[TextRegion]:
93
+ from .preprocessor import ImageQualityError
94
+ count = textpage.count_chars()
95
+ if count > MAX_TEXT_CHARS:
96
+ raise ImageQualityError('PDF_TEXT_TOO_LARGE')
97
+ regions, chars, boxes = [], [], []
98
+ line_top = line_bottom = None
99
+
100
+ def flush():
101
+ nonlocal line_top, line_bottom
102
+ text = ''.join(chars).strip()
103
+ if text and boxes:
104
+ x0, y0 = min(b[0] for b in boxes), min(b[1] for b in boxes)
105
+ x1, y1 = max(b[2] for b in boxes), max(b[3] for b in boxes)
106
+ regions.append(TextRegion(text, [[x0,y0],[x1,y0],[x1,y1],[x0,y1]], 1.0))
107
+ chars.clear()
108
+ boxes.clear()
109
+ line_top = line_bottom = None
110
+
111
+ for index in range(count):
112
+ char = textpage.get_text_range(index, 1)
113
+ if not char or char in '\r\n':
114
+ flush()
115
+ continue
116
+ if char.isspace():
117
+ chars.append(' ')
118
+ continue
119
+ left, bottom, right, top = textpage.get_charbox(index)
120
+ # PDFium's own transform handles CropBox/MediaBox offsets and bitmap
121
+ # rounding. Scaling page coordinates ourselves misaligns redaction.
122
+ corners = [converter.to_bitmap(x, y) for x, y in
123
+ ((left, bottom), (right, bottom), (right, top), (left, top))]
124
+ box = [max(0, min(p[0] for p in corners)), max(0, min(p[1] for p in corners)),
125
+ min(width, max(p[0] for p in corners)), min(height, max(p[1] for p in corners))]
126
+ if box[0] >= box[2] or box[1] >= box[3]:
127
+ continue
128
+ if boxes and (box[1] > line_bottom+3 or box[3] < line_top-3 or box[0] < boxes[-1][0]-5):
129
+ flush()
130
+ chars.append(char)
131
+ boxes.append(box)
132
+ line_top = min(line_top, box[1]) if line_top is not None else box[1]
133
+ line_bottom = max(line_bottom, box[3]) if line_bottom is not None else box[3]
134
+ flush()
135
+ return regions
@@ -0,0 +1,143 @@
1
+ """Explicit document capabilities and normalized country/type hints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import pycountry
8
+
9
+
10
+ # ISO 3166-1 plus Doc 9303 Part 3, section 5 A–G (2024 consolidated edition):
11
+ # https://www.icao.int/sites/default/files/publications/DocSeries/9303_p3_cons_en.pdf
12
+ # Includes unspecified nationality XXX and deprecated codes for old documents.
13
+ # Part H's IAO signs ICAO master lists; it is not a travel-document country.
14
+ MRZ_COUNTRY_CODES = frozenset(country.alpha_3 for country in pycountry.countries) | {
15
+ "D", "GBD", "GBN", "GBO", "GBP", "GBS", "RKS", "EUE",
16
+ "UNO", "UNA", "UNK", "XBA", "XIM", "XCC", "XCE", "XCO", "XEC",
17
+ "XPO", "XES", "XMP", "XOM", "XDC", "XXA", "XXB", "XXC", "XXX",
18
+ "ANT", "NTZ", "UTO",
19
+ }
20
+
21
+
22
+ def is_known_mrz_country(code: str | None) -> bool:
23
+ """Validate an observed MRZ code without substituting a different code."""
24
+ return isinstance(code, str) and code.upper().rstrip("<") in MRZ_COUNTRY_CODES
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class DocumentProfile:
29
+ document_type: str
30
+ name: str
31
+ countries: tuple[str, ...]
32
+ fields: tuple[str, ...]
33
+ required_fields: tuple[str, ...]
34
+ extraction_methods: tuple[str, ...]
35
+ experimental: bool = True
36
+ legacy: bool = False
37
+
38
+
39
+ _IDENTITY = ("surname", "given_names", "date_of_birth", "document_number", "expiry_date")
40
+ _ADDRESS = ("address", "city", "state", "postal_code")
41
+ _MRZ = ("country_code", "mrz_raw")
42
+ _PROFILES = (
43
+ DocumentProfile("passport", "Passport book", ("*",), ("surname", "given_names", "full_name", "passport_number", "nationality", "date_of_birth", "sex", "expiry_date", "issue_date", "place_of_birth", "country_code"), ("surname", "passport_number"), ("mrz", "ocr"), False, True),
44
+ DocumentProfile("pan", "Indian PAN card", ("IN",), ("pan_number", "name", "father_name", "date_of_birth"), ("pan_number", "name", "date_of_birth"), ("ocr",), False, True),
45
+ DocumentProfile("aadhaar", "Indian Aadhaar card", ("IN",), ("aadhaar_number", "name", "date_of_birth", "year_of_birth", "gender", "address", "pincode", "checksum_valid", "aadhaar_masked", "aadhaar_last4", "vid"), ("aadhaar_number_or_masked_last4", "name", "date_or_year_of_birth", "gender"), ("ocr",), False, True),
46
+ DocumentProfile("driving_licence", "Indian driving licence", ("IN",), ("dl_number", "name", "date_of_birth", "issue_date", "validity_date", "address", "relation_name", "blood_group", "class_of_vehicle", "validity_date_transport"), ("dl_number", "name", "date_of_birth", "issue_date", "validity_date"), ("ocr",), False, True),
47
+ DocumentProfile("voter_id", "Indian voter ID", ("IN",), ("epic_number", "name", "relation_name", "relation_type", "gender", "date_of_birth", "age"), ("epic_number", "name", "date_of_birth_or_age"), ("ocr",), False, True),
48
+ DocumentProfile("nrega_job_card", "Indian NREGA job card", ("IN",), ("job_card_number", "head_of_household", "category", "registration_date", "validity_from", "validity_to", "address", "village", "gram_panchayat", "block", "district", "state", "bpl_status", "family_id", "members"), ("job_card_number", "household_or_member_name", "location"), ("ocr",), True, True),
49
+ DocumentProfile("npr_letter", "Indian NPR letter", ("IN",), ("reference_number", "name", "address", "pincode", "issue_date"), ("name", "address"), ("ocr",), True, True),
50
+ DocumentProfile("us_driver_license", "US driver license", ("US",), _IDENTITY + _ADDRESS + ("first_name", "middle_names", "issue_date", "sex", "license_class", "restrictions", "endorsements"), ("surname", "document_number", "date_of_birth", "expiry_date"), ("pdf417", "ocr")),
51
+ DocumentProfile("us_state_id", "US state identification card", ("US",), _IDENTITY + _ADDRESS + ("first_name", "middle_names", "issue_date", "sex"), ("surname", "document_number", "date_of_birth", "expiry_date"), ("pdf417", "ocr")),
52
+ DocumentProfile("passport_card", "Passport card", ("*",), _IDENTITY + ("nationality", "sex", "issue_date", "address") + _MRZ, ("surname", "document_number", "date_of_birth", "expiry_date"), ("mrz", "ocr")),
53
+ DocumentProfile("us_green_card", "US Permanent Resident Card (I-551)", ("US",), ("surname", "given_names", "uscis_number", "card_number", "date_of_birth", "expiry_date", "category", "resident_since", "country_of_birth", "sex") + _MRZ, ("surname", "uscis_number", "card_number", "date_of_birth"), ("mrz", "ocr")),
54
+ DocumentProfile("us_ead", "US Employment Authorization Document (I-766)", ("US",), ("surname", "given_names", "uscis_number", "card_number", "date_of_birth", "expiry_date", "category", "valid_from", "country_of_birth", "sex") + _MRZ, ("surname", "uscis_number", "card_number", "date_of_birth", "expiry_date"), ("mrz", "ocr")),
55
+ DocumentProfile("visa", "Machine-readable visa (MRV-A / MRV-B)", ("*",), _IDENTITY + ("nationality", "sex", "mrz_format") + _MRZ, ("surname", "document_number", "date_of_birth", "expiry_date"), ("mrz", "ocr")),
56
+ DocumentProfile("us_i94", "US I-94 arrival/departure record", ("US",), ("i94_number", "surname", "given_names", "date_of_birth", "admission_date", "admit_until", "class_of_admission", "passport_number", "country_of_citizenship"), ("i94_number", "surname", "class_of_admission", "admit_until"), ("ocr",)),
57
+ DocumentProfile("us_w9", "US Form W-9", ("US",), ("name", "business_name", "taxpayer_id", "taxpayer_id_type", "address", "city_state_postal_code"), ("name", "taxpayer_id", "taxpayer_id_type"), ("ocr",)),
58
+ )
59
+ DOCUMENT_PROFILES = {profile.document_type: profile for profile in _PROFILES}
60
+
61
+
62
+ def normalize_country(country: str | None) -> str | None:
63
+ if country is None:
64
+ return None
65
+ if not isinstance(country, str):
66
+ raise ValueError("INVALID_COUNTRY_HINT_TYPE")
67
+ value = country.strip().upper()
68
+ aliases = {"USA": "US", "UNITED STATES": "US", "UNITED STATES OF AMERICA": "US", "IND": "IN", "INDIA": "IN", "UK": "GB"}
69
+ value = aliases.get(value, value)
70
+ result = pycountry.countries.get(alpha_2=value) if len(value) == 2 else pycountry.countries.get(alpha_3=value)
71
+ if result is None:
72
+ raise ValueError(f"UNSUPPORTED_COUNTRY: {country}")
73
+ return result.alpha_2
74
+
75
+
76
+ def validate_document_hint(document_type: str | None, country: str | None = None) -> tuple[str | None, str | None]:
77
+ """Return canonical hints or fail explicitly instead of ignoring a conflict."""
78
+ country = normalize_country(country)
79
+ if document_type is None:
80
+ return None, country
81
+ if not isinstance(document_type, str):
82
+ raise ValueError("INVALID_DOCUMENT_TYPE_HINT_TYPE")
83
+ value = document_type.strip().lower().replace("-", "_").replace(" ", "_")
84
+ aliases = {
85
+ "driver_license": "driving_licence", "driving_license": "driving_licence",
86
+ "drivers_license": "driving_licence", "state_id": "us_state_id",
87
+ "green_card": "us_green_card", "i551": "us_green_card", "i_551": "us_green_card",
88
+ "ead": "us_ead", "i766": "us_ead", "i_766": "us_ead",
89
+ "i94": "us_i94", "i_94": "us_i94", "w9": "us_w9", "w_9": "us_w9",
90
+ "us_passport": "passport", "passport_book": "passport",
91
+ }
92
+ value = aliases.get(value, value)
93
+ if value == "driving_licence" and country == "US":
94
+ value = "us_driver_license"
95
+ profile = DOCUMENT_PROFILES.get(value)
96
+ if profile is None:
97
+ raise ValueError(f"UNSUPPORTED_DOCUMENT_TYPE: {document_type}")
98
+ if country is not None and "*" not in profile.countries and country not in profile.countries:
99
+ raise ValueError(f"DOCUMENT_COUNTRY_MISMATCH: {value}/{country}")
100
+ if country is None and len(profile.countries) == 1 and profile.countries[0] != "*":
101
+ country = profile.countries[0]
102
+ return value, country
103
+
104
+
105
+ def list_document_profiles() -> list[dict]:
106
+ """Return JSON-safe metadata, including experimental support boundaries."""
107
+ def camel(value: str) -> str:
108
+ head, *tail = value.split("_")
109
+ return head + "".join(part.title() for part in tail)
110
+
111
+ profiles = [
112
+ {
113
+ "documentType": profile.document_type,
114
+ "label": profile.name,
115
+ "countries": list(profile.countries),
116
+ "fields": [camel(name) for name in profile.fields],
117
+ "requiredFields": [camel(name) for name in profile.required_fields],
118
+ "sources": list(profile.extraction_methods),
119
+ "experimental": profile.experimental,
120
+ }
121
+ for profile in _PROFILES
122
+ ]
123
+ # The legacy completion rules contain alternatives and, for Aadhaar,
124
+ # different front/address-side requirements. Expose those explicitly rather
125
+ # than pretending every logical requirement is one physical output field.
126
+ for profile in profiles:
127
+ if profile["documentType"] == "aadhaar":
128
+ profile["requiredFieldsByVariant"] = {
129
+ "front": profile["requiredFields"].copy(),
130
+ "address": ["aadhaarNumberOrMaskedLast4", "address", "pincode"],
131
+ }
132
+ profile["requirementAlternatives"] = {
133
+ "aadhaarNumberOrMaskedLast4": [["aadhaarNumber"], ["aadhaarMasked", "aadhaarLast4"]],
134
+ "dateOrYearOfBirth": [["dateOfBirth"], ["yearOfBirth"]],
135
+ }
136
+ elif profile["documentType"] == "voter_id":
137
+ profile["requirementAlternatives"] = {"dateOfBirthOrAge": [["dateOfBirth"], ["age"]]}
138
+ elif profile["documentType"] == "nrega_job_card":
139
+ profile["requirementAlternatives"] = {
140
+ "householdOrMemberName": [["headOfHousehold"], ["members[].name"]],
141
+ "location": [["village"], ["gramPanchayat"], ["district"], ["address"]],
142
+ }
143
+ return profiles