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,611 @@
1
+ """Shared layout parsing utilities for LLM-based parse_with_layout providers.
2
+
3
+ These utilities are used by Google, OpenAI, and Anthropic providers that
4
+ produce layout-annotated output using <div data-bbox="..." data-label="...">
5
+ HTML wrappers with the Core11 label set.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import math
13
+ import re
14
+ from typing import Any
15
+
16
+ from parse_bench.schemas.parse_output import (
17
+ LayoutItemIR,
18
+ LayoutSegmentIR,
19
+ ParseLayoutPageIR,
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Layout-annotated prompts (Core11 label set)
26
+ # ---------------------------------------------------------------------------
27
+
28
+ SYSTEM_PROMPT_LAYOUT = (
29
+ "You are a document parser. Your task is to convert "
30
+ "document images to clean, well-structured markdown."
31
+ "\n\nGuidelines:\n"
32
+ "- Preserve the document structure "
33
+ "(headings, paragraphs, lists, tables)\n"
34
+ "- Convert tables to HTML format "
35
+ "(<table>, <tr>, <th>, <td>)\n"
36
+ "- For existing tables in the document: use colspan "
37
+ "and rowspan attributes to preserve merged cells "
38
+ "and hierarchical headers\n"
39
+ "- For charts/graphs being converted to tables: use "
40
+ "flat combined column headers (e.g., "
41
+ '"Primary 2015" not separate rows) so each data '
42
+ "cell's row contains all its labels\n"
43
+ "- Describe images/figures briefly in square brackets "
44
+ "like [Figure: description]\n"
45
+ "- Preserve any code blocks with appropriate syntax "
46
+ "highlighting\n"
47
+ "- Maintain reading order (left-to-right, "
48
+ "top-to-bottom for Western documents)\n"
49
+ "- Do not add commentary or explanations "
50
+ "- only output the parsed content"
51
+ "\n\n"
52
+ "Additionally, wrap each layout element in a <div> tag with:\n"
53
+ '- data-bbox="[x1, y1, x2, y2]" — bounding box in normalized 0-1000 '
54
+ "coordinates where x is horizontal (left edge = 0, right edge = 1000) "
55
+ "and y is vertical (top = 0, bottom = 1000). "
56
+ "x1,y1 is the top-left corner and x2,y2 is the bottom-right corner.\n"
57
+ '- data-label="<category>" — one of: Caption, Footnote, Formula, '
58
+ "List-item, Page-footer, Page-header, Picture, Section-header, "
59
+ "Table, Text, Title\n\n"
60
+ "Place elements in reading order. Every piece of content must be "
61
+ "inside exactly one <div> wrapper."
62
+ )
63
+
64
+ USER_PROMPT_LAYOUT = (
65
+ "Parse this document page and output its content as "
66
+ "clean markdown, with each layout element wrapped in a "
67
+ '<div data-bbox="[x1,y1,x2,y2]" data-label="Category"> tag. '
68
+ "Use HTML tables for any tabular data. "
69
+ "For charts/graphs, use flat combined column headers. "
70
+ "Output ONLY the parsed content with div wrappers, "
71
+ "no explanations."
72
+ )
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Gemini-specific layout prompts — use native [y_min, x_min, y_max, x_max]
76
+ # format to avoid intermittent coordinate inversion when asking for [x1,y1,x2,y2].
77
+ # Callers must convert with swap_gemini_bbox() after parse_layout_blocks().
78
+ # ---------------------------------------------------------------------------
79
+
80
+ SYSTEM_PROMPT_LAYOUT_GEMINI = SYSTEM_PROMPT_LAYOUT.replace(
81
+ '"[x1, y1, x2, y2]" — bounding box in normalized 0-1000 '
82
+ "coordinates where x is horizontal (left edge = 0, right edge = 1000) "
83
+ "and y is vertical (top = 0, bottom = 1000). "
84
+ "x1,y1 is the top-left corner and x2,y2 is the bottom-right corner.",
85
+ '"[y_min, x_min, y_max, x_max]" — bounding box in normalized 0-1000 '
86
+ "coordinates where x is horizontal (left edge = 0, right edge = 1000) "
87
+ "and y is vertical (top = 0, bottom = 1000). "
88
+ "The order is [y_min, x_min, y_max, x_max].",
89
+ )
90
+
91
+ USER_PROMPT_LAYOUT_GEMINI = USER_PROMPT_LAYOUT.replace(
92
+ "[x1,y1,x2,y2]",
93
+ "[y_min,x_min,y_max,x_max]",
94
+ )
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Absolute-pixel prompt variants (bbox_scale=None in the provider config).
99
+ # Some models ground well but are unreliable at re-normalizing their bboxes
100
+ # into the 0-1000 range, which conflates grounding quality with coordinate
101
+ # compliance. These variants ask for the model's native pixel frame instead;
102
+ # build_layout_pages(bbox_scale=None) then normalizes by the image
103
+ # dimensions. Built by string replacement so the variants can never drift
104
+ # from the base prompts on anything except the coordinate bullet.
105
+ # ---------------------------------------------------------------------------
106
+
107
+ _DATA_BBOX_BULLET_NORM = (
108
+ '"[x1, y1, x2, y2]" — bounding box in normalized 0-1000 '
109
+ "coordinates where x is horizontal (left edge = 0, right edge = 1000) "
110
+ "and y is vertical (top = 0, bottom = 1000). "
111
+ "x1,y1 is the top-left corner and x2,y2 is the bottom-right corner."
112
+ )
113
+ _DATA_BBOX_BULLET_ABS = (
114
+ '"[x1, y1, x2, y2]" — bounding box in ABSOLUTE PIXEL coordinates '
115
+ "of the image, where x is horizontal (left edge = 0) and y is "
116
+ "vertical (top = 0). "
117
+ "x1,y1 is the top-left corner and x2,y2 is the bottom-right corner."
118
+ )
119
+
120
+ SYSTEM_PROMPT_LAYOUT_ABS = SYSTEM_PROMPT_LAYOUT.replace(_DATA_BBOX_BULLET_NORM, _DATA_BBOX_BULLET_ABS)
121
+ assert SYSTEM_PROMPT_LAYOUT_ABS != SYSTEM_PROMPT_LAYOUT, (
122
+ "data-bbox bullet replacement did not match SYSTEM_PROMPT_LAYOUT"
123
+ )
124
+
125
+ USER_PROMPT_LAYOUT_ABS = USER_PROMPT_LAYOUT.replace(
126
+ 'data-label="Category"> tag. ',
127
+ 'data-label="Category"> tag (data-bbox in absolute pixel coordinates). ',
128
+ )
129
+ assert USER_PROMPT_LAYOUT_ABS != USER_PROMPT_LAYOUT, "user-prompt replacement did not match USER_PROMPT_LAYOUT"
130
+
131
+ _DATA_BBOX_BULLET_GEMINI_NORM = (
132
+ '"[y_min, x_min, y_max, x_max]" — bounding box in normalized 0-1000 '
133
+ "coordinates where x is horizontal (left edge = 0, right edge = 1000) "
134
+ "and y is vertical (top = 0, bottom = 1000). "
135
+ "The order is [y_min, x_min, y_max, x_max]."
136
+ )
137
+ _DATA_BBOX_BULLET_GEMINI_ABS = (
138
+ '"[y_min, x_min, y_max, x_max]" — bounding box in ABSOLUTE PIXEL '
139
+ "coordinates of the image, where x is horizontal (left edge = 0) "
140
+ "and y is vertical (top = 0). "
141
+ "The order is [y_min, x_min, y_max, x_max]."
142
+ )
143
+
144
+ SYSTEM_PROMPT_LAYOUT_GEMINI_ABS = SYSTEM_PROMPT_LAYOUT_GEMINI.replace(
145
+ _DATA_BBOX_BULLET_GEMINI_NORM, _DATA_BBOX_BULLET_GEMINI_ABS
146
+ )
147
+ assert SYSTEM_PROMPT_LAYOUT_GEMINI_ABS != SYSTEM_PROMPT_LAYOUT_GEMINI, (
148
+ "data-bbox bullet replacement did not match SYSTEM_PROMPT_LAYOUT_GEMINI"
149
+ )
150
+
151
+ USER_PROMPT_LAYOUT_GEMINI_ABS = USER_PROMPT_LAYOUT_GEMINI.replace(
152
+ 'data-label="Category"> tag. ',
153
+ 'data-label="Category"> tag (data-bbox in absolute pixel coordinates). ',
154
+ )
155
+ assert USER_PROMPT_LAYOUT_GEMINI_ABS != USER_PROMPT_LAYOUT_GEMINI, (
156
+ "user-prompt replacement did not match USER_PROMPT_LAYOUT_GEMINI"
157
+ )
158
+
159
+
160
+ def resolve_layout_prompts(
161
+ bbox_scale: float | None,
162
+ mode: str | None,
163
+ *,
164
+ gemini: bool = False,
165
+ pixel_disallowed_modes: tuple[str, ...] = ("parse_with_layout_file",),
166
+ pixel_frame_supported: bool = False,
167
+ ) -> tuple[str, str]:
168
+ """Validate *bbox_scale* and return the (system, user) layout prompts.
169
+
170
+ ``bbox_scale`` follows the same convention as the layoutdet providers:
171
+ a number means the model's coordinates are on that grid (only 1000 is
172
+ supported here, since the prompt text hardcodes the range), and ``None``
173
+ means absolute pixel coordinates, normalized by the image dimensions.
174
+
175
+ Shared by the div-layout providers so the scale validation, the
176
+ scale/mode constraint, and the prompt-pair selection cannot drift
177
+ between them.
178
+
179
+ The pixel frame is normalized by the recorded page dimensions, so it
180
+ assumes the model perceives the image at the sent resolution. Provider
181
+ APIs downscale images past their native limits, and the model then
182
+ reports pixels in that downscaled frame while ``normalize()`` divides by
183
+ the dimensions we recorded — so a provider may only opt in
184
+ (``pixel_frame_supported=True``) once it pre-resizes each page to the
185
+ size the model actually perceives. Anthropic does this in
186
+ ``_resize_to_perceived_size``; the other div-layout providers do not,
187
+ and are rejected rather than allowed to report a silently shifted frame.
188
+
189
+ Raises:
190
+ ProviderConfigError: for an unsupported scale; for ``bbox_scale=None``
191
+ on a provider that cannot pin the perceived frame; or for
192
+ ``bbox_scale=None`` combined with a mode in
193
+ *pixel_disallowed_modes* (absolute pixel coordinates are
194
+ undefined when the model is sent the PDF file rather than a
195
+ rendered image).
196
+ """
197
+ from parse_bench.inference.providers.base import ProviderConfigError
198
+
199
+ if bbox_scale is not None and bbox_scale != 1000:
200
+ raise ProviderConfigError(
201
+ f"Unsupported bbox_scale {bbox_scale!r}. Use 1000 (normalized 0-1000, "
202
+ "the default) or None (absolute pixel coordinates)."
203
+ )
204
+ if bbox_scale is None and not pixel_frame_supported:
205
+ raise ProviderConfigError(
206
+ "bbox_scale=None (pixel coordinates) is not supported by this provider. "
207
+ "The provider API may downscale the page before the model sees it, so "
208
+ "the model's pixel coordinates would be in a different frame than the "
209
+ "recorded page dimensions. Use bbox_scale=1000 (the default), or add a "
210
+ "perceived-size pre-resize to the provider first."
211
+ )
212
+ if bbox_scale is None and mode in pixel_disallowed_modes:
213
+ raise ProviderConfigError(
214
+ f"bbox_scale=None (pixel coordinates) is not supported with mode "
215
+ f"'{mode}'; absolute pixel coordinates are undefined when the model "
216
+ "is sent the PDF file."
217
+ )
218
+ if bbox_scale is None:
219
+ if gemini:
220
+ return SYSTEM_PROMPT_LAYOUT_GEMINI_ABS, USER_PROMPT_LAYOUT_GEMINI_ABS
221
+ return SYSTEM_PROMPT_LAYOUT_ABS, USER_PROMPT_LAYOUT_ABS
222
+ if gemini:
223
+ return SYSTEM_PROMPT_LAYOUT_GEMINI, USER_PROMPT_LAYOUT_GEMINI
224
+ return SYSTEM_PROMPT_LAYOUT, USER_PROMPT_LAYOUT
225
+
226
+
227
+ def swap_gemini_bbox(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
228
+ """Convert Gemini native [y_min, x_min, y_max, x_max] to [x1, y1, x2, y2]."""
229
+ for item in items:
230
+ bbox = item.get("bbox", [])
231
+ if len(bbox) == 4:
232
+ y_min, x_min, y_max, x_max = bbox
233
+ item["bbox"] = [x_min, y_min, x_max, y_max]
234
+ return items
235
+
236
+
237
+ # Label mapping (case-insensitive raw label -> canonical label string)
238
+ LABEL_MAP: dict[str, str] = {
239
+ "caption": "Caption",
240
+ "footnote": "Footnote",
241
+ "formula": "Formula",
242
+ "list-item": "List-item",
243
+ "list_item": "List-item",
244
+ "page-footer": "Page-footer",
245
+ "page_footer": "Page-footer",
246
+ "page-header": "Page-header",
247
+ "page_header": "Page-header",
248
+ "picture": "Picture",
249
+ "figure": "Picture",
250
+ "section-header": "Section-header",
251
+ "section_header": "Section-header",
252
+ "table": "Table",
253
+ "text": "Text",
254
+ "title": "Title",
255
+ }
256
+
257
+
258
+ def split_pdf_to_pages(pdf_path: str) -> list[tuple[bytes, int, int]]:
259
+ """Split a PDF into single-page PDF bytes.
260
+
261
+ Returns a list of (pdf_bytes, width_px, height_px) tuples, one per page.
262
+ Width/height are at 72 DPI (PDF points).
263
+ """
264
+ import fitz # PyMuPDF
265
+
266
+ src = fitz.open(pdf_path)
267
+ results: list[tuple[bytes, int, int]] = []
268
+ for page_num in range(len(src)):
269
+ page = src[page_num]
270
+ rect = page.rect
271
+ # Create a single-page PDF in memory
272
+ dst = fitz.open()
273
+ dst.insert_pdf(src, from_page=page_num, to_page=page_num)
274
+ pdf_bytes = dst.tobytes()
275
+ dst.close()
276
+ results.append((pdf_bytes, int(rect.width), int(rect.height)))
277
+ src.close()
278
+ return results
279
+
280
+
281
+ _WRAPPER_TAG_RE = re.compile(r"<(/?)(\w+)((?:[^>\"']|\"[^\"]*\"|'[^']*')*)>")
282
+ _BBOX_ATTR_RE = re.compile(r"data-bbox=[\"'](\[[^\]]+\])[\"']", re.IGNORECASE)
283
+ _LABEL_ATTR_RE = re.compile(r"data-label=[\"']([^\"']+)[\"']", re.IGNORECASE)
284
+
285
+
286
+ def extract_layout_blocks_lenient(content: str) -> list[dict[str, Any]]:
287
+ """Parse layout elements tolerating wrapper tags other than ``<div>``.
288
+
289
+ ``parse_layout_blocks`` implements the contract the prompt asks for: a flat
290
+ list of ``<div data-bbox=... data-label=...>`` elements, each closed. Some
291
+ models answer with the same two attributes on a different tag and leave it
292
+ unclosed — Amazon Nova 2 emits ``<TABLE data-bbox=...>`` around a real
293
+ ``<table>`` with no ``</TABLE>`` at all, and wraps the page in an outer
294
+ ``<div data-bbox=...>``. Those are formatting differences, not missing
295
+ information: bbox, label and content are all present, so the element should
296
+ be scored on what it says rather than on which tag carried it.
297
+
298
+ An element runs from its opening tag until the first of: its own closing tag
299
+ (depth-aware over same-named tags, so an inner ``</table>`` does not end a
300
+ ``<TABLE>`` wrapper), the next element's opening tag, or end of content.
301
+ Taking the earliest of the three means an unclosed wrapper simply ends where
302
+ its sibling begins, which is how Nova's contiguous bands read anyway, and no
303
+ text can be dropped or attributed to two elements.
304
+
305
+ Elements whose span holds only whitespace are containers (a page-level
306
+ wrapper around the real elements) and are skipped.
307
+ """
308
+ tags = list(_WRAPPER_TAG_RE.finditer(content))
309
+ opens = [t for t in tags if not t.group(1)]
310
+ wrappers = [t for t in opens if "data-bbox=" in t.group(3).lower()]
311
+ if not wrappers:
312
+ return []
313
+
314
+ blocks: list[dict[str, Any]] = []
315
+ for position, wrapper in enumerate(wrappers):
316
+ bbox_match = _BBOX_ATTR_RE.search(wrapper.group(3))
317
+ label_match = _LABEL_ATTR_RE.search(wrapper.group(3))
318
+ if not bbox_match:
319
+ continue
320
+ try:
321
+ bbox = json.loads(bbox_match.group(1))
322
+ except json.JSONDecodeError:
323
+ logger.warning(f"Failed to parse bbox: {bbox_match.group(1)}")
324
+ continue
325
+ if not isinstance(bbox, list) or len(bbox) != 4:
326
+ continue
327
+
328
+ name = wrapper.group(2).lower()
329
+ end = len(content)
330
+ depth = 0
331
+ for tag in tags:
332
+ if tag.start() < wrapper.end():
333
+ continue
334
+ if tag.group(2).lower() != name:
335
+ continue
336
+ if tag.group(1):
337
+ if depth == 0:
338
+ end = tag.start()
339
+ break
340
+ depth -= 1
341
+ else:
342
+ depth += 1
343
+ if position + 1 < len(wrappers):
344
+ end = min(end, wrappers[position + 1].start())
345
+
346
+ text = content[wrapper.end() : end].strip()
347
+ if not text:
348
+ continue
349
+ blocks.append({"bbox": bbox, "label": label_match.group(1) if label_match else "Text", "text": text})
350
+
351
+ return blocks
352
+
353
+
354
+ def close_open_ended_bands(items: list[dict[str, Any]], page_extent: float = 1000.0) -> list[dict[str, Any]]:
355
+ """Give an element a bottom edge when the model pinned it to the page bottom.
356
+
357
+ Amazon Nova 2 frequently emits ``[0, y1, 1000, 1000]`` for every element: the
358
+ left edge, right edge and top are real, but the bottom is left at the page
359
+ bottom, so each box swallows the whole page below it. A box like that fails
360
+ localisation (``IoA(pred, gt)`` collapses) even though its ``y1`` is correct,
361
+ which scores the model at zero for an output that does carry position.
362
+
363
+ The model states its own convention whenever it fills the field in: the boxes
364
+ come back as contiguous full-width bands, ``[0, 301, 1000, 456]`` followed by
365
+ ``[0, 456, 1000, 480]`` — one band's bottom is the next band's top. This
366
+ applies exactly that reading, so an open-ended band ends where its successor
367
+ begins. A full-width band with a correct vertical extent localises fine
368
+ (``IoA(pred, gt)`` is the ground-truth box's width fraction, comfortably over
369
+ the 0.20 floor), so this recovers real credit rather than manufacturing it.
370
+
371
+ Only boxes whose bottom sits at the page extent are touched, and only when a
372
+ later element starts strictly lower. Boxes with a real bottom edge, the final
373
+ element, and degenerate output where every element shares one top edge are all
374
+ left exactly as they are.
375
+ """
376
+ for index, item in enumerate(items):
377
+ bbox = item.get("bbox")
378
+ if not isinstance(bbox, list) or len(bbox) != 4:
379
+ continue
380
+ y1, y2 = bbox[1], bbox[3]
381
+ if y2 < page_extent:
382
+ continue
383
+ for following in items[index + 1 :]:
384
+ next_bbox = following.get("bbox")
385
+ if not isinstance(next_bbox, list) or len(next_bbox) != 4:
386
+ continue
387
+ if next_bbox[1] > y1:
388
+ bbox[3] = next_bbox[1]
389
+ break
390
+ return items
391
+
392
+
393
+ def parse_layout_blocks(content: str) -> list[dict[str, Any]]:
394
+ """Parse <div data-bbox="..." data-label="...">content</div> blocks.
395
+
396
+ Handles both attribute orderings. Returns list of dicts with
397
+ 'bbox' (list[float]), 'label' (str), and 'text' (str) keys.
398
+ """
399
+ blocks: list[dict[str, Any]] = []
400
+
401
+ # Match opening div with both attribute orders
402
+ pattern_bbox_first = re.compile(
403
+ r'<div\s+[^>]*?data-bbox=["\'](\[[^\]]+\])["\'][^>]*?data-label=["\']([^"\']+)["\'][^>]*?>'
404
+ r"([\s\S]*?)</div>",
405
+ re.IGNORECASE,
406
+ )
407
+ pattern_label_first = re.compile(
408
+ r'<div\s+[^>]*?data-label=["\']([^"\']+)["\'][^>]*?data-bbox=["\'](\[[^\]]+\])["\'][^>]*?>'
409
+ r"([\s\S]*?)</div>",
410
+ re.IGNORECASE,
411
+ )
412
+
413
+ # Collect all matches with their start positions, then sort by
414
+ # position so mixed attribute orderings preserve document order.
415
+ raw_matches: list[tuple[int, str, str, str]] = [] # (pos, bbox_str, label, text)
416
+
417
+ for match in pattern_bbox_first.finditer(content):
418
+ raw_matches.append((match.start(), match.group(1), match.group(2), match.group(3)))
419
+
420
+ for match in pattern_label_first.finditer(content):
421
+ raw_matches.append((match.start(), match.group(2), match.group(1), match.group(3)))
422
+
423
+ raw_matches.sort(key=lambda m: m[0])
424
+
425
+ seen_positions: set[int] = set()
426
+ for pos, bbox_str, label, text in raw_matches:
427
+ if pos in seen_positions:
428
+ continue # skip duplicate from overlapping patterns
429
+ seen_positions.add(pos)
430
+ try:
431
+ bbox = json.loads(bbox_str)
432
+ if isinstance(bbox, list) and len(bbox) == 4:
433
+ blocks.append({"bbox": bbox, "label": label, "text": text.strip()})
434
+ except json.JSONDecodeError:
435
+ logger.warning(f"Failed to parse bbox: {bbox_str}")
436
+
437
+ return blocks
438
+
439
+
440
+ # A markdown ATX heading marker: optional indent, 1-6 hashes, then whitespace.
441
+ # The trailing whitespace is required (as in CommonMark), so literal text like
442
+ # "#1 Best Seller" is not mistaken for a marker.
443
+ _LEADING_HEADING_MARKER = re.compile(r"^[ \t]*(#{1,6})[ \t]+")
444
+
445
+
446
+ def _strip_heading_marker(text: str) -> str:
447
+ """Strip a leading markdown heading marker, if any, from item text."""
448
+ match = _LEADING_HEADING_MARKER.match(text)
449
+ if not match:
450
+ return text
451
+ bare = text[match.end() :].strip()
452
+ return bare if bare else text
453
+
454
+
455
+ def _strip_outer_formula_delimiters(text: str) -> str:
456
+ """Strip one outer pair of ``$$``/``$`` delimiters when unambiguous.
457
+
458
+ Only strips when the interior contains no further ``$``, so multi-formula
459
+ items (``$a$ + $b$``) and text with interior dollar signs pass through
460
+ verbatim rather than being corrupted.
461
+ """
462
+ stripped = text.strip()
463
+ for delim in ("$$", "$"):
464
+ if len(stripped) > 2 * len(delim) and stripped.startswith(delim) and stripped.endswith(delim):
465
+ interior = stripped[len(delim) : -len(delim)]
466
+ if "$" not in interior and interior.strip():
467
+ return interior.strip()
468
+ return text
469
+
470
+
471
+ def items_to_markdown(items: list[dict[str, Any]]) -> str:
472
+ """Assemble clean markdown from parsed layout items.
473
+
474
+ The layout prompt asks for clean markdown inside each div, so models
475
+ frequently emit heading markers (``# Heading``) and formula delimiters
476
+ (``$$...$$``) in the item text already. Any existing markers are stripped
477
+ before the label-derived ones are applied, so they don't double up
478
+ (``# # Heading``, nested ``$$`` blocks) — doubled markers defeat the
479
+ heading/formula detection in downstream scoring. Title still always
480
+ renders as H1 and Section-header as H2.
481
+ """
482
+ parts: list[str] = []
483
+ for item in items:
484
+ label = item.get("label", "").lower()
485
+ text = item.get("text", "")
486
+ if not text:
487
+ continue
488
+ if label == "title":
489
+ parts.append(f"# {_strip_heading_marker(text)}")
490
+ elif label in ("section-header", "section_header"):
491
+ parts.append(f"## {_strip_heading_marker(text)}")
492
+ elif label == "formula":
493
+ parts.append(f"$$\n{_strip_outer_formula_delimiters(text)}\n$$")
494
+ else:
495
+ parts.append(text)
496
+ return "\n\n".join(parts)
497
+
498
+
499
+ # Coordinates may overshoot their grid by rounding noise; values beyond
500
+ # scale * this tolerance indicate the model did not follow the coordinate
501
+ # instruction (see the compliance warning in ``build_layout_pages``).
502
+ _BBOX_ROUNDING_TOLERANCE = 1.05
503
+
504
+
505
+ def _is_finite_bbox(bbox: Any) -> bool:
506
+ """True when *bbox* is four finite numeric (non-bool) values."""
507
+ return (
508
+ isinstance(bbox, list)
509
+ and len(bbox) == 4
510
+ and all(isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v) for v in bbox)
511
+ )
512
+
513
+
514
+ def build_layout_pages(
515
+ items: list[dict[str, Any]],
516
+ image_width: int,
517
+ image_height: int,
518
+ markdown: str,
519
+ page_number: int = 1,
520
+ bbox_scale: float | None = 1000.0,
521
+ ) -> list[ParseLayoutPageIR]:
522
+ """Convert parsed layout blocks to ParseLayoutPageIR.
523
+
524
+ Args:
525
+ items: Parsed layout blocks from ``parse_layout_blocks``.
526
+ image_width: Page image width in pixels.
527
+ image_height: Page image height in pixels.
528
+ markdown: Page markdown content.
529
+ page_number: 1-indexed page number.
530
+ bbox_scale: The grid the bboxes are on (same convention as the
531
+ layoutdet providers): a number divides coordinates by that
532
+ scale; ``None`` means absolute pixel coordinates (the
533
+ ``bbox_scale=None`` pipelines, prompted with the ``*_ABS``
534
+ variants), normalized by the image dimensions.
535
+ """
536
+ if not items or not image_width or not image_height:
537
+ return []
538
+
539
+ if bbox_scale is None:
540
+ scale_x = float(image_width)
541
+ scale_y = float(image_height)
542
+ else:
543
+ scale_x = scale_y = float(bbox_scale)
544
+
545
+ compliance_warned = False
546
+ layout_items: list[LayoutItemIR] = []
547
+ for item in items:
548
+ bbox = item.get("bbox", [])
549
+ label_raw = item.get("label", "text")
550
+ text = item.get("text", "")
551
+
552
+ # Clamping a non-finite value would silently place it at the page
553
+ # edge (min(scale, nan) returns scale), so skip boxes the clamp
554
+ # cannot handle honestly.
555
+ if not _is_finite_bbox(bbox):
556
+ if len(bbox) == 4:
557
+ logger.warning(f"Skipping layout item with non-numeric bbox: {bbox!r}")
558
+ continue
559
+
560
+ if (
561
+ bbox_scale is not None
562
+ and not compliance_warned
563
+ and any(v > bbox_scale * _BBOX_ROUNDING_TOLERANCE for v in bbox)
564
+ ):
565
+ logger.warning(
566
+ f"Page {page_number}: bbox coordinates exceed the 0-{bbox_scale:g} "
567
+ "range; the model may be emitting absolute pixels (consider "
568
+ "bbox_scale=None)"
569
+ )
570
+ compliance_warned = True
571
+
572
+ x1, y1, x2, y2 = bbox
573
+
574
+ # Clamp to the page so out-of-range values cannot produce segments
575
+ # outside the unit square.
576
+ x1 = max(0.0, min(scale_x, x1))
577
+ x2 = max(0.0, min(scale_x, x2))
578
+ y1 = max(0.0, min(scale_y, y1))
579
+ y2 = max(0.0, min(scale_y, y2))
580
+
581
+ # Convert [x1,y1,x2,y2] to normalized [0,1] COCO [x,y,w,h]
582
+ nx = x1 / scale_x
583
+ ny = y1 / scale_y
584
+ nw = (x2 - x1) / scale_x
585
+ nh = (y2 - y1) / scale_y
586
+
587
+ label = LABEL_MAP.get(label_raw.lower(), "Text")
588
+ seg = LayoutSegmentIR(x=nx, y=ny, w=nw, h=nh, confidence=1.0, label=label)
589
+
590
+ norm_label = label_raw.lower()
591
+ if norm_label == "table":
592
+ item_type = "table"
593
+ elif norm_label in ("picture", "figure"):
594
+ item_type = "image"
595
+ else:
596
+ item_type = "text"
597
+
598
+ layout_items.append(LayoutItemIR(type=item_type, value=text, bbox=seg, layout_segments=[seg]))
599
+
600
+ if not layout_items:
601
+ return []
602
+
603
+ return [
604
+ ParseLayoutPageIR(
605
+ page_number=page_number,
606
+ width=float(image_width),
607
+ height=float(image_height),
608
+ md=markdown,
609
+ items=layout_items,
610
+ )
611
+ ]