parse-bench 1.0.0__py3-none-any.whl

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 (227) hide show
  1. parse_bench/__init__.py +3 -0
  2. parse_bench/analysis/__init__.py +6 -0
  3. parse_bench/analysis/aggregation_report.py +582 -0
  4. parse_bench/analysis/cli.py +472 -0
  5. parse_bench/analysis/comparison.py +382 -0
  6. parse_bench/analysis/comparison_core.py +357 -0
  7. parse_bench/analysis/comparison_report.py +2066 -0
  8. parse_bench/analysis/detailed_report.py +2254 -0
  9. parse_bench/analysis/leaderboard_report.py +852 -0
  10. parse_bench/analysis/metric_definitions.py +771 -0
  11. parse_bench/cli.py +267 -0
  12. parse_bench/data/__init__.py +1 -0
  13. parse_bench/data/cli.py +118 -0
  14. parse_bench/data/download.py +127 -0
  15. parse_bench/evaluation/__init__.py +11 -0
  16. parse_bench/evaluation/cli.py +435 -0
  17. parse_bench/evaluation/evaluators/__init__.py +17 -0
  18. parse_bench/evaluation/evaluators/base.py +34 -0
  19. parse_bench/evaluation/evaluators/extract.py +429 -0
  20. parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
  21. parse_bench/evaluation/evaluators/parse.py +1353 -0
  22. parse_bench/evaluation/evaluators/qa.py +199 -0
  23. parse_bench/evaluation/layout_adapters/__init__.py +21 -0
  24. parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
  25. parse_bench/evaluation/layout_adapters/base.py +105 -0
  26. parse_bench/evaluation/layout_adapters/registry.py +109 -0
  27. parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
  28. parse_bench/evaluation/layout_label_mappers/base.py +66 -0
  29. parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
  30. parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
  31. parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
  32. parse_bench/evaluation/metric_aggregation.py +56 -0
  33. parse_bench/evaluation/metrics/__init__.py +5 -0
  34. parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
  35. parse_bench/evaluation/metrics/attribution/constants.py +12 -0
  36. parse_bench/evaluation/metrics/attribution/core.py +1108 -0
  37. parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
  38. parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
  39. parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
  40. parse_bench/evaluation/metrics/base.py +33 -0
  41. parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
  42. parse_bench/evaluation/metrics/extract/__init__.py +29 -0
  43. parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
  44. parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
  45. parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
  46. parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
  47. parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
  48. parse_bench/evaluation/metrics/extract/test_types.py +11 -0
  49. parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
  50. parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
  51. parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
  52. parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
  53. parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
  54. parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
  55. parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
  56. parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
  57. parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
  58. parse_bench/evaluation/metrics/parse/__init__.py +5 -0
  59. parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
  60. parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
  61. parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
  62. parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
  63. parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
  64. parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
  65. parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
  66. parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
  67. parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
  68. parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
  69. parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
  70. parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
  71. parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
  72. parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
  73. parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
  74. parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
  75. parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
  76. parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
  77. parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
  78. parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
  79. parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
  80. parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
  81. parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
  82. parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
  83. parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
  84. parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
  85. parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
  86. parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
  87. parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
  88. parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
  89. parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
  90. parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
  91. parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
  92. parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
  93. parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
  94. parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
  95. parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
  96. parse_bench/evaluation/metrics/parse/test_types.py +103 -0
  97. parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
  98. parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
  99. parse_bench/evaluation/metrics/parse/utils.py +885 -0
  100. parse_bench/evaluation/metrics/qa/__init__.py +5 -0
  101. parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
  102. parse_bench/evaluation/qa/__init__.py +5 -0
  103. parse_bench/evaluation/qa/llm_service.py +335 -0
  104. parse_bench/evaluation/reports/__init__.py +8 -0
  105. parse_bench/evaluation/reports/csv.py +64 -0
  106. parse_bench/evaluation/reports/html.py +338 -0
  107. parse_bench/evaluation/reports/markdown.py +98 -0
  108. parse_bench/evaluation/reports/rule_csv.py +22 -0
  109. parse_bench/evaluation/runner.py +1864 -0
  110. parse_bench/evaluation/stats.py +104 -0
  111. parse_bench/extensions.py +72 -0
  112. parse_bench/inference/__init__.py +33 -0
  113. parse_bench/inference/chunkr_layout_extraction.py +160 -0
  114. parse_bench/inference/cli.py +484 -0
  115. parse_bench/inference/layout_extraction.py +422 -0
  116. parse_bench/inference/pipelines/__init__.py +59 -0
  117. parse_bench/inference/pipelines/extract.py +39 -0
  118. parse_bench/inference/pipelines/layout.py +142 -0
  119. parse_bench/inference/pipelines/parse.py +2603 -0
  120. parse_bench/inference/pipelines.py +0 -0
  121. parse_bench/inference/providers/__init__.py +28 -0
  122. parse_bench/inference/providers/base.py +196 -0
  123. parse_bench/inference/providers/cancellation.py +137 -0
  124. parse_bench/inference/providers/extract/__init__.py +22 -0
  125. parse_bench/inference/providers/extract/citations.py +549 -0
  126. parse_bench/inference/providers/extract/extend.py +851 -0
  127. parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
  128. parse_bench/inference/providers/layoutdet/__init__.py +25 -0
  129. parse_bench/inference/providers/layoutdet/adapters.py +946 -0
  130. parse_bench/inference/providers/layoutdet/base.py +203 -0
  131. parse_bench/inference/providers/layoutdet/chandra.py +449 -0
  132. parse_bench/inference/providers/layoutdet/docling.py +125 -0
  133. parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
  134. parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
  135. parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
  136. parse_bench/inference/providers/layoutdet/paddle.py +117 -0
  137. parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
  138. parse_bench/inference/providers/layoutdet/surya.py +250 -0
  139. parse_bench/inference/providers/layoutdet/yolo.py +109 -0
  140. parse_bench/inference/providers/parse/__init__.py +64 -0
  141. parse_bench/inference/providers/parse/_docling_common.py +233 -0
  142. parse_bench/inference/providers/parse/_layout_utils.py +611 -0
  143. parse_bench/inference/providers/parse/amazon_nova.py +515 -0
  144. parse_bench/inference/providers/parse/anthropic.py +882 -0
  145. parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
  146. parse_bench/inference/providers/parse/chandra2.py +633 -0
  147. parse_bench/inference/providers/parse/chunkr.py +268 -0
  148. parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
  149. parse_bench/inference/providers/parse/datalab.py +370 -0
  150. parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
  151. parse_bench/inference/providers/parse/docling.py +281 -0
  152. parse_bench/inference/providers/parse/docling_serve.py +289 -0
  153. parse_bench/inference/providers/parse/dots_ocr.py +574 -0
  154. parse_bench/inference/providers/parse/extend_parse.py +710 -0
  155. parse_bench/inference/providers/parse/falconocr.py +436 -0
  156. parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
  157. parse_bench/inference/providers/parse/gemma4.py +472 -0
  158. parse_bench/inference/providers/parse/glm_zai.py +229 -0
  159. parse_bench/inference/providers/parse/google.py +1125 -0
  160. parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
  161. parse_bench/inference/providers/parse/google_docai.py +776 -0
  162. parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
  163. parse_bench/inference/providers/parse/granite_vision.py +515 -0
  164. parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
  165. parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
  166. parse_bench/inference/providers/parse/landingai.py +452 -0
  167. parse_bench/inference/providers/parse/liteparse.py +350 -0
  168. parse_bench/inference/providers/parse/llamaparse.py +677 -0
  169. parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
  170. parse_bench/inference/providers/parse/markitdown.py +138 -0
  171. parse_bench/inference/providers/parse/mineru25.py +405 -0
  172. parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
  173. parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
  174. parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
  175. parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
  176. parse_bench/inference/providers/parse/oi_parser.py +222 -0
  177. parse_bench/inference/providers/parse/openai.py +740 -0
  178. parse_bench/inference/providers/parse/opendataloader.py +152 -0
  179. parse_bench/inference/providers/parse/paddleocr.py +624 -0
  180. parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
  181. parse_bench/inference/providers/parse/pulse.py +785 -0
  182. parse_bench/inference/providers/parse/pymupdf.py +207 -0
  183. parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
  184. parse_bench/inference/providers/parse/pypdf.py +179 -0
  185. parse_bench/inference/providers/parse/qwen.py +678 -0
  186. parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
  187. parse_bench/inference/providers/parse/reducto.py +546 -0
  188. parse_bench/inference/providers/parse/surya2.py +372 -0
  189. parse_bench/inference/providers/parse/tesseract.py +301 -0
  190. parse_bench/inference/providers/parse/textract.py +694 -0
  191. parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
  192. parse_bench/inference/providers/parse/unstructured.py +485 -0
  193. parse_bench/inference/providers/parse/warp_ingest.py +199 -0
  194. parse_bench/inference/providers/registry.py +49 -0
  195. parse_bench/inference/renormalize.py +170 -0
  196. parse_bench/inference/runner.py +2023 -0
  197. parse_bench/layout_label_mapping.py +424 -0
  198. parse_bench/layout_projection.py +179 -0
  199. parse_bench/pipeline/__init__.py +1 -0
  200. parse_bench/pipeline/cli.py +549 -0
  201. parse_bench/schemas/__init__.py +33 -0
  202. parse_bench/schemas/evaluation.py +93 -0
  203. parse_bench/schemas/extract_output.py +36 -0
  204. parse_bench/schemas/layout_detection_output.py +545 -0
  205. parse_bench/schemas/layout_ontology.py +315 -0
  206. parse_bench/schemas/metrics.py +69 -0
  207. parse_bench/schemas/parse_output.py +152 -0
  208. parse_bench/schemas/pipeline.py +22 -0
  209. parse_bench/schemas/pipeline_io.py +106 -0
  210. parse_bench/schemas/product.py +97 -0
  211. parse_bench/test_cases/__init__.py +25 -0
  212. parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
  213. parse_bench/test_cases/extract_field_paths.py +164 -0
  214. parse_bench/test_cases/layout_attribution_generation.py +287 -0
  215. parse_bench/test_cases/loader.py +652 -0
  216. parse_bench/test_cases/parse_rule_schemas.py +1071 -0
  217. parse_bench/test_cases/rule_filters.py +32 -0
  218. parse_bench/test_cases/rule_ids.py +107 -0
  219. parse_bench/test_cases/schema.py +427 -0
  220. parse_bench/utils/__init__.py +15 -0
  221. parse_bench/utils/gemini_layout_utils.py +670 -0
  222. parse_bench/utils/text_aggregation.py +100 -0
  223. parse_bench-1.0.0.dist-info/METADATA +476 -0
  224. parse_bench-1.0.0.dist-info/RECORD +227 -0
  225. parse_bench-1.0.0.dist-info/WHEEL +4 -0
  226. parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
  227. parse_bench-1.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,694 @@
1
+ """Provider for AWS Textract document parsing."""
2
+
3
+ import os
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from parse_bench.inference.providers.base import (
9
+ Provider,
10
+ ProviderConfigError,
11
+ ProviderPermanentError,
12
+ ProviderTransientError,
13
+ )
14
+ from parse_bench.inference.providers.registry import register_provider
15
+ from parse_bench.schemas.parse_output import (
16
+ LayoutItemIR,
17
+ LayoutSegmentIR,
18
+ PageIR,
19
+ ParseLayoutPageIR,
20
+ ParseOutput,
21
+ )
22
+ from parse_bench.schemas.pipeline import PipelineSpec
23
+ from parse_bench.schemas.pipeline_io import (
24
+ InferenceRequest,
25
+ InferenceResult,
26
+ RawInferenceResult,
27
+ )
28
+ from parse_bench.schemas.product import ProductType
29
+
30
+ # Textract LAYOUT_* BlockType -> Canonical17 label string
31
+ TEXTRACT_LABEL_MAP: dict[str, str] = {
32
+ "LAYOUT_TITLE": "Title",
33
+ "LAYOUT_SECTION_HEADER": "Section-header",
34
+ "LAYOUT_TEXT": "Text",
35
+ "LAYOUT_TABLE": "Table",
36
+ "LAYOUT_FIGURE": "Picture",
37
+ "LAYOUT_LIST": "List-item",
38
+ "LAYOUT_HEADER": "Page-header",
39
+ "LAYOUT_FOOTER": "Page-footer",
40
+ "LAYOUT_PAGE_NUMBER": "Page-footer",
41
+ "LAYOUT_KEY_VALUE": "Key-Value Region",
42
+ }
43
+
44
+ # Textract SELECTION_ELEMENT SelectionStatus -> Canonical17 checkbox label.
45
+ # SELECTION_ELEMENT blocks are only emitted when the FORMS feature is enabled.
46
+ TEXTRACT_SELECTION_LABEL_MAP: dict[str, str] = {
47
+ "SELECTED": "Checkbox-Selected",
48
+ "NOT_SELECTED": "Checkbox-Unselected",
49
+ }
50
+
51
+ # Virtual page dimensions for normalized coordinate conversion.
52
+ # Textract BoundingBox is already [0,1], so these cancel out during evaluation.
53
+ _VIRTUAL_PAGE_DIM = 1000.0
54
+
55
+ # Textract per-page pricing (USD), first 1M pages/month tier, US West (Oregon).
56
+ # Keyed by (detect_tables, detect_forms). Source: https://aws.amazon.com/textract/pricing/
57
+ # Layout is included free when combined with Tables; when used alone it has its own tier.
58
+ _TEXTRACT_COST_PER_PAGE_USD: dict[tuple[bool, bool], float] = {
59
+ (False, False): 0.004, # AnalyzeDocument - Layout only
60
+ (True, False): 0.015, # AnalyzeDocument - Tables (Layout free with Tables)
61
+ (False, True): 0.050, # AnalyzeDocument - Forms
62
+ (True, True): 0.065, # AnalyzeDocument - Tables + Forms
63
+ }
64
+
65
+
66
+ @register_provider("textract")
67
+ class TextractProvider(Provider):
68
+ """
69
+ Provider for AWS Textract document parsing.
70
+
71
+ Extracts text, tables, and forms from PDFs and images using AWS Textract.
72
+ Tables are converted to HTML to preserve their visual structure.
73
+ """
74
+
75
+ def __init__(self, provider_name: str, base_config: dict[str, Any] | None = None):
76
+ """
77
+ Initialize the provider.
78
+
79
+ :param provider_name: Name of the provider
80
+ :param base_config: Optional configuration with:
81
+ - `aws_access_key_id`: AWS access key (or use AWS_ACCESS_KEY_ID env var)
82
+ - `aws_secret_access_key`: AWS secret key (or use AWS_SECRET_ACCESS_KEY env var)
83
+ - `aws_region`: AWS region (default: "us-east-1", or use AWS_REGION env var)
84
+ - `output_tables_as_html`: Whether to output tables as HTML (default: True)
85
+ - `detect_tables`: Whether to detect tables (default: True)
86
+ - `detect_forms`: Whether to detect forms/key-value pairs and checkbox
87
+ selection elements (default: False). Required for Checkbox-Selected /
88
+ Checkbox-Unselected layout predictions. Note that enabling FORMS
89
+ roughly ~5x the AWS per-page price vs. LAYOUT-only.
90
+ """
91
+ super().__init__(provider_name, base_config)
92
+
93
+ # Get AWS credentials from config or environment
94
+ self._aws_access_key_id = self.base_config.get("aws_access_key_id", os.environ.get("AWS_ACCESS_KEY_ID"))
95
+ self._aws_secret_access_key = self.base_config.get(
96
+ "aws_secret_access_key", os.environ.get("AWS_SECRET_ACCESS_KEY")
97
+ )
98
+ self._aws_region = self.base_config.get("aws_region", os.environ.get("AWS_REGION", "us-east-1"))
99
+
100
+ # Configuration options
101
+ self._output_tables_as_html = self.base_config.get("output_tables_as_html", True)
102
+ self._detect_tables = self.base_config.get("detect_tables", True)
103
+ self._detect_forms = self.base_config.get("detect_forms", False)
104
+
105
+ # Validate credentials
106
+ if not self._aws_access_key_id or not self._aws_secret_access_key:
107
+ raise ProviderConfigError(
108
+ "AWS credentials not configured. Set AWS_ACCESS_KEY_ID and "
109
+ "AWS_SECRET_ACCESS_KEY environment variables or provide them in config."
110
+ )
111
+
112
+ # Initialize boto3 client
113
+ try:
114
+ import boto3
115
+ except ImportError as e:
116
+ raise ProviderConfigError("boto3 package not installed. Run: pip install boto3") from e
117
+
118
+ self._textract_client = boto3.client(
119
+ "textract",
120
+ aws_access_key_id=self._aws_access_key_id,
121
+ aws_secret_access_key=self._aws_secret_access_key,
122
+ region_name=self._aws_region,
123
+ )
124
+
125
+ # Textract synchronous API limits
126
+ _MAX_DIMENSION = 10000 # Max 10,000 pixels in any dimension
127
+ _MAX_BYTES = 10 * 1024 * 1024 # Max 10 MB
128
+ _TARGET_BYTES = 9 * 1024 * 1024 # Target 9 MB to leave margin
129
+
130
+ def _resize_image_for_textract(self, image: Any) -> bytes:
131
+ """
132
+ Resize and compress an image to fit within Textract's limits.
133
+
134
+ Textract synchronous API limits:
135
+ - Max dimension: 10,000 pixels
136
+ - Max file size: 10 MB
137
+
138
+ :param image: PIL Image object
139
+ :return: PNG bytes that fit within Textract limits
140
+ """
141
+ import io
142
+
143
+ from PIL import Image
144
+
145
+ # Step 1: Resize if dimensions exceed limit
146
+ width, height = image.size
147
+ if width > self._MAX_DIMENSION or height > self._MAX_DIMENSION:
148
+ scale = min(self._MAX_DIMENSION / width, self._MAX_DIMENSION / height)
149
+ new_width = int(width * scale)
150
+ new_height = int(height * scale)
151
+ image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
152
+
153
+ # Step 2: Try PNG first
154
+ img_buffer = io.BytesIO()
155
+ image.save(img_buffer, format="PNG", optimize=True)
156
+ img_bytes = img_buffer.getvalue()
157
+
158
+ # Step 3: If still too large, progressively reduce size
159
+ scale = 0.9
160
+ while len(img_bytes) > self._TARGET_BYTES and scale > 0.3:
161
+ new_width = int(image.size[0] * scale)
162
+ new_height = int(image.size[1] * scale)
163
+ resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
164
+
165
+ img_buffer = io.BytesIO()
166
+ resized.save(img_buffer, format="PNG", optimize=True)
167
+ img_bytes = img_buffer.getvalue()
168
+
169
+ if len(img_bytes) <= self._TARGET_BYTES:
170
+ break
171
+ scale *= 0.9
172
+
173
+ return img_bytes
174
+
175
+ def _analyze_document(self, file_path: str) -> dict[str, Any]:
176
+ """
177
+ Analyze a document using AWS Textract.
178
+
179
+ :param file_path: Path to the PDF or image file
180
+ :return: Raw Textract API response
181
+ :raises ProviderError: For any API errors
182
+ """
183
+ try:
184
+ from botocore.exceptions import ClientError
185
+ except ImportError as e:
186
+ raise ProviderConfigError("botocore package not installed. Run: pip install boto3") from e
187
+
188
+ # Read the file and check if it needs resizing (for images)
189
+ path = Path(file_path)
190
+ suffix = path.suffix.lower()
191
+
192
+ if suffix in {".png", ".jpg", ".jpeg", ".tiff", ".tif"}:
193
+ # For images, load and resize if needed
194
+ from PIL import Image
195
+
196
+ with Image.open(file_path) as img:
197
+ document_bytes = self._resize_image_for_textract(img)
198
+ else:
199
+ # For other formats (shouldn't happen), read as-is
200
+ with open(file_path, "rb") as f:
201
+ document_bytes = f.read()
202
+
203
+ # Determine which features to analyze
204
+ feature_types = ["LAYOUT"]
205
+ if self._detect_tables:
206
+ feature_types.append("TABLES")
207
+ if self._detect_forms:
208
+ feature_types.append("FORMS")
209
+
210
+ try:
211
+ if feature_types:
212
+ response = self._textract_client.analyze_document(
213
+ Document={"Bytes": document_bytes},
214
+ FeatureTypes=feature_types,
215
+ )
216
+ else:
217
+ # Just detect text without tables/forms
218
+ response = self._textract_client.detect_document_text(Document={"Bytes": document_bytes})
219
+ return response # type: ignore[no-any-return]
220
+
221
+ except ClientError as e:
222
+ error_code = e.response.get("Error", {}).get("Code", "")
223
+ error_message = e.response.get("Error", {}).get("Message", str(e))
224
+
225
+ # Categorize errors
226
+ if error_code in ("ThrottlingException", "ProvisionedThroughputExceededException"):
227
+ raise ProviderTransientError(f"Rate limit exceeded: {error_message}") from e
228
+ elif error_code in ("InvalidParameterException", "UnsupportedDocumentException"):
229
+ raise ProviderPermanentError(f"Invalid document: {error_message}") from e
230
+ elif error_code in ("AccessDeniedException", "InvalidS3ObjectException"):
231
+ raise ProviderConfigError(f"AWS access error: {error_message}") from e
232
+ else:
233
+ raise ProviderTransientError(f"AWS Textract error: {error_message}") from e
234
+ except Exception as e:
235
+ raise ProviderTransientError(f"Unexpected error calling Textract: {e}") from e
236
+
237
+ def _analyze_multipage_document(self, file_path: str) -> dict[str, Any]:
238
+ """
239
+ Analyze a multi-page document using AWS Textract async API.
240
+
241
+ For PDFs, Textract requires using S3 + async operations for multi-page.
242
+ This method handles single-page PDFs and images via synchronous API,
243
+ and falls back to page-by-page processing for multi-page PDFs.
244
+
245
+ :param file_path: Path to the document file
246
+ :return: Combined Textract response
247
+ """
248
+ path = Path(file_path)
249
+ suffix = path.suffix.lower()
250
+
251
+ # For images, use direct synchronous API
252
+ if suffix in {".png", ".jpg", ".jpeg", ".tiff", ".tif"}:
253
+ return self._analyze_document(file_path)
254
+
255
+ # For PDFs, convert each page to image and process
256
+ try:
257
+ from pdf2image import convert_from_path
258
+ except ImportError as e:
259
+ raise ProviderConfigError("pdf2image package not installed. Run: pip install pdf2image") from e
260
+
261
+ try:
262
+ images = convert_from_path(file_path, dpi=300)
263
+ except Exception as e:
264
+ raise ProviderPermanentError(f"Failed to convert PDF to images: {e}") from e
265
+
266
+ all_blocks: list[dict[str, Any]] = []
267
+ current_page = 0
268
+
269
+ for page_num, image in enumerate(images):
270
+ # Convert PIL image to bytes, resizing if needed for Textract limits
271
+ img_bytes = self._resize_image_for_textract(image)
272
+
273
+ # Analyze this page
274
+ feature_types = ["LAYOUT"]
275
+ if self._detect_tables:
276
+ feature_types.append("TABLES")
277
+ if self._detect_forms:
278
+ feature_types.append("FORMS")
279
+
280
+ try:
281
+ from botocore.exceptions import ClientError
282
+
283
+ if feature_types:
284
+ response = self._textract_client.analyze_document(
285
+ Document={"Bytes": img_bytes},
286
+ FeatureTypes=feature_types,
287
+ )
288
+ else:
289
+ response = self._textract_client.detect_document_text(Document={"Bytes": img_bytes})
290
+
291
+ # Add page number to blocks and accumulate
292
+ for block in response.get("Blocks", []):
293
+ block["Page"] = page_num + 1
294
+ all_blocks.append(block)
295
+
296
+ current_page = page_num + 1
297
+
298
+ except ClientError as e:
299
+ error_code = e.response.get("Error", {}).get("Code", "")
300
+ error_message = e.response.get("Error", {}).get("Message", str(e))
301
+
302
+ if error_code in ("ThrottlingException", "ProvisionedThroughputExceededException"):
303
+ raise ProviderTransientError(f"Rate limit exceeded: {error_message}") from e
304
+ elif error_code in ("InvalidParameterException", "UnsupportedDocumentException"):
305
+ raise ProviderPermanentError(f"Invalid document: {error_message}") from e
306
+ else:
307
+ raise ProviderTransientError(f"AWS Textract error: {error_message}") from e
308
+
309
+ return {
310
+ "Blocks": all_blocks,
311
+ "DocumentMetadata": {"Pages": current_page},
312
+ }
313
+
314
+ def _convert_to_markdown(self, textract_response: dict[str, Any]) -> dict[str, Any]:
315
+ """
316
+ Convert Textract response to markdown format with HTML tables.
317
+
318
+ Uses the amazon-textract-textractor library to properly parse
319
+ and convert tables to HTML while preserving their visual structure.
320
+
321
+ :param textract_response: Raw Textract API response
322
+ :return: Dict with pages and markdown content
323
+ """
324
+ try:
325
+ from textractor.parsers import response_parser
326
+ except ImportError as e:
327
+ raise ProviderConfigError(
328
+ "amazon-textract-textractor package not installed. Run: pip install amazon-textract-textractor"
329
+ ) from e
330
+
331
+ # Parse the response using textractor
332
+ document = response_parser.parse(textract_response)
333
+
334
+ # Get number of pages
335
+ num_pages = textract_response.get("DocumentMetadata", {}).get("Pages", 1)
336
+
337
+ pages_content: dict[int, list[str]] = {i: [] for i in range(1, num_pages + 1)}
338
+
339
+ # Process each page — interleave lines and tables by y-position
340
+ for page in document.pages:
341
+ page_num = page.page_num
342
+
343
+ # Collect all elements with their y-positions for reading order
344
+ elements: list[tuple[float, str]] = []
345
+
346
+ for line in page.lines:
347
+ # Skip lines that are part of tables
348
+ if not self._is_in_table(line, page):
349
+ y_pos = line.bbox.y if hasattr(line, "bbox") and line.bbox else 0.0
350
+ elements.append((y_pos, line.text))
351
+
352
+ if self._detect_tables and self._output_tables_as_html:
353
+ for table in page.tables:
354
+ y_pos = table.bbox.y if hasattr(table, "bbox") and table.bbox else 0.0
355
+ # Use textractor's built-in to_html() which handles colspan/rowspan
356
+ html_table = table.to_html() if hasattr(table, "to_html") else ""
357
+ if html_table:
358
+ elements.append((y_pos, html_table))
359
+
360
+ # Sort by y-position to reconstruct reading order
361
+ elements.sort(key=lambda x: x[0])
362
+ pages_content[page_num] = [elem[1] for elem in elements]
363
+
364
+ # Build page-level markdown
365
+ pages_data = []
366
+ for page_num in range(1, num_pages + 1):
367
+ content = pages_content.get(page_num, [])
368
+ markdown = "\n\n".join(content)
369
+ pages_data.append(
370
+ {
371
+ "page_index": page_num - 1,
372
+ "markdown": markdown,
373
+ }
374
+ )
375
+
376
+ # Build full document markdown
377
+ full_markdown = "\n\n".join(page["markdown"] for page in pages_data if page["markdown"]) # type: ignore[misc]
378
+
379
+ return {
380
+ "pages": pages_data,
381
+ "markdown": full_markdown,
382
+ "num_pages": num_pages,
383
+ }
384
+
385
+ def _is_in_table(self, line: Any, page: Any) -> bool:
386
+ """
387
+ Check if a line is contained within any table on the page.
388
+
389
+ :param line: A textractor Line object
390
+ :param page: A textractor Page object
391
+ :return: True if line is within a table
392
+ """
393
+ if not hasattr(page, "tables") or not page.tables:
394
+ return False
395
+
396
+ line_bbox = line.bbox if hasattr(line, "bbox") else None
397
+ if not line_bbox:
398
+ return False
399
+
400
+ for table in page.tables:
401
+ table_bbox = table.bbox if hasattr(table, "bbox") else None
402
+ if table_bbox and self._bbox_contains(table_bbox, line_bbox):
403
+ return True
404
+ return False
405
+
406
+ def _bbox_contains(self, outer: Any, inner: Any) -> bool:
407
+ """
408
+ Check if outer bounding box contains inner bounding box.
409
+
410
+ :param outer: Outer bounding box
411
+ :param inner: Inner bounding box
412
+ :return: True if outer contains inner
413
+ """
414
+ try:
415
+ return ( # type: ignore[no-any-return]
416
+ outer.x <= inner.x
417
+ and outer.y <= inner.y
418
+ and (outer.x + outer.width) >= (inner.x + inner.width)
419
+ and (outer.y + outer.height) >= (inner.y + inner.height)
420
+ )
421
+ except AttributeError:
422
+ return False
423
+
424
+ def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
425
+ """
426
+ Run inference and return raw results.
427
+
428
+ :param pipeline: Pipeline specification
429
+ :param request: Inference request
430
+ :return: Raw inference result
431
+ :raises ProviderError: For any provider-related failures
432
+ """
433
+ if request.product_type != ProductType.PARSE:
434
+ raise ProviderPermanentError(
435
+ f"TextractProvider only supports PARSE product type, got {request.product_type}"
436
+ )
437
+
438
+ source_path = Path(request.source_file_path)
439
+ if not source_path.exists():
440
+ raise ProviderPermanentError(f"Source file not found: {source_path}")
441
+
442
+ # Check file extension
443
+ supported_extensions = {".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".tif"}
444
+ if source_path.suffix.lower() not in supported_extensions:
445
+ raise ProviderPermanentError(
446
+ f"TextractProvider only supports {supported_extensions}, got {source_path.suffix}"
447
+ )
448
+
449
+ # Apply config overrides from pipeline
450
+ config = pipeline.config or {}
451
+ if "output_tables_as_html" in config:
452
+ self._output_tables_as_html = config["output_tables_as_html"]
453
+ if "detect_tables" in config:
454
+ self._detect_tables = config["detect_tables"]
455
+ if "detect_forms" in config:
456
+ self._detect_forms = config["detect_forms"]
457
+
458
+ started_at = datetime.now()
459
+
460
+ try:
461
+ # Analyze the document
462
+ textract_response = self._analyze_multipage_document(str(source_path))
463
+
464
+ completed_at = datetime.now()
465
+ latency_ms = int((completed_at - started_at).total_seconds() * 1000)
466
+
467
+ raw_output: dict[str, Any] = {
468
+ "textract_response": textract_response,
469
+ "config": {
470
+ "output_tables_as_html": self._output_tables_as_html,
471
+ "detect_tables": self._detect_tables,
472
+ "detect_forms": self._detect_forms,
473
+ },
474
+ }
475
+
476
+ num_pages = int(textract_response.get("DocumentMetadata", {}).get("Pages", 0) or 0)
477
+ if num_pages > 0:
478
+ cost_per_page = _TEXTRACT_COST_PER_PAGE_USD[(self._detect_tables, self._detect_forms)]
479
+ raw_output["num_pages"] = num_pages
480
+ raw_output["cost_per_page_usd"] = cost_per_page
481
+ raw_output["cost_usd"] = cost_per_page * num_pages
482
+
483
+ return RawInferenceResult(
484
+ request=request,
485
+ pipeline=pipeline,
486
+ pipeline_name=pipeline.pipeline_name,
487
+ product_type=request.product_type,
488
+ raw_output=raw_output,
489
+ started_at=started_at,
490
+ completed_at=completed_at,
491
+ latency_in_ms=latency_ms,
492
+ )
493
+
494
+ except (ProviderPermanentError, ProviderTransientError, ProviderConfigError):
495
+ raise
496
+ except Exception as e:
497
+ raise ProviderPermanentError(f"Unexpected error during inference: {e}") from e
498
+
499
+ def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
500
+ """
501
+ Normalize raw inference result to produce ParseOutput.
502
+
503
+ :param raw_result: Raw inference result from run_inference()
504
+ :return: Inference result with both raw and normalized outputs
505
+ :raises ProviderError: For any normalization failures
506
+ """
507
+ if raw_result.product_type != ProductType.PARSE:
508
+ raise ProviderPermanentError(
509
+ f"TextractProvider only supports PARSE product type, got {raw_result.product_type}"
510
+ )
511
+
512
+ # Extract config from raw output
513
+ config = raw_result.raw_output.get("config", {})
514
+ self._output_tables_as_html = config.get("output_tables_as_html", True)
515
+ self._detect_tables = config.get("detect_tables", True)
516
+
517
+ # Convert Textract response to markdown
518
+ textract_response = raw_result.raw_output.get("textract_response", {})
519
+ markdown_result = self._convert_to_markdown(textract_response)
520
+
521
+ # Build page-level output
522
+ pages: list[PageIR] = []
523
+ for page_data in markdown_result.get("pages", []):
524
+ pages.append(
525
+ PageIR(
526
+ page_index=page_data["page_index"],
527
+ markdown=page_data["markdown"],
528
+ )
529
+ )
530
+
531
+ # Build layout_pages for layout cross-evaluation
532
+ blocks = textract_response.get("Blocks", [])
533
+ layout_pages = _build_layout_pages(blocks)
534
+
535
+ output = ParseOutput(
536
+ task_type="parse",
537
+ example_id=raw_result.request.example_id,
538
+ pipeline_name=raw_result.pipeline_name,
539
+ pages=pages,
540
+ layout_pages=layout_pages,
541
+ markdown=markdown_result.get("markdown", ""),
542
+ )
543
+
544
+ return InferenceResult(
545
+ request=raw_result.request,
546
+ pipeline_name=raw_result.pipeline_name,
547
+ product_type=raw_result.product_type,
548
+ raw_output=raw_result.raw_output,
549
+ output=output,
550
+ started_at=raw_result.started_at,
551
+ completed_at=raw_result.completed_at,
552
+ latency_in_ms=raw_result.latency_in_ms,
553
+ )
554
+
555
+
556
+ def _build_layout_pages(blocks: list[dict[str, Any]]) -> list[ParseLayoutPageIR]:
557
+ """Build layout_pages from Textract LAYOUT_* blocks for layout cross-evaluation.
558
+
559
+ Groups LAYOUT_* blocks by page and converts each block's normalized [0,1]
560
+ BoundingBox into a LayoutSegmentIR with canonical label mapping.
561
+ Text content is extracted by traversing child LINE blocks.
562
+ """
563
+ from collections import defaultdict
564
+
565
+ # Build block ID index for child traversal
566
+ block_index: dict[str, dict[str, Any]] = {}
567
+ for block in blocks:
568
+ block_id = block.get("Id")
569
+ if block_id:
570
+ block_index[block_id] = block
571
+
572
+ # Group LAYOUT_* blocks by page
573
+ pages_blocks: dict[int, list[dict[str, Any]]] = defaultdict(list)
574
+ for block in blocks:
575
+ block_type = block.get("BlockType", "")
576
+ if block_type in TEXTRACT_LABEL_MAP:
577
+ page_num = block.get("Page", 1)
578
+ pages_blocks[page_num].append(block)
579
+
580
+ # Group SELECTION_ELEMENT blocks (checkboxes) by page
581
+ pages_selection_blocks: dict[int, list[dict[str, Any]]] = defaultdict(list)
582
+ for block in blocks:
583
+ if block.get("BlockType") != "SELECTION_ELEMENT":
584
+ continue
585
+ status = block.get("SelectionStatus", "")
586
+ if status not in TEXTRACT_SELECTION_LABEL_MAP:
587
+ continue
588
+ page_num = block.get("Page", 1)
589
+ pages_selection_blocks[page_num].append(block)
590
+
591
+ layout_pages: list[ParseLayoutPageIR] = []
592
+ all_page_nums = sorted(set(pages_blocks.keys()) | set(pages_selection_blocks.keys()))
593
+ for page_num in all_page_nums:
594
+ page_blocks = pages_blocks.get(page_num, [])
595
+ items: list[LayoutItemIR] = []
596
+
597
+ for block in page_blocks:
598
+ block_type = block.get("BlockType", "")
599
+ canonical_label = TEXTRACT_LABEL_MAP.get(block_type)
600
+ if canonical_label is None:
601
+ continue
602
+
603
+ # Extract bbox (normalized [0,1] xywh)
604
+ bbox = block.get("Geometry", {}).get("BoundingBox", {})
605
+ left = float(bbox.get("Left", 0.0))
606
+ top = float(bbox.get("Top", 0.0))
607
+ width = float(bbox.get("Width", 0.0))
608
+ height = float(bbox.get("Height", 0.0))
609
+
610
+ confidence = float(block.get("Confidence", 100.0)) / 100.0
611
+
612
+ seg = LayoutSegmentIR(
613
+ x=left,
614
+ y=top,
615
+ w=width,
616
+ h=height,
617
+ confidence=confidence,
618
+ label=canonical_label,
619
+ )
620
+
621
+ # Extract text from child LINE blocks
622
+ content = _get_block_text(block, block_index)
623
+
624
+ norm_label = canonical_label.strip().lower()
625
+ if norm_label == "table":
626
+ item_type = "table"
627
+ elif norm_label == "picture":
628
+ item_type = "image"
629
+ else:
630
+ item_type = "text"
631
+
632
+ items.append(
633
+ LayoutItemIR(
634
+ type=item_type,
635
+ value=content,
636
+ bbox=seg,
637
+ layout_segments=[seg],
638
+ )
639
+ )
640
+
641
+ for block in pages_selection_blocks.get(page_num, []):
642
+ status = block.get("SelectionStatus", "")
643
+ canonical_label = TEXTRACT_SELECTION_LABEL_MAP[status]
644
+
645
+ bbox = block.get("Geometry", {}).get("BoundingBox", {})
646
+ left = float(bbox.get("Left", 0.0))
647
+ top = float(bbox.get("Top", 0.0))
648
+ width = float(bbox.get("Width", 0.0))
649
+ height = float(bbox.get("Height", 0.0))
650
+ confidence = float(block.get("Confidence", 100.0)) / 100.0
651
+
652
+ seg = LayoutSegmentIR(
653
+ x=left,
654
+ y=top,
655
+ w=width,
656
+ h=height,
657
+ confidence=confidence,
658
+ label=canonical_label,
659
+ )
660
+ items.append(
661
+ LayoutItemIR(
662
+ type="text",
663
+ value="",
664
+ bbox=seg,
665
+ layout_segments=[seg],
666
+ )
667
+ )
668
+
669
+ layout_pages.append(
670
+ ParseLayoutPageIR(
671
+ page_number=page_num,
672
+ width=_VIRTUAL_PAGE_DIM,
673
+ height=_VIRTUAL_PAGE_DIM,
674
+ items=items,
675
+ )
676
+ )
677
+
678
+ return layout_pages
679
+
680
+
681
+ def _get_block_text(block: dict[str, Any], block_index: dict[str, dict[str, Any]]) -> str:
682
+ """Extract text from a LAYOUT block by traversing child LINE blocks."""
683
+ relationships = block.get("Relationships", [])
684
+ lines: list[str] = []
685
+ for rel in relationships:
686
+ if rel.get("Type") != "CHILD":
687
+ continue
688
+ for child_id in rel.get("Ids", []):
689
+ child = block_index.get(child_id)
690
+ if child and child.get("BlockType") == "LINE":
691
+ text = child.get("Text", "")
692
+ if text:
693
+ lines.append(text)
694
+ return "\n".join(lines)