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.
- parse_bench/__init__.py +3 -0
- parse_bench/analysis/__init__.py +6 -0
- parse_bench/analysis/aggregation_report.py +582 -0
- parse_bench/analysis/cli.py +472 -0
- parse_bench/analysis/comparison.py +382 -0
- parse_bench/analysis/comparison_core.py +357 -0
- parse_bench/analysis/comparison_report.py +2066 -0
- parse_bench/analysis/detailed_report.py +2254 -0
- parse_bench/analysis/leaderboard_report.py +852 -0
- parse_bench/analysis/metric_definitions.py +771 -0
- parse_bench/cli.py +267 -0
- parse_bench/data/__init__.py +1 -0
- parse_bench/data/cli.py +118 -0
- parse_bench/data/download.py +127 -0
- parse_bench/evaluation/__init__.py +11 -0
- parse_bench/evaluation/cli.py +435 -0
- parse_bench/evaluation/evaluators/__init__.py +17 -0
- parse_bench/evaluation/evaluators/base.py +34 -0
- parse_bench/evaluation/evaluators/extract.py +429 -0
- parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
- parse_bench/evaluation/evaluators/parse.py +1353 -0
- parse_bench/evaluation/evaluators/qa.py +199 -0
- parse_bench/evaluation/layout_adapters/__init__.py +21 -0
- parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
- parse_bench/evaluation/layout_adapters/base.py +105 -0
- parse_bench/evaluation/layout_adapters/registry.py +109 -0
- parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
- parse_bench/evaluation/layout_label_mappers/base.py +66 -0
- parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
- parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
- parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
- parse_bench/evaluation/metric_aggregation.py +56 -0
- parse_bench/evaluation/metrics/__init__.py +5 -0
- parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
- parse_bench/evaluation/metrics/attribution/constants.py +12 -0
- parse_bench/evaluation/metrics/attribution/core.py +1108 -0
- parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
- parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
- parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
- parse_bench/evaluation/metrics/base.py +33 -0
- parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
- parse_bench/evaluation/metrics/extract/__init__.py +29 -0
- parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
- parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
- parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
- parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
- parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
- parse_bench/evaluation/metrics/extract/test_types.py +11 -0
- parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
- parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
- parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
- parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
- parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
- parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
- parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
- parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
- parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
- parse_bench/evaluation/metrics/parse/__init__.py +5 -0
- parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
- parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
- parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
- parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
- parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
- parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
- parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
- parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
- parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
- parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
- parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
- parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
- parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
- parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
- parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
- parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
- parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
- parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
- parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
- parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
- parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
- parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
- parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
- parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
- parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
- parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
- parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
- parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
- parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
- parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
- parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
- parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
- parse_bench/evaluation/metrics/parse/test_types.py +103 -0
- parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
- parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
- parse_bench/evaluation/metrics/parse/utils.py +885 -0
- parse_bench/evaluation/metrics/qa/__init__.py +5 -0
- parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
- parse_bench/evaluation/qa/__init__.py +5 -0
- parse_bench/evaluation/qa/llm_service.py +335 -0
- parse_bench/evaluation/reports/__init__.py +8 -0
- parse_bench/evaluation/reports/csv.py +64 -0
- parse_bench/evaluation/reports/html.py +338 -0
- parse_bench/evaluation/reports/markdown.py +98 -0
- parse_bench/evaluation/reports/rule_csv.py +22 -0
- parse_bench/evaluation/runner.py +1864 -0
- parse_bench/evaluation/stats.py +104 -0
- parse_bench/extensions.py +72 -0
- parse_bench/inference/__init__.py +33 -0
- parse_bench/inference/chunkr_layout_extraction.py +160 -0
- parse_bench/inference/cli.py +484 -0
- parse_bench/inference/layout_extraction.py +422 -0
- parse_bench/inference/pipelines/__init__.py +59 -0
- parse_bench/inference/pipelines/extract.py +39 -0
- parse_bench/inference/pipelines/layout.py +142 -0
- parse_bench/inference/pipelines/parse.py +2603 -0
- parse_bench/inference/pipelines.py +0 -0
- parse_bench/inference/providers/__init__.py +28 -0
- parse_bench/inference/providers/base.py +196 -0
- parse_bench/inference/providers/cancellation.py +137 -0
- parse_bench/inference/providers/extract/__init__.py +22 -0
- parse_bench/inference/providers/extract/citations.py +549 -0
- parse_bench/inference/providers/extract/extend.py +851 -0
- parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
- parse_bench/inference/providers/layoutdet/__init__.py +25 -0
- parse_bench/inference/providers/layoutdet/adapters.py +946 -0
- parse_bench/inference/providers/layoutdet/base.py +203 -0
- parse_bench/inference/providers/layoutdet/chandra.py +449 -0
- parse_bench/inference/providers/layoutdet/docling.py +125 -0
- parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
- parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
- parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
- parse_bench/inference/providers/layoutdet/paddle.py +117 -0
- parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
- parse_bench/inference/providers/layoutdet/surya.py +250 -0
- parse_bench/inference/providers/layoutdet/yolo.py +109 -0
- parse_bench/inference/providers/parse/__init__.py +64 -0
- parse_bench/inference/providers/parse/_docling_common.py +233 -0
- parse_bench/inference/providers/parse/_layout_utils.py +611 -0
- parse_bench/inference/providers/parse/amazon_nova.py +515 -0
- parse_bench/inference/providers/parse/anthropic.py +882 -0
- parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
- parse_bench/inference/providers/parse/chandra2.py +633 -0
- parse_bench/inference/providers/parse/chunkr.py +268 -0
- parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
- parse_bench/inference/providers/parse/datalab.py +370 -0
- parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
- parse_bench/inference/providers/parse/docling.py +281 -0
- parse_bench/inference/providers/parse/docling_serve.py +289 -0
- parse_bench/inference/providers/parse/dots_ocr.py +574 -0
- parse_bench/inference/providers/parse/extend_parse.py +710 -0
- parse_bench/inference/providers/parse/falconocr.py +436 -0
- parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
- parse_bench/inference/providers/parse/gemma4.py +472 -0
- parse_bench/inference/providers/parse/glm_zai.py +229 -0
- parse_bench/inference/providers/parse/google.py +1125 -0
- parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
- parse_bench/inference/providers/parse/google_docai.py +776 -0
- parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
- parse_bench/inference/providers/parse/granite_vision.py +515 -0
- parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
- parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
- parse_bench/inference/providers/parse/landingai.py +452 -0
- parse_bench/inference/providers/parse/liteparse.py +350 -0
- parse_bench/inference/providers/parse/llamaparse.py +677 -0
- parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
- parse_bench/inference/providers/parse/markitdown.py +138 -0
- parse_bench/inference/providers/parse/mineru25.py +405 -0
- parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
- parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
- parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
- parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
- parse_bench/inference/providers/parse/oi_parser.py +222 -0
- parse_bench/inference/providers/parse/openai.py +740 -0
- parse_bench/inference/providers/parse/opendataloader.py +152 -0
- parse_bench/inference/providers/parse/paddleocr.py +624 -0
- parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
- parse_bench/inference/providers/parse/pulse.py +785 -0
- parse_bench/inference/providers/parse/pymupdf.py +207 -0
- parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
- parse_bench/inference/providers/parse/pypdf.py +179 -0
- parse_bench/inference/providers/parse/qwen.py +678 -0
- parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
- parse_bench/inference/providers/parse/reducto.py +546 -0
- parse_bench/inference/providers/parse/surya2.py +372 -0
- parse_bench/inference/providers/parse/tesseract.py +301 -0
- parse_bench/inference/providers/parse/textract.py +694 -0
- parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
- parse_bench/inference/providers/parse/unstructured.py +485 -0
- parse_bench/inference/providers/parse/warp_ingest.py +199 -0
- parse_bench/inference/providers/registry.py +49 -0
- parse_bench/inference/renormalize.py +170 -0
- parse_bench/inference/runner.py +2023 -0
- parse_bench/layout_label_mapping.py +424 -0
- parse_bench/layout_projection.py +179 -0
- parse_bench/pipeline/__init__.py +1 -0
- parse_bench/pipeline/cli.py +549 -0
- parse_bench/schemas/__init__.py +33 -0
- parse_bench/schemas/evaluation.py +93 -0
- parse_bench/schemas/extract_output.py +36 -0
- parse_bench/schemas/layout_detection_output.py +545 -0
- parse_bench/schemas/layout_ontology.py +315 -0
- parse_bench/schemas/metrics.py +69 -0
- parse_bench/schemas/parse_output.py +152 -0
- parse_bench/schemas/pipeline.py +22 -0
- parse_bench/schemas/pipeline_io.py +106 -0
- parse_bench/schemas/product.py +97 -0
- parse_bench/test_cases/__init__.py +25 -0
- parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
- parse_bench/test_cases/extract_field_paths.py +164 -0
- parse_bench/test_cases/layout_attribution_generation.py +287 -0
- parse_bench/test_cases/loader.py +652 -0
- parse_bench/test_cases/parse_rule_schemas.py +1071 -0
- parse_bench/test_cases/rule_filters.py +32 -0
- parse_bench/test_cases/rule_ids.py +107 -0
- parse_bench/test_cases/schema.py +427 -0
- parse_bench/utils/__init__.py +15 -0
- parse_bench/utils/gemini_layout_utils.py +670 -0
- parse_bench/utils/text_aggregation.py +100 -0
- parse_bench-1.0.0.dist-info/METADATA +476 -0
- parse_bench-1.0.0.dist-info/RECORD +227 -0
- parse_bench-1.0.0.dist-info/WHEEL +4 -0
- parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
- parse_bench-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
"""Provider for Azure Document Intelligence PARSE."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from azure.ai.documentintelligence import DocumentIntelligenceClient
|
|
9
|
+
from azure.ai.documentintelligence.models import AnalyzeResult
|
|
10
|
+
from azure.core.credentials import AzureKeyCredential
|
|
11
|
+
|
|
12
|
+
from parse_bench.inference.providers.base import (
|
|
13
|
+
Provider,
|
|
14
|
+
ProviderConfigError,
|
|
15
|
+
ProviderPermanentError,
|
|
16
|
+
ProviderTransientError,
|
|
17
|
+
)
|
|
18
|
+
from parse_bench.inference.providers.registry import register_provider
|
|
19
|
+
from parse_bench.schemas.parse_output import (
|
|
20
|
+
LayoutItemIR,
|
|
21
|
+
LayoutSegmentIR,
|
|
22
|
+
PageIR,
|
|
23
|
+
ParseLayoutPageIR,
|
|
24
|
+
ParseOutput,
|
|
25
|
+
)
|
|
26
|
+
from parse_bench.schemas.pipeline import PipelineSpec
|
|
27
|
+
from parse_bench.schemas.pipeline_io import (
|
|
28
|
+
InferenceRequest,
|
|
29
|
+
InferenceResult,
|
|
30
|
+
RawInferenceResult,
|
|
31
|
+
)
|
|
32
|
+
from parse_bench.schemas.product import ProductType
|
|
33
|
+
|
|
34
|
+
# Azure DI paragraph role -> Canonical17 label string
|
|
35
|
+
AZURE_DI_LABEL_MAP: dict[str, str] = {
|
|
36
|
+
"title": "Title",
|
|
37
|
+
"sectionHeading": "Section-header",
|
|
38
|
+
"pageHeader": "Page-header",
|
|
39
|
+
"pageFooter": "Page-footer",
|
|
40
|
+
"footnote": "Footnote",
|
|
41
|
+
"pageNumber": "Page-footer",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Default label for paragraphs without a recognized role
|
|
45
|
+
_DEFAULT_PARAGRAPH_LABEL = "Text"
|
|
46
|
+
|
|
47
|
+
# Azure DI selection mark state -> Canonical17 checkbox label
|
|
48
|
+
AZURE_DI_SELECTION_LABEL_MAP: dict[str, str] = {
|
|
49
|
+
"selected": "Checkbox-Selected",
|
|
50
|
+
"unselected": "Checkbox-Unselected",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Virtual page dimensions for normalized coordinate conversion.
|
|
54
|
+
# Azure DI polygons are normalized to [0,1] via page width/height, so these cancel out.
|
|
55
|
+
_VIRTUAL_PAGE_DIM = 1000.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@register_provider("azure_document_intelligence")
|
|
59
|
+
class AzureDocumentIntelligenceProvider(Provider):
|
|
60
|
+
"""
|
|
61
|
+
Provider for Azure Document Intelligence PARSE.
|
|
62
|
+
|
|
63
|
+
This provider uses Azure AI Document Intelligence for parsing tasks.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, provider_name: str, base_config: dict[str, Any] | None = None):
|
|
67
|
+
"""
|
|
68
|
+
Initialize the provider.
|
|
69
|
+
|
|
70
|
+
:param provider_name: Name of the provider
|
|
71
|
+
:param base_config: Optional configuration with:
|
|
72
|
+
- `api_key`: Azure Document Intelligence API key
|
|
73
|
+
(defaults to AZURE_DOCUMENT_INTELLIGENCE_KEY env var)
|
|
74
|
+
- `endpoint`: Azure Document Intelligence endpoint URL
|
|
75
|
+
(defaults to AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT env var)
|
|
76
|
+
- `model_id`: Model to use for analysis (default: "prebuilt-layout")
|
|
77
|
+
Options: "prebuilt-read", "prebuilt-layout", "prebuilt-document"
|
|
78
|
+
- `output_content_format`: Output format - "text" or "markdown"
|
|
79
|
+
(default: "markdown")
|
|
80
|
+
"""
|
|
81
|
+
super().__init__(provider_name, base_config)
|
|
82
|
+
|
|
83
|
+
# Get API key and endpoint
|
|
84
|
+
self._api_key = self.base_config.get("api_key") or os.getenv("AZURE_DOCUMENT_INTELLIGENCE_KEY")
|
|
85
|
+
self._endpoint = self.base_config.get("endpoint") or os.getenv("AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT")
|
|
86
|
+
|
|
87
|
+
if not self._api_key:
|
|
88
|
+
raise ProviderConfigError(
|
|
89
|
+
"Azure Document Intelligence API key is required. "
|
|
90
|
+
"Set AZURE_DOCUMENT_INTELLIGENCE_KEY environment variable "
|
|
91
|
+
"or pass api_key in base_config."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if not self._endpoint:
|
|
95
|
+
raise ProviderConfigError(
|
|
96
|
+
"Azure Document Intelligence endpoint is required. "
|
|
97
|
+
"Set AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT environment variable "
|
|
98
|
+
"or pass endpoint in base_config."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Get configuration with defaults
|
|
102
|
+
self._model_id = self.base_config.get("model_id", "prebuilt-layout")
|
|
103
|
+
self._output_content_format = self.base_config.get("output_content_format", "markdown")
|
|
104
|
+
|
|
105
|
+
# Initialize client
|
|
106
|
+
self._client = DocumentIntelligenceClient(
|
|
107
|
+
endpoint=self._endpoint,
|
|
108
|
+
credential=AzureKeyCredential(self._api_key),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def _parse_pdf(self, pdf_path: str) -> dict[str, Any]:
|
|
112
|
+
"""
|
|
113
|
+
Parse a PDF using Azure Document Intelligence API.
|
|
114
|
+
|
|
115
|
+
:param pdf_path: Path to the PDF file
|
|
116
|
+
:return: Raw API response as dictionary
|
|
117
|
+
:raises ProviderError: For any API errors
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
# Read PDF file
|
|
121
|
+
with open(pdf_path, "rb") as f:
|
|
122
|
+
pdf_bytes = f.read()
|
|
123
|
+
|
|
124
|
+
# Analyze the document
|
|
125
|
+
poller = self._client.begin_analyze_document( # type: ignore[call-overload]
|
|
126
|
+
self._model_id,
|
|
127
|
+
body=pdf_bytes,
|
|
128
|
+
output_content_format=self._output_content_format,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Wait for completion and get result
|
|
132
|
+
result: AnalyzeResult = poller.result()
|
|
133
|
+
|
|
134
|
+
# Convert result to dictionary for raw storage
|
|
135
|
+
raw_response = self._convert_result_to_dict(result)
|
|
136
|
+
|
|
137
|
+
# Store configuration for reference
|
|
138
|
+
raw_response["_config"] = {
|
|
139
|
+
"model_id": self._model_id,
|
|
140
|
+
"output_content_format": self._output_content_format,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return raw_response
|
|
144
|
+
|
|
145
|
+
except FileNotFoundError as e:
|
|
146
|
+
raise ProviderPermanentError(f"PDF file not found: {pdf_path}") from e
|
|
147
|
+
except Exception as e:
|
|
148
|
+
# Check if it's a transient error
|
|
149
|
+
error_str = str(e).lower()
|
|
150
|
+
transient_keywords = [
|
|
151
|
+
"timeout",
|
|
152
|
+
"network",
|
|
153
|
+
"connection",
|
|
154
|
+
"503",
|
|
155
|
+
"502",
|
|
156
|
+
"504",
|
|
157
|
+
"429",
|
|
158
|
+
"throttl",
|
|
159
|
+
"rate limit",
|
|
160
|
+
]
|
|
161
|
+
if any(keyword in error_str for keyword in transient_keywords):
|
|
162
|
+
raise ProviderTransientError(f"Transient error during parsing: {e}") from e
|
|
163
|
+
else:
|
|
164
|
+
raise ProviderPermanentError(f"Error during parsing: {e}") from e
|
|
165
|
+
|
|
166
|
+
def _convert_result_to_dict(self, result: AnalyzeResult) -> dict[str, Any]:
|
|
167
|
+
"""
|
|
168
|
+
Convert Azure Document Intelligence AnalyzeResult to dictionary.
|
|
169
|
+
|
|
170
|
+
:param result: AnalyzeResult from Azure API
|
|
171
|
+
:return: Dictionary representation of the result
|
|
172
|
+
"""
|
|
173
|
+
response: dict[str, Any] = {}
|
|
174
|
+
|
|
175
|
+
# Extract content (full document text/markdown)
|
|
176
|
+
if result.content:
|
|
177
|
+
response["content"] = result.content
|
|
178
|
+
|
|
179
|
+
# Extract pages with their content
|
|
180
|
+
if result.pages:
|
|
181
|
+
pages_data = []
|
|
182
|
+
for page in result.pages:
|
|
183
|
+
page_dict: dict[str, Any] = {
|
|
184
|
+
"page_number": page.page_number,
|
|
185
|
+
"width": page.width,
|
|
186
|
+
"height": page.height,
|
|
187
|
+
"unit": page.unit,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
# Extract lines if available
|
|
191
|
+
if page.lines:
|
|
192
|
+
page_dict["lines"] = [
|
|
193
|
+
{
|
|
194
|
+
"content": line.content,
|
|
195
|
+
"polygon": line.polygon if line.polygon else None,
|
|
196
|
+
}
|
|
197
|
+
for line in page.lines
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
# Extract words if available
|
|
201
|
+
if page.words:
|
|
202
|
+
page_dict["words"] = [
|
|
203
|
+
{
|
|
204
|
+
"content": word.content,
|
|
205
|
+
"polygon": list(word.polygon) if word.polygon else None,
|
|
206
|
+
"confidence": word.confidence if word.confidence is not None else None,
|
|
207
|
+
}
|
|
208
|
+
for word in page.words
|
|
209
|
+
]
|
|
210
|
+
page_dict["word_count"] = len(page.words)
|
|
211
|
+
|
|
212
|
+
# Extract selection marks (checkboxes) if available
|
|
213
|
+
selection_marks = getattr(page, "selection_marks", None)
|
|
214
|
+
if selection_marks:
|
|
215
|
+
page_dict["selection_marks"] = [
|
|
216
|
+
{
|
|
217
|
+
"state": mark.state,
|
|
218
|
+
"polygon": list(mark.polygon) if mark.polygon else None,
|
|
219
|
+
"confidence": mark.confidence if mark.confidence is not None else None,
|
|
220
|
+
}
|
|
221
|
+
for mark in selection_marks
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
pages_data.append(page_dict)
|
|
225
|
+
|
|
226
|
+
response["pages"] = pages_data
|
|
227
|
+
|
|
228
|
+
# Extract tables if available
|
|
229
|
+
if result.tables:
|
|
230
|
+
tables_data = []
|
|
231
|
+
for table in result.tables:
|
|
232
|
+
table_dict: dict[str, Any] = {
|
|
233
|
+
"row_count": table.row_count,
|
|
234
|
+
"column_count": table.column_count,
|
|
235
|
+
"cells": [],
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if table.cells:
|
|
239
|
+
for cell in table.cells:
|
|
240
|
+
cell_dict: dict[str, Any] = {
|
|
241
|
+
"row_index": cell.row_index,
|
|
242
|
+
"column_index": cell.column_index,
|
|
243
|
+
"content": cell.content,
|
|
244
|
+
"row_span": cell.row_span,
|
|
245
|
+
"column_span": cell.column_span,
|
|
246
|
+
# Azure tags structural roles via cell.kind:
|
|
247
|
+
# "columnHeader" / "rowHeader" / "stubHead" / "description"
|
|
248
|
+
# / "content" (default). Used to choose <th> vs <td>
|
|
249
|
+
# when reconstructing table HTML downstream.
|
|
250
|
+
"kind": getattr(cell, "kind", None),
|
|
251
|
+
}
|
|
252
|
+
# Per-cell polygons let downstream tooling overlay
|
|
253
|
+
# individual cell bboxes. Some Azure tables only carry a
|
|
254
|
+
# table-level region, so this is best-effort optional.
|
|
255
|
+
cell_bounding_regions = getattr(cell, "bounding_regions", None)
|
|
256
|
+
if cell_bounding_regions:
|
|
257
|
+
cell_dict["bounding_regions"] = [
|
|
258
|
+
{
|
|
259
|
+
"page_number": br.page_number,
|
|
260
|
+
"polygon": list(br.polygon) if br.polygon else None,
|
|
261
|
+
}
|
|
262
|
+
for br in cell_bounding_regions
|
|
263
|
+
]
|
|
264
|
+
table_dict["cells"].append(cell_dict)
|
|
265
|
+
|
|
266
|
+
tables_data.append(table_dict)
|
|
267
|
+
|
|
268
|
+
response["tables"] = tables_data
|
|
269
|
+
|
|
270
|
+
# Extract paragraphs if available (with bounding regions for layout)
|
|
271
|
+
if result.paragraphs:
|
|
272
|
+
paragraphs_data = []
|
|
273
|
+
for para in result.paragraphs:
|
|
274
|
+
para_dict: dict[str, Any] = {
|
|
275
|
+
"content": para.content,
|
|
276
|
+
"role": para.role if para.role else None,
|
|
277
|
+
}
|
|
278
|
+
if para.bounding_regions:
|
|
279
|
+
para_dict["bounding_regions"] = [
|
|
280
|
+
{
|
|
281
|
+
"page_number": br.page_number,
|
|
282
|
+
"polygon": list(br.polygon) if br.polygon else None,
|
|
283
|
+
}
|
|
284
|
+
for br in para.bounding_regions
|
|
285
|
+
]
|
|
286
|
+
paragraphs_data.append(para_dict)
|
|
287
|
+
response["paragraphs"] = paragraphs_data
|
|
288
|
+
|
|
289
|
+
# Extract tables if available (with bounding regions for layout)
|
|
290
|
+
if result.tables:
|
|
291
|
+
for i, table in enumerate(result.tables):
|
|
292
|
+
if table.bounding_regions and i < len(response.get("tables", [])):
|
|
293
|
+
response["tables"][i]["bounding_regions"] = [
|
|
294
|
+
{
|
|
295
|
+
"page_number": br.page_number,
|
|
296
|
+
"polygon": list(br.polygon) if br.polygon else None,
|
|
297
|
+
}
|
|
298
|
+
for br in table.bounding_regions
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
# Extract figures if available (with bounding regions for layout)
|
|
302
|
+
if result.figures:
|
|
303
|
+
response["figures"] = [
|
|
304
|
+
{
|
|
305
|
+
"caption": fig.caption.content if fig.caption else None,
|
|
306
|
+
"bounding_regions": [
|
|
307
|
+
{
|
|
308
|
+
"page_number": br.page_number,
|
|
309
|
+
"polygon": list(br.polygon) if br.polygon else None,
|
|
310
|
+
}
|
|
311
|
+
for br in fig.bounding_regions
|
|
312
|
+
]
|
|
313
|
+
if fig.bounding_regions
|
|
314
|
+
else [],
|
|
315
|
+
}
|
|
316
|
+
for fig in result.figures
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
# Extract key-value pairs if available
|
|
320
|
+
if result.key_value_pairs:
|
|
321
|
+
response["key_value_pairs"] = [
|
|
322
|
+
{
|
|
323
|
+
"key": kvp.key.content if kvp.key else None,
|
|
324
|
+
"value": kvp.value.content if kvp.value else None,
|
|
325
|
+
}
|
|
326
|
+
for kvp in result.key_value_pairs
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
return response
|
|
330
|
+
|
|
331
|
+
def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
|
|
332
|
+
"""
|
|
333
|
+
Run inference and return raw results.
|
|
334
|
+
|
|
335
|
+
:param pipeline: Pipeline specification
|
|
336
|
+
:param request: Inference request
|
|
337
|
+
:return: Raw inference result
|
|
338
|
+
:raises ProviderError: For any provider-related failures
|
|
339
|
+
"""
|
|
340
|
+
if request.product_type != ProductType.PARSE:
|
|
341
|
+
raise ProviderPermanentError(
|
|
342
|
+
f"AzureDocumentIntelligenceProvider only supports PARSE product type, got {request.product_type}"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
started_at = datetime.now()
|
|
346
|
+
|
|
347
|
+
# Check if file exists
|
|
348
|
+
pdf_path = Path(request.source_file_path)
|
|
349
|
+
if not pdf_path.exists():
|
|
350
|
+
raise ProviderPermanentError(f"PDF file not found: {pdf_path}")
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
# Run parsing
|
|
354
|
+
raw_output = self._parse_pdf(str(pdf_path))
|
|
355
|
+
|
|
356
|
+
completed_at = datetime.now()
|
|
357
|
+
latency_ms = int((completed_at - started_at).total_seconds() * 1000)
|
|
358
|
+
|
|
359
|
+
return RawInferenceResult(
|
|
360
|
+
request=request,
|
|
361
|
+
pipeline=pipeline,
|
|
362
|
+
pipeline_name=pipeline.pipeline_name,
|
|
363
|
+
product_type=request.product_type,
|
|
364
|
+
raw_output=raw_output,
|
|
365
|
+
started_at=started_at,
|
|
366
|
+
completed_at=completed_at,
|
|
367
|
+
latency_in_ms=latency_ms,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
except (ProviderPermanentError, ProviderTransientError, ProviderConfigError):
|
|
371
|
+
raise
|
|
372
|
+
except Exception as e:
|
|
373
|
+
raise ProviderPermanentError(f"Unexpected error during inference: {e}") from e
|
|
374
|
+
|
|
375
|
+
def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
|
|
376
|
+
"""
|
|
377
|
+
Normalize raw inference result to produce ParseOutput.
|
|
378
|
+
|
|
379
|
+
:param raw_result: Raw inference result from run_inference()
|
|
380
|
+
:return: Inference result with both raw and normalized outputs
|
|
381
|
+
:raises ProviderError: For any normalization failures
|
|
382
|
+
"""
|
|
383
|
+
if raw_result.product_type != ProductType.PARSE:
|
|
384
|
+
raise ProviderPermanentError(
|
|
385
|
+
f"AzureDocumentIntelligenceProvider only supports PARSE product type, got {raw_result.product_type}"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# Extract the main content (markdown or text)
|
|
389
|
+
content = raw_result.raw_output.get("content", "")
|
|
390
|
+
|
|
391
|
+
# Build page-level data if available
|
|
392
|
+
pages: list[PageIR] = []
|
|
393
|
+
raw_pages = raw_result.raw_output.get("pages", [])
|
|
394
|
+
|
|
395
|
+
if raw_pages:
|
|
396
|
+
# Azure returns page boundaries, we can try to split content by pages
|
|
397
|
+
# For now, we'll create page entries with line-based content
|
|
398
|
+
for page_data in raw_pages:
|
|
399
|
+
page_num = page_data.get("page_number", 1)
|
|
400
|
+
page_index = page_num - 1 # Convert to 0-indexed
|
|
401
|
+
|
|
402
|
+
# Reconstruct page content from lines if available
|
|
403
|
+
page_content = ""
|
|
404
|
+
if "lines" in page_data:
|
|
405
|
+
page_content = "\n".join(line.get("content", "") for line in page_data.get("lines", []))
|
|
406
|
+
|
|
407
|
+
pages.append(PageIR(page_index=page_index, markdown=page_content))
|
|
408
|
+
|
|
409
|
+
# Build layout_pages for layout cross-evaluation
|
|
410
|
+
layout_pages = _build_layout_pages(raw_result.raw_output)
|
|
411
|
+
|
|
412
|
+
output = ParseOutput(
|
|
413
|
+
task_type="parse",
|
|
414
|
+
example_id=raw_result.request.example_id,
|
|
415
|
+
pipeline_name=raw_result.pipeline_name,
|
|
416
|
+
pages=pages,
|
|
417
|
+
layout_pages=layout_pages,
|
|
418
|
+
markdown=content,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
return InferenceResult(
|
|
422
|
+
request=raw_result.request,
|
|
423
|
+
pipeline_name=raw_result.pipeline_name,
|
|
424
|
+
product_type=raw_result.product_type,
|
|
425
|
+
raw_output=raw_result.raw_output,
|
|
426
|
+
output=output,
|
|
427
|
+
started_at=raw_result.started_at,
|
|
428
|
+
completed_at=raw_result.completed_at,
|
|
429
|
+
latency_in_ms=raw_result.latency_in_ms,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _polygon_to_normalized_bbox(
|
|
434
|
+
polygon: list[float],
|
|
435
|
+
page_width: float,
|
|
436
|
+
page_height: float,
|
|
437
|
+
) -> tuple[float, float, float, float]:
|
|
438
|
+
"""Convert Azure DI polygon (8 floats, 4 corner points in page units) to normalized [0,1] xywh.
|
|
439
|
+
|
|
440
|
+
The polygon contains [x1,y1, x2,y2, x3,y3, x4,y4] in the page's coordinate
|
|
441
|
+
system (typically inches). We take min/max to get axis-aligned bbox, then
|
|
442
|
+
normalize by page dimensions.
|
|
443
|
+
"""
|
|
444
|
+
xs = [polygon[i] for i in range(0, len(polygon), 2)]
|
|
445
|
+
ys = [polygon[i] for i in range(1, len(polygon), 2)]
|
|
446
|
+
x_min = min(xs)
|
|
447
|
+
y_min = min(ys)
|
|
448
|
+
x_max = max(xs)
|
|
449
|
+
y_max = max(ys)
|
|
450
|
+
|
|
451
|
+
# Normalize to [0, 1]
|
|
452
|
+
nx = x_min / page_width if page_width > 0 else 0.0
|
|
453
|
+
ny = y_min / page_height if page_height > 0 else 0.0
|
|
454
|
+
nw = (x_max - x_min) / page_width if page_width > 0 else 0.0
|
|
455
|
+
nh = (y_max - y_min) / page_height if page_height > 0 else 0.0
|
|
456
|
+
|
|
457
|
+
return (nx, ny, nw, nh)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _build_table_html_from_cells(
|
|
461
|
+
cells: list[dict[str, Any]],
|
|
462
|
+
row_count: int,
|
|
463
|
+
column_count: int,
|
|
464
|
+
) -> str:
|
|
465
|
+
"""Reconstruct a ``<table>`` HTML string from Azure DI cell dicts.
|
|
466
|
+
|
|
467
|
+
Honors ``row_span`` / ``column_span`` (cells covered by a previous
|
|
468
|
+
cell's span are skipped, not double-emitted) and Azure's ``cell.kind``
|
|
469
|
+
(``"columnHeader"`` / ``"rowHeader"`` → ``<th>``; everything else →
|
|
470
|
+
``<td>``). Cells without integer ``row_index`` / ``column_index`` are
|
|
471
|
+
skipped silently — they're structural only.
|
|
472
|
+
|
|
473
|
+
Returns ``""`` when input is degenerate (no cells, missing row/col
|
|
474
|
+
counts) so callers can fall back to flat-text content.
|
|
475
|
+
"""
|
|
476
|
+
from html import escape as _html_escape
|
|
477
|
+
|
|
478
|
+
if not cells or row_count <= 0 or column_count <= 0:
|
|
479
|
+
return ""
|
|
480
|
+
|
|
481
|
+
grid: dict[tuple[int, int], dict[str, Any]] = {}
|
|
482
|
+
for cell in cells:
|
|
483
|
+
r = cell.get("row_index")
|
|
484
|
+
c = cell.get("column_index")
|
|
485
|
+
if isinstance(r, int) and isinstance(c, int):
|
|
486
|
+
grid[(r, c)] = cell
|
|
487
|
+
|
|
488
|
+
covered: set[tuple[int, int]] = set()
|
|
489
|
+
rows_html: list[str] = []
|
|
490
|
+
for r in range(row_count):
|
|
491
|
+
cells_html: list[str] = []
|
|
492
|
+
for c in range(column_count):
|
|
493
|
+
if (r, c) in covered:
|
|
494
|
+
continue
|
|
495
|
+
cell_at_pos = grid.get((r, c))
|
|
496
|
+
if cell_at_pos is None:
|
|
497
|
+
cells_html.append("<td></td>")
|
|
498
|
+
continue
|
|
499
|
+
row_span = int(cell_at_pos.get("row_span") or 1)
|
|
500
|
+
col_span = int(cell_at_pos.get("column_span") or 1)
|
|
501
|
+
for dr in range(row_span):
|
|
502
|
+
for dc in range(col_span):
|
|
503
|
+
if dr == 0 and dc == 0:
|
|
504
|
+
continue
|
|
505
|
+
covered.add((r + dr, c + dc))
|
|
506
|
+
attrs = ""
|
|
507
|
+
if row_span > 1:
|
|
508
|
+
attrs += f' rowspan="{row_span}"'
|
|
509
|
+
if col_span > 1:
|
|
510
|
+
attrs += f' colspan="{col_span}"'
|
|
511
|
+
kind = (cell_at_pos.get("kind") or "").strip()
|
|
512
|
+
tag = "th" if kind in ("columnHeader", "rowHeader") else "td"
|
|
513
|
+
content = _html_escape(cell_at_pos.get("content") or "")
|
|
514
|
+
cells_html.append(f"<{tag}{attrs}>{content}</{tag}>")
|
|
515
|
+
# Always emit a <tr> for every row in row_count, even when every
|
|
516
|
+
# position is covered by a rowspan from a previous row. HTML
|
|
517
|
+
# counts <tr> elements to resolve rowspan targets — dropping
|
|
518
|
+
# the empty <tr> would push the next row's cells into the wrong
|
|
519
|
+
# row index. The empty <tr></tr> renders as an invisible row in
|
|
520
|
+
# browsers and parses cleanly under lxml + BeautifulSoup, which
|
|
521
|
+
# is what the TEDS evaluator uses.
|
|
522
|
+
rows_html.append("<tr>" + "".join(cells_html) + "</tr>")
|
|
523
|
+
|
|
524
|
+
return "<table>" + "".join(rows_html) + "</table>"
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _bbox_center_inside_any(
|
|
528
|
+
bbox: tuple[float, float, float, float],
|
|
529
|
+
others: list[tuple[float, float, float, float]],
|
|
530
|
+
) -> bool:
|
|
531
|
+
"""``True`` when the center of ``bbox`` lies inside any rectangle in
|
|
532
|
+
``others``. Both rectangles are normalized xywh in the same coord
|
|
533
|
+
system. Used to filter Azure DI cell-as-paragraph duplicates.
|
|
534
|
+
"""
|
|
535
|
+
cx = bbox[0] + bbox[2] / 2.0
|
|
536
|
+
cy = bbox[1] + bbox[3] / 2.0
|
|
537
|
+
for ox, oy, ow, oh in others:
|
|
538
|
+
if ox <= cx <= ox + ow and oy <= cy <= oy + oh:
|
|
539
|
+
return True
|
|
540
|
+
return False
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _build_layout_pages(raw_output: dict[str, Any]) -> list[ParseLayoutPageIR]:
|
|
544
|
+
"""Build layout_pages from Azure DI paragraphs/tables/figures for layout cross-evaluation.
|
|
545
|
+
|
|
546
|
+
Groups elements by page using bounding_regions and converts Azure DI polygon
|
|
547
|
+
coordinates (in page units) into normalized [0,1] LayoutSegmentIR entries.
|
|
548
|
+
"""
|
|
549
|
+
from collections import defaultdict
|
|
550
|
+
|
|
551
|
+
# Build page dimension lookup from pages data
|
|
552
|
+
page_dims: dict[int, tuple[float, float]] = {}
|
|
553
|
+
for page_data in raw_output.get("pages", []):
|
|
554
|
+
page_num = page_data.get("page_number", 1)
|
|
555
|
+
width = float(page_data.get("width", 1.0))
|
|
556
|
+
height = float(page_data.get("height", 1.0))
|
|
557
|
+
page_dims[page_num] = (width, height)
|
|
558
|
+
|
|
559
|
+
# Collect all layout elements grouped by page:
|
|
560
|
+
# (canonical_label, nx, ny, nw, nh, value_text, confidence, html_or_empty).
|
|
561
|
+
# html_or_empty is populated only for table items; other element kinds
|
|
562
|
+
# push an empty string so the tuple stays uniform.
|
|
563
|
+
pages_items: dict[int, list[tuple[str, float, float, float, float, str, float, str]]] = defaultdict(list)
|
|
564
|
+
|
|
565
|
+
# Pre-compute table rectangles per page so we can skip paragraphs
|
|
566
|
+
# whose bbox sits inside one. Azure DI returns each table cell *both*
|
|
567
|
+
# as a ``tables[*].cells`` entry (which we use for HTML
|
|
568
|
+
# reconstruction) and as a standalone ``paragraphs[]`` entry —
|
|
569
|
+
# typically with no role and the same bbox as the cell. Without this
|
|
570
|
+
# filter the layout output ends up with one Text bbox per cell on
|
|
571
|
+
# top of the parent Table region.
|
|
572
|
+
table_rects_by_page: dict[int, list[tuple[float, float, float, float]]] = defaultdict(list)
|
|
573
|
+
for table in raw_output.get("tables", []):
|
|
574
|
+
for br in table.get("bounding_regions", []):
|
|
575
|
+
page_num = br.get("page_number", 1)
|
|
576
|
+
polygon = br.get("polygon")
|
|
577
|
+
if not polygon or len(polygon) < 8:
|
|
578
|
+
continue
|
|
579
|
+
pw, ph = page_dims.get(page_num, (1.0, 1.0))
|
|
580
|
+
table_rects_by_page[page_num].append(_polygon_to_normalized_bbox(polygon, pw, ph))
|
|
581
|
+
|
|
582
|
+
# Process paragraphs
|
|
583
|
+
for para in raw_output.get("paragraphs", []):
|
|
584
|
+
role = para.get("role")
|
|
585
|
+
canonical_label = AZURE_DI_LABEL_MAP.get(role, _DEFAULT_PARAGRAPH_LABEL) if role else _DEFAULT_PARAGRAPH_LABEL
|
|
586
|
+
content = para.get("content", "")
|
|
587
|
+
|
|
588
|
+
for br in para.get("bounding_regions", []):
|
|
589
|
+
page_num = br.get("page_number", 1)
|
|
590
|
+
polygon = br.get("polygon")
|
|
591
|
+
if not polygon or len(polygon) < 8:
|
|
592
|
+
continue
|
|
593
|
+
pw, ph = page_dims.get(page_num, (1.0, 1.0))
|
|
594
|
+
nx, ny, nw, nh = _polygon_to_normalized_bbox(polygon, pw, ph)
|
|
595
|
+
if _bbox_center_inside_any((nx, ny, nw, nh), table_rects_by_page.get(page_num, [])):
|
|
596
|
+
# Cell-as-paragraph duplicate. Cell content already lives
|
|
597
|
+
# in the table region's HTML / value via
|
|
598
|
+
# _build_table_html_from_cells; emitting it again would
|
|
599
|
+
# double-count.
|
|
600
|
+
continue
|
|
601
|
+
pages_items[page_num].append((canonical_label, nx, ny, nw, nh, content, 1.0, ""))
|
|
602
|
+
|
|
603
|
+
# Process tables
|
|
604
|
+
for table in raw_output.get("tables", []):
|
|
605
|
+
# Flat text fallback for the LayoutItemIR.value field — kept for
|
|
606
|
+
# compatibility with callers that don't read .html. Structured
|
|
607
|
+
# HTML reconstruction (handed to LayoutItemIR.html) preserves
|
|
608
|
+
# <th>/<td> roles and spans for table metrics.
|
|
609
|
+
cells = table.get("cells", [])
|
|
610
|
+
content = " ".join(c.get("content", "") for c in cells if c.get("content"))
|
|
611
|
+
html_content = _build_table_html_from_cells(
|
|
612
|
+
cells,
|
|
613
|
+
int(table.get("row_count") or 0),
|
|
614
|
+
int(table.get("column_count") or 0),
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
for br in table.get("bounding_regions", []):
|
|
618
|
+
page_num = br.get("page_number", 1)
|
|
619
|
+
polygon = br.get("polygon")
|
|
620
|
+
if not polygon or len(polygon) < 8:
|
|
621
|
+
continue
|
|
622
|
+
pw, ph = page_dims.get(page_num, (1.0, 1.0))
|
|
623
|
+
nx, ny, nw, nh = _polygon_to_normalized_bbox(polygon, pw, ph)
|
|
624
|
+
pages_items[page_num].append(("Table", nx, ny, nw, nh, content, 1.0, html_content))
|
|
625
|
+
|
|
626
|
+
# Process figures
|
|
627
|
+
for fig in raw_output.get("figures", []):
|
|
628
|
+
caption = fig.get("caption") or ""
|
|
629
|
+
for br in fig.get("bounding_regions", []):
|
|
630
|
+
page_num = br.get("page_number", 1)
|
|
631
|
+
polygon = br.get("polygon")
|
|
632
|
+
if not polygon or len(polygon) < 8:
|
|
633
|
+
continue
|
|
634
|
+
pw, ph = page_dims.get(page_num, (1.0, 1.0))
|
|
635
|
+
nx, ny, nw, nh = _polygon_to_normalized_bbox(polygon, pw, ph)
|
|
636
|
+
pages_items[page_num].append(("Picture", nx, ny, nw, nh, caption, 1.0, ""))
|
|
637
|
+
|
|
638
|
+
# Process selection marks (checkboxes) — live on page objects, not at document root
|
|
639
|
+
for page_data in raw_output.get("pages", []):
|
|
640
|
+
page_num = page_data.get("page_number", 1)
|
|
641
|
+
pw, ph = page_dims.get(page_num, (1.0, 1.0))
|
|
642
|
+
for mark in page_data.get("selection_marks", []) or []:
|
|
643
|
+
state = mark.get("state") or ""
|
|
644
|
+
checkbox_label = AZURE_DI_SELECTION_LABEL_MAP.get(state)
|
|
645
|
+
if checkbox_label is None:
|
|
646
|
+
continue
|
|
647
|
+
polygon = mark.get("polygon")
|
|
648
|
+
if not polygon or len(polygon) < 8:
|
|
649
|
+
continue
|
|
650
|
+
nx, ny, nw, nh = _polygon_to_normalized_bbox(polygon, pw, ph)
|
|
651
|
+
confidence = mark.get("confidence")
|
|
652
|
+
confidence_val = float(confidence) if confidence is not None else 1.0
|
|
653
|
+
pages_items[page_num].append((checkbox_label, nx, ny, nw, nh, "", confidence_val, ""))
|
|
654
|
+
|
|
655
|
+
# Build ParseLayoutPageIR list
|
|
656
|
+
layout_pages: list[ParseLayoutPageIR] = []
|
|
657
|
+
for page_num in sorted(pages_items.keys()):
|
|
658
|
+
items_data = pages_items[page_num]
|
|
659
|
+
items: list[LayoutItemIR] = []
|
|
660
|
+
|
|
661
|
+
for canonical_label, nx, ny, nw, nh, content, confidence, html_content in items_data:
|
|
662
|
+
seg = LayoutSegmentIR(
|
|
663
|
+
x=nx,
|
|
664
|
+
y=ny,
|
|
665
|
+
w=nw,
|
|
666
|
+
h=nh,
|
|
667
|
+
confidence=confidence,
|
|
668
|
+
label=canonical_label,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
norm_label = canonical_label.strip().lower()
|
|
672
|
+
if norm_label == "table":
|
|
673
|
+
item_type = "table"
|
|
674
|
+
elif norm_label == "picture":
|
|
675
|
+
item_type = "image"
|
|
676
|
+
elif canonical_label in AZURE_DI_SELECTION_LABEL_MAP.values():
|
|
677
|
+
item_type = "checkbox"
|
|
678
|
+
else:
|
|
679
|
+
item_type = "text"
|
|
680
|
+
|
|
681
|
+
items.append(
|
|
682
|
+
LayoutItemIR(
|
|
683
|
+
type=item_type,
|
|
684
|
+
value=content,
|
|
685
|
+
html=html_content,
|
|
686
|
+
bbox=seg,
|
|
687
|
+
layout_segments=[seg],
|
|
688
|
+
)
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
layout_pages.append(
|
|
692
|
+
ParseLayoutPageIR(
|
|
693
|
+
page_number=page_num,
|
|
694
|
+
width=_VIRTUAL_PAGE_DIM,
|
|
695
|
+
height=_VIRTUAL_PAGE_DIM,
|
|
696
|
+
items=items,
|
|
697
|
+
)
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
return layout_pages
|