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,549 @@
1
+ """Helpers for normalizing provider field citation bboxes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ if TYPE_CHECKING:
9
+ from parse_bench.schemas.extract_output import FieldCitation
10
+ else:
11
+ FieldCitation = Any
12
+
13
+ _STRUCTURAL_KEYS = {
14
+ "citation",
15
+ "citations",
16
+ "document_metadata",
17
+ "field_metadata",
18
+ "fields",
19
+ "metadata",
20
+ "page_metadata",
21
+ "properties",
22
+ "row_metadata",
23
+ }
24
+
25
+
26
+ def _field_citation_cls() -> type[Any]:
27
+ from parse_bench.schemas.extract_output import FieldCitation as _FieldCitation
28
+
29
+ return _FieldCitation
30
+
31
+
32
+ def extract_extend_field_citations(raw_output: Mapping[str, Any]) -> list[FieldCitation]:
33
+ """Extract citations from Extend AI processor-run metadata."""
34
+ output = _as_mapping(_as_mapping(raw_output.get("processor_run")).get("output"))
35
+ metadata = _as_mapping(output.get("metadata"))
36
+ return _dedupe(_collect_field_map(metadata, source="extend"))
37
+
38
+
39
+ def extract_llamaextract_field_citations(metadata: Any, *, source: str) -> list[FieldCitation]:
40
+ """Extract citations from LlamaExtract metadata in known and fallback shapes."""
41
+ metadata_map = _as_mapping(metadata)
42
+ if not metadata_map:
43
+ return []
44
+
45
+ citations: list[FieldCitation] = []
46
+
47
+ for key in ("field_metadata", "document_metadata", "fields"):
48
+ citations.extend(_collect_field_map(_as_mapping(metadata_map.get(key)), source=source))
49
+
50
+ for key in ("page_metadata", "row_metadata"):
51
+ entries = metadata_map.get(key)
52
+ if not isinstance(entries, Sequence) or isinstance(entries, (str, bytes, bytearray)):
53
+ continue
54
+ for entry in entries:
55
+ entry_map = _as_mapping(entry)
56
+ default_page = _extract_page(entry_map)
57
+ default_dimensions = _extract_dimensions(entry_map)
58
+ for field_key in ("field_metadata", "document_metadata", "fields"):
59
+ citations.extend(
60
+ _collect_field_map(
61
+ _as_mapping(entry_map.get(field_key)),
62
+ source=source,
63
+ default_page=default_page,
64
+ default_dimensions=default_dimensions,
65
+ )
66
+ )
67
+
68
+ citations.extend(_collect_recursive(node=metadata_map, source=source, path=[]))
69
+ return _dedupe(citations)
70
+
71
+
72
+ def _collect_field_map(
73
+ field_map: Mapping[str, Any],
74
+ *,
75
+ source: str,
76
+ default_page: int | None = None,
77
+ default_dimensions: tuple[float, float] | None = None,
78
+ ) -> list[FieldCitation]:
79
+ citations: list[FieldCitation] = []
80
+ for field_path, node in field_map.items():
81
+ if field_path.startswith("_"):
82
+ continue
83
+ citations.extend(
84
+ _collect_node_citations(
85
+ field_path=field_path,
86
+ node=node,
87
+ source=source,
88
+ default_page=default_page,
89
+ default_dimensions=default_dimensions,
90
+ )
91
+ )
92
+ return citations
93
+
94
+
95
+ def _collect_node_citations(
96
+ *,
97
+ field_path: str,
98
+ node: Any,
99
+ source: str,
100
+ default_page: int | None,
101
+ default_dimensions: tuple[float, float] | None,
102
+ ) -> list[FieldCitation]:
103
+ node_map = _as_mapping(node)
104
+ if not node_map:
105
+ return []
106
+
107
+ page = _extract_page(node_map) or default_page
108
+ dimensions = _extract_dimensions(node_map) or default_dimensions
109
+ citations: list[FieldCitation] = []
110
+ for citation in _iter_citation_entries(node_map):
111
+ citations.extend(
112
+ _normalize_citation(
113
+ field_path=field_path,
114
+ citation=citation,
115
+ source=source,
116
+ default_page=page,
117
+ default_dimensions=dimensions,
118
+ )
119
+ )
120
+ return citations
121
+
122
+
123
+ def _iter_citation_entries(node: Mapping[str, Any]) -> list[Any]:
124
+ """Iterate citation entries supporting both plural `citations` and singular `citation` keys."""
125
+ entries: list[Any] = []
126
+ for key in ("citations", "citation"):
127
+ for entry in _as_sequence(node.get(key)):
128
+ entries.append(entry)
129
+ return entries
130
+
131
+
132
+ def _collect_recursive(*, node: Any, source: str, path: list[str]) -> list[FieldCitation]:
133
+ node_map = _as_mapping(node)
134
+ if not node_map:
135
+ return []
136
+
137
+ citations: list[FieldCitation] = []
138
+ explicit_path = _extract_field_path(node_map)
139
+ field_path = explicit_path or _format_field_path(path)
140
+ if field_path:
141
+ for citation in _iter_citation_entries(node_map):
142
+ citations.extend(
143
+ _normalize_citation(
144
+ field_path=field_path,
145
+ citation=citation,
146
+ source=source,
147
+ default_page=_extract_page(node_map),
148
+ default_dimensions=_extract_dimensions(node_map),
149
+ )
150
+ )
151
+
152
+ for key, value in node_map.items():
153
+ if key in ("citations", "citation"):
154
+ continue
155
+ next_path = path if key in _STRUCTURAL_KEYS else [*path, key]
156
+ if isinstance(value, Mapping):
157
+ citations.extend(_collect_recursive(node=value, source=source, path=next_path))
158
+ elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
159
+ for index, item in enumerate(value):
160
+ item_path = next_path if key in _STRUCTURAL_KEYS else [*next_path, f"[{index}]"]
161
+ citations.extend(_collect_recursive(node=item, source=source, path=item_path))
162
+
163
+ return citations
164
+
165
+
166
+ def _format_field_path(path: list[str]) -> str:
167
+ """Render path tokens so list-index tokens (`[N]`) attach to the prior key without a dot.
168
+
169
+ GT field paths use bracket notation (`employees[0].basic_salary`). We collect tokens during
170
+ the recursive walk and convert any leading-bracket tokens into bracket-joined segments so
171
+ predictions match GT field path scope.
172
+ """
173
+ rendered = ""
174
+ for token in path:
175
+ if token.startswith("[") and token.endswith("]"):
176
+ rendered += token
177
+ elif rendered:
178
+ rendered += "." + token
179
+ else:
180
+ rendered = token
181
+ return rendered
182
+
183
+
184
+ def _normalize_citation(
185
+ *,
186
+ field_path: str,
187
+ citation: Any,
188
+ source: str,
189
+ default_page: int | None,
190
+ default_dimensions: tuple[float, float] | None,
191
+ ) -> list[FieldCitation]:
192
+ citation_map = _as_mapping(citation)
193
+ if not citation_map:
194
+ return []
195
+
196
+ page = _extract_page(citation_map) or default_page or 1
197
+ dimensions = _extract_dimensions(citation_map) or default_dimensions
198
+ polygon = _extract_polygon(citation_map)
199
+ reference_text = _extract_reference_text(citation_map)
200
+ confidence = _extract_confidence(citation_map)
201
+ metadata = _compact_metadata(citation_map)
202
+
203
+ plural_bboxes = _extract_bbox_list(citation_map)
204
+ if plural_bboxes:
205
+ normalized_polygon = _normalize_polygon(polygon, dimensions) if polygon is not None else None
206
+ results: list[FieldCitation] = []
207
+ for entry_bbox in plural_bboxes:
208
+ normalized_bbox = _normalize_bbox(entry_bbox, dimensions)
209
+ if normalized_bbox is None:
210
+ continue
211
+ results.append(
212
+ _field_citation_cls()(
213
+ field_path=field_path,
214
+ page=page,
215
+ bbox=normalized_bbox,
216
+ polygon=normalized_polygon,
217
+ reference_text=reference_text,
218
+ confidence=confidence,
219
+ source=source,
220
+ metadata=metadata,
221
+ )
222
+ )
223
+ return results
224
+
225
+ raw_bbox = _bbox_from_polygon(polygon) if polygon is not None else _extract_bbox(citation_map)
226
+ normalized_bbox = _normalize_bbox(raw_bbox, dimensions)
227
+ if normalized_bbox is None:
228
+ return []
229
+
230
+ normalized_polygon = _normalize_polygon(polygon, dimensions) if polygon is not None else None
231
+ return [
232
+ _field_citation_cls()(
233
+ field_path=field_path,
234
+ page=page,
235
+ bbox=normalized_bbox,
236
+ polygon=normalized_polygon,
237
+ reference_text=reference_text,
238
+ confidence=confidence,
239
+ source=source,
240
+ metadata=metadata,
241
+ )
242
+ ]
243
+
244
+
245
+ def _extract_bbox_list(node: Mapping[str, Any]) -> list[list[float]] | None:
246
+ """Extract a plural list of bboxes if `bounding_boxes` is present.
247
+
248
+ Each entry can be either a 4-element [x, y, w, h] sequence or a mapping with
249
+ x/y/w/h or x1/y1/x2/y2 keys.
250
+ """
251
+ raw = node.get("bounding_boxes")
252
+ if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes, bytearray)):
253
+ return None
254
+ if not raw:
255
+ return None
256
+ bboxes: list[list[float]] = []
257
+ for entry in raw:
258
+ bbox: list[float] | None = None
259
+ if isinstance(entry, Mapping):
260
+ bbox = _bbox_from_mapping(entry)
261
+ elif isinstance(entry, Sequence) and not isinstance(entry, (str, bytes, bytearray)):
262
+ bbox = _bbox_from_sequence(entry)
263
+ if bbox is not None:
264
+ bboxes.append(bbox)
265
+ return bboxes or None
266
+
267
+
268
+ def _extract_field_path(node: Mapping[str, Any]) -> str | None:
269
+ for key in ("field_path", "fieldPath", "path", "field", "name", "key"):
270
+ value = node.get(key)
271
+ if isinstance(value, str) and value:
272
+ return value
273
+ return None
274
+
275
+
276
+ def _extract_page(node: Mapping[str, Any]) -> int | None:
277
+ for key in ("page", "page_number", "pageNumber"):
278
+ value = _coerce_int(node.get(key))
279
+ if value is not None and value >= 1:
280
+ return value
281
+ for key in ("page_index", "pageIndex"):
282
+ value = _coerce_int(node.get(key))
283
+ if value is not None and value >= 0:
284
+ return value + 1
285
+ return None
286
+
287
+
288
+ def _extract_dimensions(node: Mapping[str, Any]) -> tuple[float, float] | None:
289
+ width = _coerce_float(_first_present(node, ("page_width", "pageWidth", "width", "image_width", "imageWidth")))
290
+ height = _coerce_float(_first_present(node, ("page_height", "pageHeight", "height", "image_height", "imageHeight")))
291
+ if width is not None and height is not None and width > 0 and height > 0:
292
+ return width, height
293
+
294
+ for key in ("page_dimensions", "pageDimensions", "page_size", "pageSize", "dimensions", "image_size", "imageSize"):
295
+ size = _as_mapping(node.get(key))
296
+ width = _coerce_float(_first_present(size, ("width", "w")))
297
+ height = _coerce_float(_first_present(size, ("height", "h")))
298
+ if width is not None and height is not None and width > 0 and height > 0:
299
+ return width, height
300
+ return None
301
+
302
+
303
+ def _extract_bbox(node: Mapping[str, Any]) -> list[float] | None:
304
+ for key in ("bbox", "bounding_box", "boundingBox", "box"):
305
+ bbox = node.get(key)
306
+ bbox_from_dict = _bbox_from_mapping(_as_mapping(bbox))
307
+ if bbox_from_dict is not None:
308
+ return bbox_from_dict
309
+ bbox_from_sequence = _bbox_from_sequence(bbox)
310
+ if bbox_from_sequence is not None:
311
+ return bbox_from_sequence
312
+
313
+ bbox_from_dict = _bbox_from_mapping(node)
314
+ if bbox_from_dict is not None:
315
+ return bbox_from_dict
316
+ return None
317
+
318
+
319
+ def _bbox_from_mapping(node: Mapping[str, Any]) -> list[float] | None:
320
+ if not node:
321
+ return None
322
+
323
+ x = _coerce_float(_first_present(node, ("x", "left")))
324
+ y = _coerce_float(_first_present(node, ("y", "top")))
325
+ width = _coerce_float(_first_present(node, ("w", "width")))
326
+ height = _coerce_float(_first_present(node, ("h", "height")))
327
+ if x is not None and y is not None and width is not None and height is not None:
328
+ return [x, y, width, height]
329
+
330
+ x1 = _coerce_float(_first_present(node, ("x1", "left")))
331
+ y1 = _coerce_float(_first_present(node, ("y1", "top")))
332
+ x2 = _coerce_float(_first_present(node, ("x2", "right")))
333
+ y2 = _coerce_float(_first_present(node, ("y2", "bottom")))
334
+ if x1 is not None and y1 is not None and x2 is not None and y2 is not None:
335
+ return [x1, y1, x2 - x1, y2 - y1]
336
+ return None
337
+
338
+
339
+ def _bbox_from_sequence(raw: Any) -> list[float] | None:
340
+ if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes, bytearray)) or len(raw) != 4:
341
+ return None
342
+ values = [_coerce_float(value) for value in raw]
343
+ if any(value is None for value in values):
344
+ return None
345
+ return [float(value) for value in values if value is not None]
346
+
347
+
348
+ def _extract_polygon(node: Mapping[str, Any]) -> list[list[float]] | None:
349
+ for key in ("polygon", "bounding_polygon", "boundingPolygon", "points", "vertices"):
350
+ polygon = _polygon_from_raw(node.get(key))
351
+ if polygon is not None:
352
+ return polygon
353
+ return None
354
+
355
+
356
+ def _polygon_from_raw(raw: Any) -> list[list[float]] | None:
357
+ if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes, bytearray)):
358
+ return None
359
+ if not raw:
360
+ return None
361
+
362
+ points: list[list[float]] = []
363
+ if all(isinstance(point, Mapping) for point in raw):
364
+ for point in raw:
365
+ point_map = _as_mapping(point)
366
+ x = _coerce_float(point_map.get("x"))
367
+ y = _coerce_float(point_map.get("y"))
368
+ if x is None or y is None:
369
+ return None
370
+ points.append([x, y])
371
+ elif all(isinstance(point, Sequence) and not isinstance(point, (str, bytes, bytearray)) for point in raw):
372
+ for point in raw:
373
+ if len(point) < 2:
374
+ return None
375
+ x = _coerce_float(point[0])
376
+ y = _coerce_float(point[1])
377
+ if x is None or y is None:
378
+ return None
379
+ points.append([x, y])
380
+ else:
381
+ values = [_coerce_float(value) for value in raw]
382
+ if len(values) % 2 != 0 or any(value is None for value in values):
383
+ return None
384
+ numeric_values = [float(value) for value in values if value is not None]
385
+ points = [[numeric_values[index], numeric_values[index + 1]] for index in range(0, len(numeric_values), 2)]
386
+
387
+ return points if len(points) >= 2 else None
388
+
389
+
390
+ def _bbox_from_polygon(polygon: list[list[float]] | None) -> list[float] | None:
391
+ if not polygon:
392
+ return None
393
+ xs = [point[0] for point in polygon]
394
+ ys = [point[1] for point in polygon]
395
+ left = min(xs)
396
+ top = min(ys)
397
+ return [left, top, max(xs) - left, max(ys) - top]
398
+
399
+
400
+ def _normalize_bbox(raw_bbox: list[float] | None, dimensions: tuple[float, float] | None) -> list[float] | None:
401
+ if raw_bbox is None or len(raw_bbox) != 4:
402
+ return None
403
+ x, y, width, height = raw_bbox
404
+ if width <= 0 or height <= 0:
405
+ return None
406
+
407
+ if _looks_normalized(raw_bbox):
408
+ normalized = raw_bbox
409
+ elif dimensions is not None:
410
+ page_width, page_height = dimensions
411
+ normalized = [x / page_width, y / page_height, width / page_width, height / page_height]
412
+ else:
413
+ return None
414
+
415
+ if not _looks_normalized(normalized):
416
+ return None
417
+ return [round(value, 8) for value in normalized]
418
+
419
+
420
+ def _normalize_polygon(
421
+ polygon: list[list[float]] | None,
422
+ dimensions: tuple[float, float] | None,
423
+ ) -> list[list[float]] | None:
424
+ if polygon is None:
425
+ return None
426
+ flat = [coordinate for point in polygon for coordinate in point]
427
+ if all(0 <= value <= 1 for value in flat):
428
+ return [[round(point[0], 8), round(point[1], 8)] for point in polygon]
429
+ if dimensions is None:
430
+ return None
431
+ page_width, page_height = dimensions
432
+ normalized = [[point[0] / page_width, point[1] / page_height] for point in polygon]
433
+ if not all(0 <= value <= 1 for point in normalized for value in point):
434
+ return None
435
+ return [[round(point[0], 8), round(point[1], 8)] for point in normalized]
436
+
437
+
438
+ def _looks_normalized(bbox: list[float]) -> bool:
439
+ x, y, width, height = bbox
440
+ return (
441
+ 0 <= x <= 1
442
+ and 0 <= y <= 1
443
+ and 0 < width <= 1
444
+ and 0 < height <= 1
445
+ and x + width <= 1.000001
446
+ and y + height <= 1.000001
447
+ )
448
+
449
+
450
+ def _extract_reference_text(node: Mapping[str, Any]) -> str | None:
451
+ value = _first_present(
452
+ node, ("reference_text", "referenceText", "matching_text", "matchingText", "text", "content", "value")
453
+ )
454
+ if isinstance(value, str):
455
+ return value
456
+ return None
457
+
458
+
459
+ def _extract_confidence(node: Mapping[str, Any]) -> float | None:
460
+ confidence = _coerce_float(_first_present(node, ("confidence", "score", "probability")))
461
+ if confidence is None:
462
+ return None
463
+ return confidence
464
+
465
+
466
+ def _compact_metadata(node: Mapping[str, Any]) -> dict[str, Any] | None:
467
+ metadata = {
468
+ key: value
469
+ for key, value in node.items()
470
+ if key
471
+ not in {
472
+ "bbox",
473
+ "bounding_box",
474
+ "boundingBox",
475
+ "box",
476
+ "bounding_boxes",
477
+ "polygon",
478
+ "bounding_polygon",
479
+ "boundingPolygon",
480
+ "points",
481
+ "vertices",
482
+ }
483
+ }
484
+ return dict(metadata) if metadata else None
485
+
486
+
487
+ def _dedupe(citations: list[FieldCitation]) -> list[FieldCitation]:
488
+ seen: set[tuple[Any, ...]] = set()
489
+ deduped: list[FieldCitation] = []
490
+ for citation in citations:
491
+ key = (
492
+ citation.field_path,
493
+ citation.page,
494
+ tuple(citation.bbox),
495
+ citation.reference_text,
496
+ citation.source,
497
+ )
498
+ if key in seen:
499
+ continue
500
+ seen.add(key)
501
+ deduped.append(citation)
502
+ return deduped
503
+
504
+
505
+ def _as_mapping(value: Any) -> Mapping[str, Any]:
506
+ return value if isinstance(value, Mapping) else {}
507
+
508
+
509
+ def _as_sequence(value: Any) -> Sequence[Any]:
510
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
511
+ return value
512
+ return []
513
+
514
+
515
+ def _first_present(node: Mapping[str, Any], keys: tuple[str, ...]) -> Any:
516
+ for key in keys:
517
+ if key in node:
518
+ return node[key]
519
+ return None
520
+
521
+
522
+ def _coerce_float(value: Any) -> float | None:
523
+ if isinstance(value, bool) or value is None:
524
+ return None
525
+ if isinstance(value, (int, float)):
526
+ return float(value)
527
+ if isinstance(value, str):
528
+ try:
529
+ return float(value)
530
+ except ValueError:
531
+ return None
532
+ return None
533
+
534
+
535
+ def _coerce_int(value: Any) -> int | None:
536
+ if isinstance(value, bool) or value is None:
537
+ return None
538
+ if isinstance(value, int):
539
+ return value
540
+ if isinstance(value, float) and value.is_integer():
541
+ return int(value)
542
+ if isinstance(value, str):
543
+ try:
544
+ parsed = float(value)
545
+ except ValueError:
546
+ return None
547
+ if parsed.is_integer():
548
+ return int(parsed)
549
+ return None