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,704 @@
1
+ """Provider for Infinity-Parser2 PARSE via infinity_parser2 SDK with vLLM server."""
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ import traceback
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from pdf2image import convert_from_path
12
+ from PIL import Image as PILImage
13
+
14
+ from parse_bench.inference.providers.base import (
15
+ Provider,
16
+ ProviderConfigError,
17
+ ProviderPermanentError,
18
+ ProviderTransientError,
19
+ )
20
+ from parse_bench.inference.providers.registry import register_provider
21
+ from parse_bench.schemas.parse_output import PageIR, ParseLayoutPageIR, ParseOutput
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
+ logger = logging.getLogger(__name__)
31
+
32
+ DEFAULT_MODEL_NAME = "infly/Infinity-Parser2-Flash"
33
+
34
+ # Infinity-Parser2 category → Canonical17 label mapping
35
+ INFINITY_CATEGORY_MAP: dict[str, str] = {
36
+ "header": "Page-header",
37
+ "title": "Section-header",
38
+ "text": "Text",
39
+ "figure": "Picture",
40
+ "table": "Table",
41
+ "formula": "Formula",
42
+ "figure_caption": "Caption",
43
+ "table_caption": "Caption",
44
+ "formula_caption": "Caption",
45
+ "figure_footnote": "Footnote",
46
+ "table_footnote": "Footnote",
47
+ "page_footnote": "Footnote",
48
+ "footer": "Page-footer",
49
+ }
50
+
51
+
52
+ @register_provider("infinity_parser2")
53
+ class InfinityParser2Provider(Provider):
54
+ """
55
+ Provider for Infinity-Parser2 via the infinity_parser2 SDK.
56
+
57
+ Infinity-Parser2 is a document understanding model that converts PDFs
58
+ and images to structured markdown/JSON. This provider uses the
59
+ ``vllm-server`` backend which communicates with a running vLLM OpenAI-
60
+ compatible server over HTTP. This avoids thread-safety issues in the
61
+ ``vllm-engine`` backend when running concurrent requests.
62
+
63
+ Configuration options:
64
+ - model_name (str, default="infly/Infinity-Parser2-Flash"): Model name (must match server)
65
+ - api_url (str, default="http://localhost:8000/v1/chat/completions"): vLLM server endpoint
66
+ - api_key (str, default="EMPTY"): API key for the server
67
+ - timeout (int, default=900): Request timeout in seconds (the pro model can
68
+ exceed 300 s on dense pages)
69
+ - task_type (str, default="doc2json"): Parse task type
70
+ - output_format (str, default="json"): Output format (json returns per-element layout with bboxes)
71
+ - batch_size (int, default=4): Batch size for processing
72
+ - max_new_tokens (int, default=None): Override max tokens for generation
73
+ - temperature (float, default=0.0): Sampling temperature
74
+ - deep_parsing_mode (bool, default=True): Parse figure content.
75
+ """
76
+
77
+ def __init__(self, provider_name: str, base_config: dict[str, Any] | None = None):
78
+ super().__init__(provider_name, base_config)
79
+
80
+ self._model_name = self.base_config.get("model_name", DEFAULT_MODEL_NAME)
81
+ self._api_url = self.base_config.get("api_url", "http://localhost:8000/v1/chat/completions")
82
+ self._api_key = self.base_config.get("api_key", "EMPTY")
83
+ self._timeout = self.base_config.get("timeout", 900)
84
+ self._task_type = self.base_config.get("task_type", "doc2json")
85
+ self._output_format = self.base_config.get("output_format", "json")
86
+ self._batch_size = self.base_config.get("batch_size", 4)
87
+ self._max_new_tokens = self.base_config.get("max_new_tokens")
88
+ self._temperature = self.base_config.get("temperature", 0.0)
89
+ self._deep_parsing_mode = self.base_config.get("deep_parsing_mode", True)
90
+
91
+ try:
92
+ from infinity_parser2 import InfinityParser2
93
+ except ImportError as e:
94
+ traceback.print_exc()
95
+ raise ProviderConfigError("import infinity_parser2 failed") from e
96
+
97
+ kwargs: dict[str, Any] = {
98
+ "model_name": self._model_name,
99
+ "backend": "vllm-server",
100
+ "api_url": self._api_url,
101
+ "api_key": self._api_key,
102
+ "timeout": self._timeout,
103
+ }
104
+
105
+ self._parser = InfinityParser2(**kwargs)
106
+
107
+ def _parse_page(self, pil_image: PILImage.Image, page_width: float, page_height: float) -> dict[str, Any]:
108
+ """Parse a single rendered page image and return the legacy one-page raw shape."""
109
+ parse_kwargs: dict[str, Any] = {
110
+ "task_type": self._task_type,
111
+ "batch_size": self._batch_size,
112
+ }
113
+
114
+ if self._output_format:
115
+ parse_kwargs["output_format"] = self._output_format
116
+
117
+ if self._max_new_tokens is not None:
118
+ parse_kwargs["max_new_tokens"] = self._max_new_tokens
119
+
120
+ if "temperature" in self.base_config:
121
+ parse_kwargs["temperature"] = self._temperature
122
+
123
+ result = self._parser.parse(pil_image, **parse_kwargs)
124
+
125
+ if self._deep_parsing_mode:
126
+ result = self._apply_deep_parsing(result, pil_image)
127
+
128
+ return {
129
+ "result": result,
130
+ "_config": {
131
+ "model_name": self._model_name,
132
+ "backend": "vllm-server",
133
+ "api_url": self._api_url,
134
+ "task_type": self._task_type,
135
+ "output_format": self._output_format,
136
+ "batch_size": self._batch_size,
137
+ "page_width": page_width,
138
+ "page_height": page_height,
139
+ },
140
+ }
141
+
142
+ def _parse_pages(self, pages: list[tuple[PILImage.Image, float, float]]) -> dict[str, Any]:
143
+ """Parse every rendered page in order.
144
+
145
+ Single-page inputs keep the legacy raw_output shape; multi-page inputs
146
+ add ``page_results`` (one legacy-shaped dict per page) which
147
+ ``normalize`` reassembles in page order. The previous implementation
148
+ rendered only the first PDF page, silently parsing just the cover page
149
+ of multi-page documents.
150
+ """
151
+ results = [self._parse_page(image, width, height) for image, width, height in pages]
152
+ first = results[0]
153
+ if len(results) == 1:
154
+ return first
155
+ merged = dict(first)
156
+ merged["page_results"] = results
157
+ return merged
158
+
159
+ def _parse_document(self, file_path: str) -> dict[str, Any]:
160
+ """
161
+ Parse a document using InfinityParser2.
162
+
163
+ :param file_path: Path to the PDF or image file
164
+ :return: Raw parsing result
165
+ """
166
+ try:
167
+ return self._parse_pages(load_images(file_path))
168
+
169
+ except Exception as e:
170
+ error_str = str(e).lower()
171
+ transient_keywords = ["timeout", "network", "connection", "cuda", "out of memory", "oom"]
172
+ if any(keyword in error_str for keyword in transient_keywords):
173
+ raise ProviderTransientError(f"Error during parsing (GPU/memory): {e}") from e
174
+ raise ProviderPermanentError(f"Error parsing document: {e}") from e
175
+
176
+ def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
177
+ if request.product_type != ProductType.PARSE:
178
+ raise ProviderPermanentError(
179
+ f"InfinityParser2Provider only supports PARSE product type, got {request.product_type}"
180
+ )
181
+
182
+ file_path = Path(request.source_file_path)
183
+ if not file_path.exists():
184
+ raise ProviderPermanentError(f"Source file not found: {file_path}")
185
+
186
+ started_at = datetime.now()
187
+
188
+ try:
189
+ raw_output = self._parse_document(str(file_path))
190
+ completed_at = datetime.now()
191
+ latency_ms = int((completed_at - started_at).total_seconds() * 1000)
192
+
193
+ return RawInferenceResult(
194
+ request=request,
195
+ pipeline=pipeline,
196
+ pipeline_name=pipeline.pipeline_name,
197
+ product_type=request.product_type,
198
+ raw_output=raw_output,
199
+ started_at=started_at,
200
+ completed_at=completed_at,
201
+ latency_in_ms=latency_ms,
202
+ )
203
+
204
+ except (ProviderPermanentError, ProviderTransientError, ProviderConfigError):
205
+ raise
206
+ except Exception as e:
207
+ raise ProviderPermanentError(f"Unexpected error during inference: {e}") from e
208
+
209
+ def _build_layout_segment(self, bbox: list, label: str) -> dict:
210
+ """Build a LayoutSegmentIR from a bbox."""
211
+ if len(bbox) == 4:
212
+ x1, y1, x2, y2 = bbox
213
+ x, y, w, h = float(x1), float(y1), float(x2 - x1), float(y2 - y1)
214
+ else:
215
+ x, y, w, h = 0.0, 0.0, 0.0, 0.0
216
+
217
+ return {
218
+ "x": x,
219
+ "y": y,
220
+ "w": w,
221
+ "h": h,
222
+ "confidence": 1.0,
223
+ "label": label,
224
+ "start_index": None,
225
+ "end_index": None,
226
+ }
227
+
228
+ def _reassemble_text(self, label: str, text: str) -> str:
229
+ """Reassemble text content based on label."""
230
+ if not text:
231
+ return ""
232
+
233
+ if label == "Section-header":
234
+ return f"# {text.lstrip('# ')}"
235
+ elif label == "Formula":
236
+ stripped = re.sub(r"^[\s$\(\)\[\]]+|[\s$\(\)\[\]]+$", "", text)
237
+ return f"$${stripped}$$"
238
+ elif label == "Picture":
239
+ text = _convert_nonstandard_table(text)
240
+ return text
241
+ elif label == "Table":
242
+ return _convert_table_header(text)
243
+ else:
244
+ return text
245
+
246
+ def _build_layout_item(self, elem: dict, label: str) -> dict:
247
+ """Build a single LayoutItemIR from an infinity-parser2 JSON element."""
248
+ bbox = elem.get("bbox", [0, 0, 0, 0])
249
+ text = elem.get("text", "")
250
+
251
+ layout_seg = self._build_layout_segment(bbox, label)
252
+ text = self._reassemble_text(label, text)
253
+
254
+ return {
255
+ "type": label,
256
+ "md": text,
257
+ "html": text if label == "Table" else "",
258
+ "value": text,
259
+ "bbox": layout_seg,
260
+ "layout_segments": [layout_seg],
261
+ }
262
+
263
+ def _apply_deep_parsing(
264
+ self,
265
+ result: str,
266
+ pil_image: PILImage.Image,
267
+ ) -> str:
268
+ """Apply deep parsing on figure elements, re-parsing cropped figure images as markdown tables.
269
+
270
+ Extracts all ``figure`` elements from the parsed JSON, crops each figure region from
271
+ ``pil_image``, re-parses the cropped images with a custom table-extraction prompt,
272
+ and overwrites ``elem["text"]`` in place before serializing back to JSON.
273
+
274
+ Returns the (possibly modified) JSON string.
275
+ """
276
+ try:
277
+ elements: list[dict] = json.loads(result)
278
+ if not isinstance(elements, list):
279
+ return result
280
+
281
+ figure_elements = [elem for elem in elements if elem.get("category", "").strip().lower() == "figure"]
282
+ if not figure_elements:
283
+ return result
284
+
285
+ pil_figure_images = [
286
+ pil_image.crop(
287
+ (
288
+ max(0, int(elem["bbox"][0])),
289
+ max(0, int(elem["bbox"][1])),
290
+ min(pil_image.width, int(elem["bbox"][2])),
291
+ min(pil_image.height, int(elem["bbox"][3])),
292
+ )
293
+ )
294
+ for elem in figure_elements
295
+ ]
296
+
297
+ deep_parse_kwargs = {
298
+ "task_type": "custom",
299
+ "custom_prompt": "please convert the image to a markdown table",
300
+ "max_new_tokens": 2048,
301
+ }
302
+ deep_results = [self._parser.parse(img, **deep_parse_kwargs) for img in pil_figure_images]
303
+ for elem, deep_result in zip(figure_elements, deep_results, strict=True):
304
+ elem["text"] = deep_result
305
+
306
+ return json.dumps(elements)
307
+
308
+ except Exception:
309
+ logger.exception("Deep parsing pass failed; returning shallow parse result")
310
+ return result
311
+
312
+ @staticmethod
313
+ def _load_elements(result_str: Any) -> list[dict]:
314
+ """Decode the model's JSON element list, tolerating malformed output."""
315
+ if isinstance(result_str, list):
316
+ return [e for e in result_str if isinstance(e, dict)]
317
+ if not isinstance(result_str, str) or not result_str:
318
+ return []
319
+ try:
320
+ elements = json.loads(result_str)
321
+ except json.JSONDecodeError:
322
+ return []
323
+ if not isinstance(elements, list):
324
+ return []
325
+ return [e for e in elements if isinstance(e, dict)]
326
+
327
+ def _normalize(self, raw_result: RawInferenceResult) -> ParseOutput:
328
+ """Normalize JSON layout result into ParseOutput with pages, layout_pages, and markdown.
329
+
330
+ Multi-page raw outputs carry one legacy-shaped dict per page under
331
+ ``page_results``; each is normalized as its own page (page numbers are
332
+ assigned from render order, not from the model's ``page`` field, which
333
+ is always 1 for a single rendered image).
334
+ """
335
+ page_results = raw_result.raw_output.get("page_results")
336
+ if not isinstance(page_results, list) or not page_results:
337
+ page_results = [raw_result.raw_output]
338
+
339
+ if not any(page_raw.get("result") for page_raw in page_results):
340
+ raise ProviderPermanentError(f"Empty result from InfinityParser2 for {raw_result.pipeline_name}")
341
+
342
+ pages: list[PageIR] = []
343
+ layout_pages: list[ParseLayoutPageIR] = []
344
+ markdown_parts: list[str] = []
345
+
346
+ for page_num, page_raw in enumerate(page_results, start=1):
347
+ page_config = page_raw.get("_config") or {}
348
+ page_width = page_config.get("page_width", 0)
349
+ page_height = page_config.get("page_height", 0)
350
+ page_elements = self._load_elements(page_raw.get("result", ""))
351
+
352
+ header_items: list[dict] = []
353
+ footer_items: list[dict] = []
354
+ regular_items: list[dict] = []
355
+
356
+ for elem in page_elements:
357
+ raw_cat = elem.get("category", "text").strip().lower()
358
+ norm_cat = INFINITY_CATEGORY_MAP.get(raw_cat, "Text")
359
+ item = self._build_layout_item(elem, norm_cat)
360
+ if norm_cat == "Page-header":
361
+ header_items.append(item)
362
+ elif norm_cat == "Page-footer":
363
+ footer_items.append(item)
364
+ else:
365
+ regular_items.append(item)
366
+
367
+ page_items = header_items + regular_items + footer_items
368
+ page_md_parts = [item.get("md", "") for item in page_items if item.get("md")]
369
+ page_md = "\n\n".join(page_md_parts)
370
+
371
+ header_md = " ".join(c.get("value", "") for c in header_items)
372
+ footer_md = " ".join(c.get("value", "") for c in footer_items)
373
+
374
+ layout_pages.append(
375
+ ParseLayoutPageIR(
376
+ page_number=page_num,
377
+ width=page_width,
378
+ height=page_height,
379
+ md=page_md,
380
+ text=page_md,
381
+ page_header_markdown=header_md,
382
+ page_footer_markdown=footer_md,
383
+ printed_page_number="",
384
+ original_orientation_angle=0,
385
+ items=page_items,
386
+ )
387
+ )
388
+
389
+ pages.append(PageIR(page_index=page_num - 1, markdown=page_md))
390
+ if page_md:
391
+ markdown_parts.append(page_md)
392
+
393
+ full_markdown = "\n\n".join(markdown_parts)
394
+
395
+ return ParseOutput(
396
+ task_type="parse",
397
+ example_id=raw_result.request.example_id,
398
+ pipeline_name=raw_result.pipeline_name,
399
+ pages=pages,
400
+ layout_pages=layout_pages,
401
+ markdown=full_markdown,
402
+ )
403
+
404
+ def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
405
+ if raw_result.product_type != ProductType.PARSE:
406
+ raise ProviderPermanentError(
407
+ f"InfinityParser2Provider only supports PARSE product type, got {raw_result.product_type}"
408
+ )
409
+
410
+ output = self._normalize(raw_result)
411
+
412
+ return InferenceResult(
413
+ request=raw_result.request,
414
+ pipeline_name=raw_result.pipeline_name,
415
+ product_type=raw_result.product_type,
416
+ raw_output=raw_result.raw_output,
417
+ output=output,
418
+ started_at=raw_result.started_at,
419
+ completed_at=raw_result.completed_at,
420
+ latency_in_ms=raw_result.latency_in_ms,
421
+ )
422
+
423
+
424
+ def load_images(file_path: str) -> list[tuple[PILImage.Image, float, float]]:
425
+ """Load a PDF or image file as PIL Images (one per page) with their dimensions.
426
+
427
+ - PDF: renders every page to an RGB image at 300 DPI, in source order.
428
+ - Image: opens and converts to RGB (single page).
429
+
430
+ Returns:
431
+ List of (PIL Image, width, height) tuples, width/height in pixels.
432
+ """
433
+ path = Path(file_path)
434
+ if path.suffix.lower() == ".pdf":
435
+ images = convert_from_path(str(path), dpi=300)
436
+ if not images:
437
+ raise ProviderPermanentError(f"Failed to render PDF pages: {file_path}")
438
+ pil_images = [image.convert("RGB") for image in images]
439
+ else:
440
+ pil_images = [PILImage.open(str(path)).convert("RGB")]
441
+
442
+ return [(image, float(image.size[0]), float(image.size[1])) for image in pil_images]
443
+
444
+
445
+ def load_image(file_path: str) -> tuple[PILImage.Image, float, float]:
446
+ """Load the first page of a PDF (or an image) as a PIL Image with its dimensions."""
447
+ return load_images(file_path)[0]
448
+
449
+
450
+ # =============================================================================
451
+ # Postprocess for chart2table
452
+ # =============================================================================
453
+
454
+
455
+ def _is_valid_md_table(table_text: str) -> bool:
456
+ """Check if a markdown table is valid (non-empty)."""
457
+ if not table_text or not table_text.strip():
458
+ return False
459
+
460
+ if not all(ch in table_text for ch in ["|", "-", "\n"]):
461
+ return False
462
+
463
+ stripped = table_text[table_text.find("|") : table_text.rfind("|") + 1]
464
+ stripped = re.sub(r"^\s*\|[\s\-:|]+\|\s*$", "", stripped, flags=re.MULTILINE)
465
+ if not stripped.replace(" ", "").replace("\n", "").replace("|", ""):
466
+ return False
467
+
468
+ return True
469
+
470
+
471
+ def _is_nonstandard_table(text: str) -> bool:
472
+ """Check if text is a non-standard markdown table (no leading '|', contains '&' separators)."""
473
+ if not text:
474
+ return False
475
+ stripped = text.strip()
476
+ if stripped.startswith("|"):
477
+ return False
478
+ return "&" in text
479
+
480
+
481
+ def _find_column_number(text: str) -> int:
482
+ """Find the column number from a nonstandard table.
483
+
484
+ Split the text by '&' and count '|' in each segment. The header row always
485
+ has the most pipes (full cells). Column count = max(pipe_counts) + 1.
486
+ """
487
+ if "&" not in text:
488
+ return 0
489
+ raw_segments = text.split("&")
490
+ segments = [s.strip() for s in raw_segments if s.strip()]
491
+ if not segments:
492
+ return 0
493
+ pipe_counts = [s.count("|") for s in segments]
494
+ return max(pipe_counts) + 1
495
+
496
+
497
+ def _find_all_separator_indices(text: str, col_num: int) -> list[int]:
498
+ """Identify which '&' characters are row-group separators based on pipe counts."""
499
+ if col_num == 0:
500
+ return []
501
+ expected_pipes = col_num - 1
502
+ sep_positions = []
503
+ prev_sep = -1
504
+ i = 0
505
+ while i < len(text):
506
+ amp = text.find("&", i)
507
+ if amp == -1:
508
+ break
509
+
510
+ # Count pipes between prev_sep+1 and amp-1
511
+ pipe_count = 0
512
+ for j in range(prev_sep + 1, amp):
513
+ if text[j] == "|":
514
+ pipe_count += 1
515
+
516
+ if pipe_count == expected_pipes:
517
+ sep_positions.append(amp)
518
+ prev_sep = amp
519
+
520
+ i = amp + 1
521
+ return sep_positions
522
+
523
+
524
+ def _convert_nonstandard_table(text: str) -> str:
525
+ """Convert a non-standard markdown table (with '&' row-group separators) to proper markdown table format."""
526
+ if not _is_nonstandard_table(text):
527
+ return text
528
+
529
+ col_num = _find_column_number(text)
530
+ if col_num == 0:
531
+ return text
532
+
533
+ sep_indices = _find_all_separator_indices(text, col_num)
534
+ if not sep_indices:
535
+ return text
536
+
537
+ segments = []
538
+ prev = 0
539
+ for idx in sep_indices:
540
+ segments.append(text[prev:idx].strip())
541
+ prev = idx + 1
542
+ segments.append(text[prev:].strip())
543
+
544
+ header = segments[0]
545
+ if not header.startswith("|"):
546
+ header = "| " + header
547
+ if not header.rstrip().endswith("|"):
548
+ header = header.rstrip() + " |"
549
+
550
+ separator = "| " + " | ".join(["---"] * col_num) + " |"
551
+ normalized_lines = [header, separator]
552
+
553
+ for seg in segments[1:]:
554
+ if not seg:
555
+ continue
556
+ cells = [c.strip() for c in seg.split("|") if c.strip()]
557
+ padded = cells + [""] * max(0, col_num - len(cells))
558
+ row = "| " + " | ".join(padded[:col_num]).rstrip() + " |"
559
+ normalized_lines.append(row)
560
+
561
+ return "\n".join(normalized_lines)
562
+
563
+
564
+ # =============================================================================
565
+ # Postprocess for HTML table header
566
+ # =============================================================================
567
+
568
+
569
+ def _is_year_cell(text: str) -> bool:
570
+ """Return True if text looks like a date/year (yyyy, yyyymm, yyyymmdd, etc.)."""
571
+ text = text.strip()
572
+ return bool(re.fullmatch(r"(19|20)\d{2,4}([-/]?\d{2}([-/]?\d{2})?)?", text))
573
+
574
+
575
+ def _is_gender_cell(text: str) -> bool:
576
+ """Return True if text looks like gender."""
577
+ text = text.strip().lower()
578
+ return text in ("male", "female", "non-binary", "other", "undisclosed")
579
+
580
+
581
+ def _is_pure_text_cell(text: str) -> bool:
582
+ """Return True if text contains no digits at all."""
583
+ text = text.strip()
584
+ return bool(text) and any(c.isalpha() for c in text)
585
+
586
+
587
+ def _is_pure_number_cell(text: str) -> bool:
588
+ """Return True if text looks like a pure numeric value.
589
+
590
+ Accepts numbers with commas, decimals, dollar sign, percent sign,
591
+ plus/minus sign, and parentheses (for negative numbers).
592
+ """
593
+ text = text.strip()
594
+ if not text:
595
+ return False
596
+ # Allow: digits, comma, dot, minus, plus, $, %, parentheses
597
+ allowed = set("0123456789,.-+()$% ")
598
+ return all(c in allowed for c in text)
599
+
600
+
601
+ def _determine_header_row_count(rows: list) -> int:
602
+ """Determine how many top rows are header rows (year/gender/value rules + rowspan fallback)."""
603
+ if not rows:
604
+ return 0
605
+
606
+ def non_empty_cells(row):
607
+ return [td.get_text(strip=True) for td in row.find_all("td", recursive=False) if td.get_text(strip=True)]
608
+
609
+ def stats(row_list):
610
+ """Return (pure_text_count, pure_number_count, total) for a list of rows."""
611
+ text_count = number_count = total = 0
612
+ for row in row_list:
613
+ for cell in non_empty_cells(row):
614
+ total += 1
615
+ if _is_pure_text_cell(cell):
616
+ text_count += 1
617
+ elif _is_pure_number_cell(cell):
618
+ number_count += 1
619
+ return text_count, number_count, total
620
+
621
+ # Rule 1: Year
622
+ for i, row in enumerate(rows):
623
+ if i > 3:
624
+ break
625
+ cells = non_empty_cells(row)
626
+ if not cells:
627
+ continue
628
+ year_count = sum(1 for c in cells if _is_year_cell(c))
629
+ if year_count / len(cells) >= 0.5:
630
+ return i + 1
631
+
632
+ # Rule 2: Gender
633
+ for i, row in enumerate(rows):
634
+ if i > 3:
635
+ break
636
+ cells = non_empty_cells(row)
637
+ if not cells:
638
+ continue
639
+ gender_count = sum(1 for c in cells if _is_gender_cell(c))
640
+ if gender_count / len(cells) >= 0.5:
641
+ return i + 1
642
+
643
+ # Rule 3: Value (pure-text header region followed by pure-number data region)
644
+ best_i = -1
645
+ best_score = -1.0
646
+ for i in range(3):
647
+ header_rows = rows[: i + 1]
648
+ data_rows = rows[i + 1 :]
649
+ if not header_rows or not data_rows:
650
+ continue
651
+ header_text, header_num, header_total = stats(header_rows)
652
+ data_text, data_num, data_total = stats(data_rows)
653
+ if header_total == 0 or data_total == 0:
654
+ continue
655
+ if header_text / header_total >= 0.5 and data_num / data_total >= 0.5:
656
+ score = header_text / header_total + data_num / data_total
657
+ if score > best_score:
658
+ best_score = score
659
+ best_i = i
660
+ if best_i >= 0:
661
+ return best_i + 1
662
+
663
+ # Rule 4: Fallback — max rowspan in row 0
664
+ first_row = rows[0]
665
+ max_rowspan = 1
666
+ for td in first_row.find_all("td", recursive=False):
667
+ rowspan = int(td.get("rowspan", 1))
668
+ if rowspan > max_rowspan:
669
+ max_rowspan = rowspan
670
+ return max_rowspan
671
+
672
+
673
+ def _convert_table_header(html: str) -> str:
674
+ """Convert <td> tags in HTML table header rows to <th> for TEDS/GriTS evaluation."""
675
+ if not html or "<table" not in html.lower():
676
+ return html
677
+
678
+ try:
679
+ from bs4 import BeautifulSoup
680
+ except ImportError:
681
+ return html
682
+
683
+ soup = BeautifulSoup(html, "html.parser")
684
+ tables = soup.find_all("table")
685
+
686
+ for table in tables:
687
+ rows = table.find_all("tr", recursive=False)
688
+ if not rows:
689
+ continue
690
+
691
+ header_row_count = _determine_header_row_count(rows)
692
+
693
+ for i, row in enumerate(rows):
694
+ if i >= header_row_count:
695
+ break
696
+ tds = row.find_all("td", recursive=False)
697
+ for td in tds:
698
+ new_th = soup.new_tag("th")
699
+ for key, value in td.attrs.items():
700
+ new_th[key] = value
701
+ new_th.string = td.get_text()
702
+ td.replace_with(new_th)
703
+
704
+ return str(soup)