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,776 @@
1
+ """Provider for Google Document AI PARSE."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+ from google.api_core.client_options import ClientOptions
11
+ from google.cloud import documentai_v1 as documentai
12
+ from pypdf import PdfReader
13
+
14
+ from parse_bench.inference.providers.base import (
15
+ Provider,
16
+ ProviderConfigError,
17
+ ProviderPermanentError,
18
+ ProviderTransientError,
19
+ )
20
+ from parse_bench.inference.providers.parse.google_docai_layout_normalization import normalize_layout_document
21
+ from parse_bench.inference.providers.registry import register_provider
22
+ from parse_bench.schemas.parse_output import LayoutItemIR, LayoutSegmentIR, PageIR, ParseLayoutPageIR, ParseOutput
23
+ from parse_bench.schemas.pipeline import PipelineSpec
24
+ from parse_bench.schemas.pipeline_io import InferenceRequest, InferenceResult, RawInferenceResult
25
+ from parse_bench.schemas.product import ProductType
26
+
27
+ try:
28
+ from google.cloud import documentai_v1beta3 as documentai_v1beta3
29
+ except ImportError: # pragma: no cover - dependency guarded by runtime validation
30
+ documentai_v1beta3 = None # type: ignore[assignment]
31
+
32
+
33
+ _REQUIRED_LAYOUT_CONFIG_FIELDS = {
34
+ "return_bounding_boxes",
35
+ "return_images",
36
+ "enable_image_annotation",
37
+ "enable_table_annotation",
38
+ }
39
+
40
+ _VIRTUAL_PAGE_DIM = 1000.0
41
+
42
+
43
+ @register_provider("google_docai")
44
+ class GoogleDocAIProvider(Provider):
45
+ """
46
+ Provider for Google Document AI PARSE.
47
+
48
+ OCR mode uses `documentai_v1`.
49
+ Layout Parser mode uses the first SDK surface that exposes the full layout
50
+ config contract, preferring `documentai_v1beta3` on current installs.
51
+ """
52
+
53
+ def __init__(self, provider_name: str, base_config: dict[str, Any] | None = None):
54
+ super().__init__(provider_name, base_config)
55
+
56
+ self._project_id = self.base_config.get("project_id") or os.getenv("GOOGLE_DOCAI_PROJECT_ID")
57
+ if not self._project_id:
58
+ raise ProviderConfigError(
59
+ "Google Cloud project ID is required. "
60
+ "Set GOOGLE_DOCAI_PROJECT_ID environment variable or pass project_id in base_config."
61
+ )
62
+
63
+ self._location = self.base_config.get("location") or os.getenv("GOOGLE_DOCAI_LOCATION", "us")
64
+
65
+ self._processor_id = self.base_config.get("processor_id") or os.getenv("GOOGLE_DOCAI_PROCESSOR_ID")
66
+ if not self._processor_id:
67
+ raise ProviderConfigError(
68
+ "Google Document AI processor ID is required. "
69
+ "Set GOOGLE_DOCAI_PROCESSOR_ID environment variable or pass processor_id in base_config."
70
+ )
71
+
72
+ self._processor_version = self.base_config.get("processor_version") or os.getenv(
73
+ "GOOGLE_DOCAI_PROCESSOR_VERSION"
74
+ )
75
+
76
+ self._enable_native_pdf_parsing = self.base_config.get("enable_native_pdf_parsing", True)
77
+ self._enable_symbol_detection = self.base_config.get("enable_symbol_detection", False)
78
+
79
+ self._use_layout_parser = self.base_config.get("use_layout_parser", False)
80
+ self._layout_processor_id = (
81
+ self.base_config.get("layout_processor_id")
82
+ or os.getenv("GOOGLE_DOCAI_LAYOUT_PROCESSOR_ID")
83
+ or self._processor_id
84
+ )
85
+ self._chunking_config = self.base_config.get("chunking_config")
86
+
87
+ self._layout_api_surface_label: str | None = None
88
+ self._layout_documentai: Any | None = None
89
+ self._layout_config_fields: set[str] = set()
90
+ if self._use_layout_parser:
91
+ self._layout_api_surface_label, self._layout_documentai = self._resolve_layout_api_surface()
92
+ self._layout_config_fields = set(
93
+ self._layout_documentai.ProcessOptions.LayoutConfig()._pb.DESCRIPTOR.fields_by_name
94
+ )
95
+
96
+ def _resolve_layout_api_surface(self) -> tuple[str, Any]:
97
+ candidates: list[tuple[str, Any]] = []
98
+ if documentai_v1beta3 is not None:
99
+ candidates.append(("v1beta3", documentai_v1beta3))
100
+ candidates.append(("v1", documentai))
101
+
102
+ for surface_label, module in candidates:
103
+ layout_fields = set(module.ProcessOptions.LayoutConfig()._pb.DESCRIPTOR.fields_by_name)
104
+ if _REQUIRED_LAYOUT_CONFIG_FIELDS.issubset(layout_fields):
105
+ return surface_label, module
106
+
107
+ raise ProviderConfigError(
108
+ "Google DocAI layout mode requires a Document AI SDK surface exposing "
109
+ f"{sorted(_REQUIRED_LAYOUT_CONFIG_FIELDS)}. "
110
+ "Current install does not provide a compatible layout API surface."
111
+ )
112
+
113
+ def _is_pdf_file(self, file_path: str) -> bool:
114
+ try:
115
+ with open(file_path, "rb") as file_handle:
116
+ return file_handle.read(4) == b"%PDF"
117
+ except Exception:
118
+ return False
119
+
120
+ def _get_page_count(self, file_path: str) -> int:
121
+ if self._is_pdf_file(file_path):
122
+ try:
123
+ reader = PdfReader(file_path)
124
+ return len(reader.pages)
125
+ except Exception:
126
+ return 1
127
+ return 1
128
+
129
+ def _get_mime_type(self, file_path: str) -> str:
130
+ suffix = Path(file_path).suffix.lower()
131
+ return {
132
+ ".pdf": "application/pdf",
133
+ ".png": "image/png",
134
+ ".jpg": "image/jpeg",
135
+ ".jpeg": "image/jpeg",
136
+ ".gif": "image/gif",
137
+ ".tiff": "image/tiff",
138
+ ".tif": "image/tiff",
139
+ ".bmp": "image/bmp",
140
+ ".webp": "image/webp",
141
+ }.get(suffix, "application/pdf")
142
+
143
+ def _is_image_file(self, file_path: str) -> bool:
144
+ return Path(file_path).suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".tiff", ".tif", ".bmp", ".webp"}
145
+
146
+ def _convert_image_to_pdf(self, file_path: str) -> bytes:
147
+ try:
148
+ import io
149
+
150
+ from PIL import Image
151
+ except ImportError as exc:
152
+ raise ProviderConfigError("Pillow library not installed. Run: pip install Pillow") from exc
153
+
154
+ try:
155
+ with Image.open(file_path) as image:
156
+ if image.mode in ("RGBA", "LA", "P"):
157
+ background = Image.new("RGB", image.size, (255, 255, 255))
158
+ if image.mode == "P":
159
+ image = image.convert("RGBA")
160
+ background.paste(image, mask=image.split()[-1] if image.mode == "RGBA" else None)
161
+ image = background
162
+ elif image.mode != "RGB":
163
+ image = image.convert("RGB")
164
+
165
+ pdf_buffer = io.BytesIO()
166
+ image.save(pdf_buffer, format="PDF", resolution=100.0)
167
+ pdf_buffer.seek(0)
168
+ return pdf_buffer.read()
169
+ except Exception as exc: # pragma: no cover - filesystem/PIL failure
170
+ raise ProviderPermanentError(f"Failed to convert image to PDF: {exc}") from exc
171
+
172
+ def _build_layout_config(self, layout_module: Any) -> Any:
173
+ chunking_config = None
174
+ if self._chunking_config:
175
+ chunking_kwargs: dict[str, Any] = {}
176
+ if "chunk_size" in self._chunking_config:
177
+ chunking_kwargs["chunk_size"] = self._chunking_config["chunk_size"]
178
+ if "include_ancestor_headings" in self._chunking_config:
179
+ chunking_kwargs["include_ancestor_headings"] = self._chunking_config["include_ancestor_headings"]
180
+ if chunking_kwargs:
181
+ chunking_config = layout_module.ProcessOptions.LayoutConfig.ChunkingConfig(**chunking_kwargs)
182
+
183
+ # Visual grounding in bench depends on native layout bounding boxes, so this
184
+ # provider is intentionally optimized for the stable Layout Parser surfaces
185
+ # that still expose bbox geometry. Newer parser versions can improve table
186
+ # understanding, but some do not expose layout bboxes and therefore cannot
187
+ # support the visual-grounding column honestly.
188
+ #
189
+ # Keep LLM image annotations enabled because they materially improve picture
190
+ # detection. Keep LLM table annotations disabled because the native
191
+ # `tableBlock` structure is already present on the stable bbox-capable path,
192
+ # and the extra table annotations did not improve merged-cell fidelity in our
193
+ # verification runs.
194
+ kwargs: dict[str, Any] = {
195
+ "chunking_config": chunking_config,
196
+ "return_bounding_boxes": True,
197
+ "return_images": True,
198
+ "enable_image_annotation": True,
199
+ "enable_table_annotation": False,
200
+ }
201
+ if "enable_image_extraction" in self._layout_config_fields:
202
+ kwargs["enable_image_extraction"] = True
203
+
204
+ return layout_module.ProcessOptions.LayoutConfig(**kwargs)
205
+
206
+ def _build_ocr_response(self, document_obj: Any) -> dict[str, Any]:
207
+ raw_response = {
208
+ "text": document_obj.text,
209
+ "mime_type": document_obj.mime_type,
210
+ "pages": [],
211
+ "entities": [],
212
+ "tables": [],
213
+ "mode": "ocr",
214
+ }
215
+
216
+ for page in document_obj.pages:
217
+ page_data = {
218
+ "page_number": page.page_number,
219
+ "width": page.dimension.width if page.dimension else None,
220
+ "height": page.dimension.height if page.dimension else None,
221
+ "blocks": [],
222
+ "paragraphs": [],
223
+ "lines": [],
224
+ "tokens": [],
225
+ "tables": [],
226
+ }
227
+
228
+ for block in page.blocks:
229
+ block_text = self._get_text_from_layout(block.layout, document_obj.text)
230
+ page_data["blocks"].append(
231
+ {
232
+ "text": block_text,
233
+ "confidence": block.layout.confidence if block.layout else None,
234
+ }
235
+ )
236
+
237
+ for para in page.paragraphs:
238
+ para_text = self._get_text_from_layout(para.layout, document_obj.text)
239
+ para_entry: dict[str, Any] = {
240
+ "text": para_text,
241
+ "confidence": para.layout.confidence if para.layout else None,
242
+ }
243
+ if para.layout and para.layout.bounding_poly and para.layout.bounding_poly.vertices:
244
+ vertices = para.layout.bounding_poly.vertices
245
+ para_entry["y_position"] = min(v.y for v in vertices if v.y is not None) if vertices else None
246
+ if para.layout and para.layout.bounding_poly:
247
+ normalized_vertices = para.layout.bounding_poly.normalized_vertices
248
+ if normalized_vertices and len(normalized_vertices) >= 4:
249
+ para_entry["normalized_bbox"] = {
250
+ "x1": normalized_vertices[0].x or 0.0,
251
+ "y1": normalized_vertices[0].y or 0.0,
252
+ "x2": normalized_vertices[2].x or 0.0,
253
+ "y2": normalized_vertices[2].y or 0.0,
254
+ }
255
+ page_data["paragraphs"].append(para_entry)
256
+
257
+ for line in page.lines:
258
+ line_text = self._get_text_from_layout(line.layout, document_obj.text)
259
+ page_data["lines"].append(
260
+ {
261
+ "text": line_text,
262
+ "confidence": line.layout.confidence if line.layout else None,
263
+ }
264
+ )
265
+
266
+ for table in page.tables:
267
+ table_data = self._extract_table(table, document_obj.text)
268
+ if table.layout and table.layout.bounding_poly and table.layout.bounding_poly.vertices:
269
+ vertices = table.layout.bounding_poly.vertices
270
+ table_data["y_position"] = min(v.y for v in vertices if v.y is not None) if vertices else None
271
+ if table.layout and table.layout.bounding_poly:
272
+ normalized_vertices = table.layout.bounding_poly.normalized_vertices
273
+ if normalized_vertices and len(normalized_vertices) >= 4:
274
+ table_data["normalized_bbox"] = {
275
+ "x1": normalized_vertices[0].x or 0.0,
276
+ "y1": normalized_vertices[0].y or 0.0,
277
+ "x2": normalized_vertices[2].x or 0.0,
278
+ "y2": normalized_vertices[2].y or 0.0,
279
+ }
280
+ page_data["tables"].append(table_data)
281
+
282
+ raw_response["pages"].append(page_data)
283
+
284
+ for entity in document_obj.entities:
285
+ raw_response["entities"].append(
286
+ {
287
+ "type": entity.type_,
288
+ "mention_text": entity.mention_text,
289
+ "confidence": entity.confidence,
290
+ }
291
+ )
292
+
293
+ raw_response["_config"] = {
294
+ "project_id": self._project_id,
295
+ "location": self._location,
296
+ "processor_id": self._processor_id,
297
+ "processor_version": self._processor_version,
298
+ "enable_native_pdf_parsing": self._enable_native_pdf_parsing,
299
+ "enable_symbol_detection": self._enable_symbol_detection,
300
+ "total_pages": len(document_obj.pages),
301
+ }
302
+ return raw_response
303
+
304
+ def _serialize_api_document(self, document_obj: Any) -> dict[str, Any]:
305
+ try:
306
+ from google.protobuf.json_format import MessageToDict # type: ignore[import-untyped]
307
+ except ImportError as exc: # pragma: no cover - protobuf always available with SDK
308
+ raise ProviderConfigError("google.protobuf is required to serialize Document AI payloads.") from exc
309
+ return cast(dict[str, Any], MessageToDict(document_obj._pb))
310
+
311
+ def _materialize_internal_raw_output(
312
+ self,
313
+ raw_payload: dict[str, Any],
314
+ *,
315
+ use_layout_parser: bool,
316
+ ) -> dict[str, Any]:
317
+ if "mode" in raw_payload and ("pages" in raw_payload or "blocks" in raw_payload):
318
+ return raw_payload
319
+
320
+ try:
321
+ from google.protobuf.json_format import ParseDict
322
+ except ImportError as exc: # pragma: no cover
323
+ raise ProviderConfigError("Google protobuf support is required to parse Document AI payloads.") from exc
324
+
325
+ if use_layout_parser:
326
+ raise ProviderPermanentError(
327
+ "Legacy Google DocAI layout raw outputs are no longer normalized through provider-shaped blocks. "
328
+ "Re-run inference to regenerate raw outputs from the untouched DocAI payload."
329
+ )
330
+
331
+ document_pb = ParseDict(raw_payload, documentai.Document()._pb)
332
+ document_obj = documentai.Document(document_pb)
333
+ return self._build_ocr_response(document_obj)
334
+
335
+ def _materialize_layout_document(self, raw_payload: dict[str, Any]) -> Any:
336
+ if self._layout_documentai is None:
337
+ raise ProviderConfigError("Layout Parser requested without an initialized layout API surface.")
338
+ try:
339
+ from google.protobuf.json_format import ParseDict
340
+ except ImportError as exc: # pragma: no cover
341
+ raise ProviderConfigError("Google protobuf support is required to parse Document AI payloads.") from exc
342
+
343
+ document_pb = ParseDict(raw_payload, self._layout_documentai.Document()._pb)
344
+ return self._layout_documentai.Document(document_pb)
345
+
346
+ def _parse_document(self, file_path: str) -> dict[str, Any]:
347
+ try:
348
+ docai_module = self._layout_documentai if self._use_layout_parser else documentai
349
+ if docai_module is None:
350
+ raise ProviderConfigError("Layout Parser requested without a compatible Document AI SDK surface.")
351
+
352
+ opts = ClientOptions(api_endpoint=f"{self._location}-documentai.googleapis.com")
353
+ client = docai_module.DocumentProcessorServiceClient(client_options=opts)
354
+
355
+ processor_id = str(self._layout_processor_id if self._use_layout_parser else self._processor_id)
356
+ processor_name = self._build_processor_name(processor_id)
357
+
358
+ with open(file_path, "rb") as file_handle:
359
+ file_content = file_handle.read()
360
+
361
+ mime_type = self._get_mime_type(file_path)
362
+ if self._use_layout_parser and self._is_image_file(file_path):
363
+ file_content = self._convert_image_to_pdf(file_path)
364
+ mime_type = "application/pdf"
365
+
366
+ raw_document = docai_module.RawDocument(content=file_content, mime_type=mime_type)
367
+
368
+ if self._use_layout_parser:
369
+ process_options = docai_module.ProcessOptions(layout_config=self._build_layout_config(docai_module))
370
+ else:
371
+ process_options = docai_module.ProcessOptions(
372
+ ocr_config=docai_module.OcrConfig(
373
+ enable_native_pdf_parsing=self._enable_native_pdf_parsing,
374
+ enable_symbol=self._enable_symbol_detection,
375
+ )
376
+ )
377
+
378
+ result = client.process_document(
379
+ request=docai_module.ProcessRequest(
380
+ name=processor_name,
381
+ raw_document=raw_document,
382
+ process_options=process_options,
383
+ )
384
+ )
385
+ return self._serialize_api_document(result.document)
386
+ except Exception as exc:
387
+ error_str = str(exc).lower()
388
+ transient_keywords = ["timeout", "deadline", "unavailable", "503", "502", "504", "connection", "network"]
389
+ if any(keyword in error_str for keyword in transient_keywords):
390
+ raise ProviderTransientError(f"Transient error during Document AI processing: {exc}") from exc
391
+ raise ProviderPermanentError(f"Error during Document AI processing: {exc}") from exc
392
+
393
+ def _build_processor_name(self, processor_id: str) -> str:
394
+ if self._processor_version:
395
+ return (
396
+ f"projects/{self._project_id}/locations/{self._location}/"
397
+ f"processors/{processor_id}/processorVersions/{self._processor_version}"
398
+ )
399
+ return f"projects/{self._project_id}/locations/{self._location}/processors/{processor_id}"
400
+
401
+ def _get_text_from_layout(self, layout: Any, full_text: str) -> str:
402
+ if not layout or not layout.text_anchor or not layout.text_anchor.text_segments:
403
+ return ""
404
+
405
+ text_parts: list[str] = []
406
+ for segment in layout.text_anchor.text_segments:
407
+ start_index = int(segment.start_index) if segment.start_index else 0
408
+ end_index = int(segment.end_index) if segment.end_index else 0
409
+ text_parts.append(full_text[start_index:end_index])
410
+ return "".join(text_parts)
411
+
412
+ def _extract_table(self, table: Any, full_text: str) -> dict[str, Any]:
413
+ table_data: dict[str, Any] = {
414
+ "header_rows": [],
415
+ "body_rows": [],
416
+ }
417
+
418
+ for row in table.header_rows:
419
+ row_data = []
420
+ for cell in row.cells:
421
+ row_data.append(
422
+ {
423
+ "text": self._get_text_from_layout(cell.layout, full_text).strip(),
424
+ "row_span": cell.row_span,
425
+ "col_span": cell.col_span,
426
+ }
427
+ )
428
+ table_data["header_rows"].append(row_data)
429
+
430
+ for row in table.body_rows:
431
+ row_data = []
432
+ for cell in row.cells:
433
+ row_data.append(
434
+ {
435
+ "text": self._get_text_from_layout(cell.layout, full_text).strip(),
436
+ "row_span": cell.row_span,
437
+ "col_span": cell.col_span,
438
+ }
439
+ )
440
+ table_data["body_rows"].append(row_data)
441
+
442
+ return table_data
443
+
444
+ def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
445
+ if request.product_type != ProductType.PARSE:
446
+ raise ProviderPermanentError(
447
+ f"GoogleDocAIProvider only supports PARSE product type, got {request.product_type}"
448
+ )
449
+
450
+ started_at = datetime.now()
451
+ file_path = Path(request.source_file_path)
452
+ if not file_path.exists():
453
+ raise ProviderPermanentError(f"File not found: {file_path}")
454
+
455
+ try:
456
+ raw_output = self._parse_document(str(file_path))
457
+ completed_at = datetime.now()
458
+ latency_ms = int((completed_at - started_at).total_seconds() * 1000)
459
+ return RawInferenceResult(
460
+ request=request,
461
+ pipeline=pipeline,
462
+ pipeline_name=pipeline.pipeline_name,
463
+ product_type=request.product_type,
464
+ raw_output=raw_output,
465
+ started_at=started_at,
466
+ completed_at=completed_at,
467
+ latency_in_ms=latency_ms,
468
+ )
469
+ except (ProviderPermanentError, ProviderTransientError):
470
+ raise
471
+ except Exception as exc: # pragma: no cover
472
+ raise ProviderPermanentError(f"Unexpected error during inference: {exc}") from exc
473
+
474
+ def _table_to_html(self, table: dict[str, Any]) -> str:
475
+ html_parts = ["<table>"]
476
+
477
+ if table.get("header_rows"):
478
+ html_parts.append("<thead>")
479
+ for row in table["header_rows"]:
480
+ html_parts.append("<tr>")
481
+ for cell in row:
482
+ colspan = f' colspan="{cell["col_span"]}"' if cell.get("col_span", 1) > 1 else ""
483
+ rowspan = f' rowspan="{cell["row_span"]}"' if cell.get("row_span", 1) > 1 else ""
484
+ html_parts.append(f"<th{colspan}{rowspan}>{cell['text']}</th>")
485
+ html_parts.append("</tr>")
486
+ html_parts.append("</thead>")
487
+
488
+ if table.get("body_rows"):
489
+ html_parts.append("<tbody>")
490
+ for row in table["body_rows"]:
491
+ html_parts.append("<tr>")
492
+ for cell in row:
493
+ colspan = f' colspan="{cell["col_span"]}"' if cell.get("col_span", 1) > 1 else ""
494
+ rowspan = f' rowspan="{cell["row_span"]}"' if cell.get("row_span", 1) > 1 else ""
495
+ html_parts.append(f"<td{colspan}{rowspan}>{cell['text']}</td>")
496
+ html_parts.append("</tr>")
497
+ html_parts.append("</tbody>")
498
+
499
+ html_parts.append("</table>")
500
+ return "\n".join(html_parts)
501
+
502
+ def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
503
+ if raw_result.product_type != ProductType.PARSE:
504
+ raise ProviderPermanentError(
505
+ f"GoogleDocAIProvider only supports PARSE product type, got {raw_result.product_type}"
506
+ )
507
+
508
+ try:
509
+ pipeline_layout = raw_result.pipeline.config.get("use_layout_parser")
510
+ use_layout_parser = pipeline_layout if pipeline_layout is not None else self._use_layout_parser
511
+ if use_layout_parser:
512
+ if isinstance(raw_result.raw_output, dict) and raw_result.raw_output.get("mode") == "layout_parser":
513
+ output = self._normalize_legacy_layout_output(raw_result.raw_output, raw_result)
514
+ else:
515
+ layout_document = self._materialize_layout_document(raw_result.raw_output)
516
+ output = normalize_layout_document(document=layout_document, raw_result=raw_result)
517
+ else:
518
+ raw_output = self._materialize_internal_raw_output(raw_result.raw_output, use_layout_parser=False)
519
+ output = self._normalize_ocr_output(raw_output, raw_result)
520
+
521
+ return InferenceResult(
522
+ request=raw_result.request,
523
+ pipeline_name=raw_result.pipeline_name,
524
+ product_type=raw_result.product_type,
525
+ raw_output=raw_result.raw_output,
526
+ output=output,
527
+ started_at=raw_result.started_at,
528
+ completed_at=raw_result.completed_at,
529
+ latency_in_ms=raw_result.latency_in_ms,
530
+ )
531
+ except Exception as exc:
532
+ raise ProviderPermanentError(f"Normalization failed: {exc}") from exc
533
+
534
+ def _normalize_ocr_output(self, raw_output: dict[str, Any], raw_result: RawInferenceResult) -> ParseOutput:
535
+ pages: list[PageIR] = []
536
+ markdown_parts: list[str] = []
537
+
538
+ for page_idx, page_data in enumerate(raw_output.get("pages", [])):
539
+ elements: list[tuple[float, str]] = []
540
+
541
+ for para in page_data.get("paragraphs", []):
542
+ text = para.get("text", "").strip()
543
+ if text:
544
+ elements.append((para.get("y_position", 0.0) or 0.0, text))
545
+
546
+ for table in page_data.get("tables", []):
547
+ elements.append((table.get("y_position", 0.0) or 0.0, self._table_to_html(table)))
548
+
549
+ elements.sort(key=lambda element: element[0])
550
+ page_markdown_parts = [element[1] for element in elements]
551
+ page_markdown = "\n\n".join(page_markdown_parts)
552
+ pages.append(PageIR(page_index=page_idx, markdown=page_markdown))
553
+ markdown_parts.append(page_markdown)
554
+
555
+ return ParseOutput(
556
+ task_type="parse",
557
+ example_id=raw_result.request.example_id,
558
+ pipeline_name=raw_result.pipeline_name,
559
+ pages=pages,
560
+ layout_pages=_build_layout_pages(raw_output),
561
+ markdown="\n\n---\n\n".join(markdown_parts),
562
+ job_id=None,
563
+ )
564
+
565
+ def _normalize_legacy_layout_output(
566
+ self,
567
+ raw_output: dict[str, Any],
568
+ raw_result: RawInferenceResult,
569
+ ) -> ParseOutput:
570
+ blocks = raw_output.get("blocks", [])
571
+ if not blocks:
572
+ full_text = raw_output.get("text", "")
573
+ return ParseOutput(
574
+ task_type="parse",
575
+ example_id=raw_result.request.example_id,
576
+ pipeline_name=raw_result.pipeline_name,
577
+ pages=[PageIR(page_index=0, markdown=full_text)],
578
+ markdown=full_text,
579
+ job_id=None,
580
+ )
581
+
582
+ page_content: dict[int, list[str]] = {}
583
+ all_content: list[str] = []
584
+ for block in blocks:
585
+ markdown = _legacy_block_to_markdown(block)
586
+ if not markdown:
587
+ continue
588
+ all_content.append(markdown)
589
+ page_span = block.get("page_span")
590
+ if page_span:
591
+ page_start = page_span.get("page_start", 1) - 1
592
+ page_end = page_span.get("page_end", page_start + 1) - 1
593
+ for page_idx in range(page_start, page_end + 1):
594
+ page_content.setdefault(page_idx, []).append(markdown)
595
+ else:
596
+ page_content.setdefault(0, []).append(markdown)
597
+
598
+ pages = [
599
+ PageIR(page_index=page_idx, markdown="\n\n".join(page_content[page_idx]))
600
+ for page_idx in sorted(page_content)
601
+ ]
602
+ layout_pages_payload = raw_output.get("layout_pages")
603
+ if not layout_pages_payload:
604
+ raise ProviderPermanentError(
605
+ "Legacy layout raw output is missing layout_pages. Re-run inference with the native layout rewrite."
606
+ )
607
+
608
+ return ParseOutput(
609
+ task_type="parse",
610
+ example_id=raw_result.request.example_id,
611
+ pipeline_name=raw_result.pipeline_name,
612
+ pages=pages,
613
+ layout_pages=[ParseLayoutPageIR.model_validate(page_data) for page_data in layout_pages_payload],
614
+ markdown="\n\n".join(all_content),
615
+ job_id=None,
616
+ )
617
+
618
+
619
+ def _build_layout_pages(raw_output: dict[str, Any]) -> list[ParseLayoutPageIR]:
620
+ layout_pages: list[ParseLayoutPageIR] = []
621
+
622
+ for page_idx, page_data in enumerate(raw_output.get("pages", [])):
623
+ items: list[LayoutItemIR] = []
624
+
625
+ for para in page_data.get("paragraphs", []):
626
+ bbox_data = para.get("normalized_bbox")
627
+ if not bbox_data:
628
+ continue
629
+
630
+ text = para.get("text", "").strip()
631
+ if not text:
632
+ continue
633
+
634
+ x1 = float(bbox_data.get("x1", 0.0))
635
+ y1 = float(bbox_data.get("y1", 0.0))
636
+ x2 = float(bbox_data.get("x2", 0.0))
637
+ y2 = float(bbox_data.get("y2", 0.0))
638
+ w = x2 - x1
639
+ h = y2 - y1
640
+ if w <= 0 or h <= 0:
641
+ continue
642
+
643
+ conf_raw = para.get("confidence")
644
+ try:
645
+ confidence = float(conf_raw) if conf_raw is not None else 1.0
646
+ except (TypeError, ValueError):
647
+ confidence = 1.0
648
+
649
+ seg = LayoutSegmentIR(x=x1, y=y1, w=w, h=h, confidence=confidence, label="Text")
650
+ items.append(LayoutItemIR(type="text", value=text, bbox=seg, layout_segments=[seg]))
651
+
652
+ for table in page_data.get("tables", []):
653
+ bbox_data = table.get("normalized_bbox")
654
+ if not bbox_data:
655
+ continue
656
+
657
+ x1 = float(bbox_data.get("x1", 0.0))
658
+ y1 = float(bbox_data.get("y1", 0.0))
659
+ x2 = float(bbox_data.get("x2", 0.0))
660
+ y2 = float(bbox_data.get("y2", 0.0))
661
+ w = x2 - x1
662
+ h = y2 - y1
663
+ if w <= 0 or h <= 0:
664
+ continue
665
+
666
+ seg = LayoutSegmentIR(x=x1, y=y1, w=w, h=h, confidence=1.0, label="Table")
667
+ table_html = _table_dict_to_html(table)
668
+ items.append(LayoutItemIR(type="table", value=table_html, bbox=seg, layout_segments=[seg]))
669
+
670
+ if items:
671
+ layout_pages.append(
672
+ ParseLayoutPageIR(
673
+ page_number=page_idx + 1,
674
+ width=_VIRTUAL_PAGE_DIM,
675
+ height=_VIRTUAL_PAGE_DIM,
676
+ items=items,
677
+ )
678
+ )
679
+
680
+ return layout_pages
681
+
682
+
683
+ def _table_dict_to_html(table: dict[str, Any]) -> str:
684
+ parts = ["<table>"]
685
+ for section, tag in [("header_rows", "th"), ("body_rows", "td")]:
686
+ rows = table.get(section, [])
687
+ if not rows:
688
+ continue
689
+ wrapper = "thead" if tag == "th" else "tbody"
690
+ parts.append(f"<{wrapper}>")
691
+ for row in rows:
692
+ parts.append("<tr>")
693
+ for cell in row:
694
+ colspan = f' colspan="{cell["col_span"]}"' if cell.get("col_span", 1) > 1 else ""
695
+ rowspan = f' rowspan="{cell["row_span"]}"' if cell.get("row_span", 1) > 1 else ""
696
+ parts.append(f"<{tag}{colspan}{rowspan}>{cell.get('text', '')}</{tag}>")
697
+ parts.append("</tr>")
698
+ parts.append(f"</{wrapper}>")
699
+ parts.append("</table>")
700
+ return "\n".join(parts)
701
+
702
+
703
+ def _legacy_block_to_markdown(block: dict[str, Any]) -> str:
704
+ block_type = block.get("type")
705
+ parts: list[str] = []
706
+
707
+ if block_type == "text":
708
+ text = block.get("text", "").strip()
709
+ text_type = block.get("text_type", "")
710
+ if text:
711
+ if text_type == "heading-1":
712
+ parts.append(f"# {text}")
713
+ elif text_type == "heading-2":
714
+ parts.append(f"## {text}")
715
+ elif text_type == "heading-3":
716
+ parts.append(f"### {text}")
717
+ elif text_type and text_type.startswith("heading"):
718
+ parts.append(f"#### {text}")
719
+ else:
720
+ parts.append(text)
721
+ for child in block.get("children", []):
722
+ child_md = _legacy_block_to_markdown(child)
723
+ if child_md:
724
+ parts.append(child_md)
725
+
726
+ elif block_type == "table":
727
+ parts.append(_legacy_layout_table_to_html(block))
728
+
729
+ elif block_type == "list":
730
+ for entry in block.get("entries", []):
731
+ entry_md = _legacy_block_to_markdown(entry)
732
+ if entry_md:
733
+ parts.append(f"- {entry_md}")
734
+
735
+ return "\n\n".join(part for part in parts if part)
736
+
737
+
738
+ def _legacy_layout_table_to_html(table_block: dict[str, Any]) -> str:
739
+ html_parts = ["<table>"]
740
+
741
+ header_rows = table_block.get("header_rows", [])
742
+ if header_rows:
743
+ html_parts.append("<thead>")
744
+ for row in header_rows:
745
+ html_parts.append("<tr>")
746
+ for cell in row:
747
+ colspan = f' colspan="{cell["col_span"]}"' if cell.get("col_span", 1) > 1 else ""
748
+ rowspan = f' rowspan="{cell["row_span"]}"' if cell.get("row_span", 1) > 1 else ""
749
+ html_parts.append(f"<th{colspan}{rowspan}>{_legacy_extract_cell_text(cell)}</th>")
750
+ html_parts.append("</tr>")
751
+ html_parts.append("</thead>")
752
+
753
+ body_rows = table_block.get("body_rows", [])
754
+ if body_rows:
755
+ html_parts.append("<tbody>")
756
+ for row in body_rows:
757
+ html_parts.append("<tr>")
758
+ for cell in row:
759
+ colspan = f' colspan="{cell["col_span"]}"' if cell.get("col_span", 1) > 1 else ""
760
+ rowspan = f' rowspan="{cell["row_span"]}"' if cell.get("row_span", 1) > 1 else ""
761
+ html_parts.append(f"<td{colspan}{rowspan}>{_legacy_extract_cell_text(cell)}</td>")
762
+ html_parts.append("</tr>")
763
+ html_parts.append("</tbody>")
764
+
765
+ html_parts.append("</table>")
766
+ return "\n".join(html_parts)
767
+
768
+
769
+ def _legacy_extract_cell_text(cell: dict[str, Any]) -> str:
770
+ texts: list[str] = []
771
+ for block in cell.get("blocks", []):
772
+ if block.get("type") == "text":
773
+ text = block.get("text", "").strip()
774
+ if text:
775
+ texts.append(text)
776
+ return " ".join(texts)