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,694 @@
|
|
|
1
|
+
"""Provider for AWS Textract document parsing."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from parse_bench.inference.providers.base import (
|
|
9
|
+
Provider,
|
|
10
|
+
ProviderConfigError,
|
|
11
|
+
ProviderPermanentError,
|
|
12
|
+
ProviderTransientError,
|
|
13
|
+
)
|
|
14
|
+
from parse_bench.inference.providers.registry import register_provider
|
|
15
|
+
from parse_bench.schemas.parse_output import (
|
|
16
|
+
LayoutItemIR,
|
|
17
|
+
LayoutSegmentIR,
|
|
18
|
+
PageIR,
|
|
19
|
+
ParseLayoutPageIR,
|
|
20
|
+
ParseOutput,
|
|
21
|
+
)
|
|
22
|
+
from parse_bench.schemas.pipeline import PipelineSpec
|
|
23
|
+
from parse_bench.schemas.pipeline_io import (
|
|
24
|
+
InferenceRequest,
|
|
25
|
+
InferenceResult,
|
|
26
|
+
RawInferenceResult,
|
|
27
|
+
)
|
|
28
|
+
from parse_bench.schemas.product import ProductType
|
|
29
|
+
|
|
30
|
+
# Textract LAYOUT_* BlockType -> Canonical17 label string
|
|
31
|
+
TEXTRACT_LABEL_MAP: dict[str, str] = {
|
|
32
|
+
"LAYOUT_TITLE": "Title",
|
|
33
|
+
"LAYOUT_SECTION_HEADER": "Section-header",
|
|
34
|
+
"LAYOUT_TEXT": "Text",
|
|
35
|
+
"LAYOUT_TABLE": "Table",
|
|
36
|
+
"LAYOUT_FIGURE": "Picture",
|
|
37
|
+
"LAYOUT_LIST": "List-item",
|
|
38
|
+
"LAYOUT_HEADER": "Page-header",
|
|
39
|
+
"LAYOUT_FOOTER": "Page-footer",
|
|
40
|
+
"LAYOUT_PAGE_NUMBER": "Page-footer",
|
|
41
|
+
"LAYOUT_KEY_VALUE": "Key-Value Region",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Textract SELECTION_ELEMENT SelectionStatus -> Canonical17 checkbox label.
|
|
45
|
+
# SELECTION_ELEMENT blocks are only emitted when the FORMS feature is enabled.
|
|
46
|
+
TEXTRACT_SELECTION_LABEL_MAP: dict[str, str] = {
|
|
47
|
+
"SELECTED": "Checkbox-Selected",
|
|
48
|
+
"NOT_SELECTED": "Checkbox-Unselected",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Virtual page dimensions for normalized coordinate conversion.
|
|
52
|
+
# Textract BoundingBox is already [0,1], so these cancel out during evaluation.
|
|
53
|
+
_VIRTUAL_PAGE_DIM = 1000.0
|
|
54
|
+
|
|
55
|
+
# Textract per-page pricing (USD), first 1M pages/month tier, US West (Oregon).
|
|
56
|
+
# Keyed by (detect_tables, detect_forms). Source: https://aws.amazon.com/textract/pricing/
|
|
57
|
+
# Layout is included free when combined with Tables; when used alone it has its own tier.
|
|
58
|
+
_TEXTRACT_COST_PER_PAGE_USD: dict[tuple[bool, bool], float] = {
|
|
59
|
+
(False, False): 0.004, # AnalyzeDocument - Layout only
|
|
60
|
+
(True, False): 0.015, # AnalyzeDocument - Tables (Layout free with Tables)
|
|
61
|
+
(False, True): 0.050, # AnalyzeDocument - Forms
|
|
62
|
+
(True, True): 0.065, # AnalyzeDocument - Tables + Forms
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@register_provider("textract")
|
|
67
|
+
class TextractProvider(Provider):
|
|
68
|
+
"""
|
|
69
|
+
Provider for AWS Textract document parsing.
|
|
70
|
+
|
|
71
|
+
Extracts text, tables, and forms from PDFs and images using AWS Textract.
|
|
72
|
+
Tables are converted to HTML to preserve their visual structure.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, provider_name: str, base_config: dict[str, Any] | None = None):
|
|
76
|
+
"""
|
|
77
|
+
Initialize the provider.
|
|
78
|
+
|
|
79
|
+
:param provider_name: Name of the provider
|
|
80
|
+
:param base_config: Optional configuration with:
|
|
81
|
+
- `aws_access_key_id`: AWS access key (or use AWS_ACCESS_KEY_ID env var)
|
|
82
|
+
- `aws_secret_access_key`: AWS secret key (or use AWS_SECRET_ACCESS_KEY env var)
|
|
83
|
+
- `aws_region`: AWS region (default: "us-east-1", or use AWS_REGION env var)
|
|
84
|
+
- `output_tables_as_html`: Whether to output tables as HTML (default: True)
|
|
85
|
+
- `detect_tables`: Whether to detect tables (default: True)
|
|
86
|
+
- `detect_forms`: Whether to detect forms/key-value pairs and checkbox
|
|
87
|
+
selection elements (default: False). Required for Checkbox-Selected /
|
|
88
|
+
Checkbox-Unselected layout predictions. Note that enabling FORMS
|
|
89
|
+
roughly ~5x the AWS per-page price vs. LAYOUT-only.
|
|
90
|
+
"""
|
|
91
|
+
super().__init__(provider_name, base_config)
|
|
92
|
+
|
|
93
|
+
# Get AWS credentials from config or environment
|
|
94
|
+
self._aws_access_key_id = self.base_config.get("aws_access_key_id", os.environ.get("AWS_ACCESS_KEY_ID"))
|
|
95
|
+
self._aws_secret_access_key = self.base_config.get(
|
|
96
|
+
"aws_secret_access_key", os.environ.get("AWS_SECRET_ACCESS_KEY")
|
|
97
|
+
)
|
|
98
|
+
self._aws_region = self.base_config.get("aws_region", os.environ.get("AWS_REGION", "us-east-1"))
|
|
99
|
+
|
|
100
|
+
# Configuration options
|
|
101
|
+
self._output_tables_as_html = self.base_config.get("output_tables_as_html", True)
|
|
102
|
+
self._detect_tables = self.base_config.get("detect_tables", True)
|
|
103
|
+
self._detect_forms = self.base_config.get("detect_forms", False)
|
|
104
|
+
|
|
105
|
+
# Validate credentials
|
|
106
|
+
if not self._aws_access_key_id or not self._aws_secret_access_key:
|
|
107
|
+
raise ProviderConfigError(
|
|
108
|
+
"AWS credentials not configured. Set AWS_ACCESS_KEY_ID and "
|
|
109
|
+
"AWS_SECRET_ACCESS_KEY environment variables or provide them in config."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Initialize boto3 client
|
|
113
|
+
try:
|
|
114
|
+
import boto3
|
|
115
|
+
except ImportError as e:
|
|
116
|
+
raise ProviderConfigError("boto3 package not installed. Run: pip install boto3") from e
|
|
117
|
+
|
|
118
|
+
self._textract_client = boto3.client(
|
|
119
|
+
"textract",
|
|
120
|
+
aws_access_key_id=self._aws_access_key_id,
|
|
121
|
+
aws_secret_access_key=self._aws_secret_access_key,
|
|
122
|
+
region_name=self._aws_region,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Textract synchronous API limits
|
|
126
|
+
_MAX_DIMENSION = 10000 # Max 10,000 pixels in any dimension
|
|
127
|
+
_MAX_BYTES = 10 * 1024 * 1024 # Max 10 MB
|
|
128
|
+
_TARGET_BYTES = 9 * 1024 * 1024 # Target 9 MB to leave margin
|
|
129
|
+
|
|
130
|
+
def _resize_image_for_textract(self, image: Any) -> bytes:
|
|
131
|
+
"""
|
|
132
|
+
Resize and compress an image to fit within Textract's limits.
|
|
133
|
+
|
|
134
|
+
Textract synchronous API limits:
|
|
135
|
+
- Max dimension: 10,000 pixels
|
|
136
|
+
- Max file size: 10 MB
|
|
137
|
+
|
|
138
|
+
:param image: PIL Image object
|
|
139
|
+
:return: PNG bytes that fit within Textract limits
|
|
140
|
+
"""
|
|
141
|
+
import io
|
|
142
|
+
|
|
143
|
+
from PIL import Image
|
|
144
|
+
|
|
145
|
+
# Step 1: Resize if dimensions exceed limit
|
|
146
|
+
width, height = image.size
|
|
147
|
+
if width > self._MAX_DIMENSION or height > self._MAX_DIMENSION:
|
|
148
|
+
scale = min(self._MAX_DIMENSION / width, self._MAX_DIMENSION / height)
|
|
149
|
+
new_width = int(width * scale)
|
|
150
|
+
new_height = int(height * scale)
|
|
151
|
+
image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
152
|
+
|
|
153
|
+
# Step 2: Try PNG first
|
|
154
|
+
img_buffer = io.BytesIO()
|
|
155
|
+
image.save(img_buffer, format="PNG", optimize=True)
|
|
156
|
+
img_bytes = img_buffer.getvalue()
|
|
157
|
+
|
|
158
|
+
# Step 3: If still too large, progressively reduce size
|
|
159
|
+
scale = 0.9
|
|
160
|
+
while len(img_bytes) > self._TARGET_BYTES and scale > 0.3:
|
|
161
|
+
new_width = int(image.size[0] * scale)
|
|
162
|
+
new_height = int(image.size[1] * scale)
|
|
163
|
+
resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
164
|
+
|
|
165
|
+
img_buffer = io.BytesIO()
|
|
166
|
+
resized.save(img_buffer, format="PNG", optimize=True)
|
|
167
|
+
img_bytes = img_buffer.getvalue()
|
|
168
|
+
|
|
169
|
+
if len(img_bytes) <= self._TARGET_BYTES:
|
|
170
|
+
break
|
|
171
|
+
scale *= 0.9
|
|
172
|
+
|
|
173
|
+
return img_bytes
|
|
174
|
+
|
|
175
|
+
def _analyze_document(self, file_path: str) -> dict[str, Any]:
|
|
176
|
+
"""
|
|
177
|
+
Analyze a document using AWS Textract.
|
|
178
|
+
|
|
179
|
+
:param file_path: Path to the PDF or image file
|
|
180
|
+
:return: Raw Textract API response
|
|
181
|
+
:raises ProviderError: For any API errors
|
|
182
|
+
"""
|
|
183
|
+
try:
|
|
184
|
+
from botocore.exceptions import ClientError
|
|
185
|
+
except ImportError as e:
|
|
186
|
+
raise ProviderConfigError("botocore package not installed. Run: pip install boto3") from e
|
|
187
|
+
|
|
188
|
+
# Read the file and check if it needs resizing (for images)
|
|
189
|
+
path = Path(file_path)
|
|
190
|
+
suffix = path.suffix.lower()
|
|
191
|
+
|
|
192
|
+
if suffix in {".png", ".jpg", ".jpeg", ".tiff", ".tif"}:
|
|
193
|
+
# For images, load and resize if needed
|
|
194
|
+
from PIL import Image
|
|
195
|
+
|
|
196
|
+
with Image.open(file_path) as img:
|
|
197
|
+
document_bytes = self._resize_image_for_textract(img)
|
|
198
|
+
else:
|
|
199
|
+
# For other formats (shouldn't happen), read as-is
|
|
200
|
+
with open(file_path, "rb") as f:
|
|
201
|
+
document_bytes = f.read()
|
|
202
|
+
|
|
203
|
+
# Determine which features to analyze
|
|
204
|
+
feature_types = ["LAYOUT"]
|
|
205
|
+
if self._detect_tables:
|
|
206
|
+
feature_types.append("TABLES")
|
|
207
|
+
if self._detect_forms:
|
|
208
|
+
feature_types.append("FORMS")
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
if feature_types:
|
|
212
|
+
response = self._textract_client.analyze_document(
|
|
213
|
+
Document={"Bytes": document_bytes},
|
|
214
|
+
FeatureTypes=feature_types,
|
|
215
|
+
)
|
|
216
|
+
else:
|
|
217
|
+
# Just detect text without tables/forms
|
|
218
|
+
response = self._textract_client.detect_document_text(Document={"Bytes": document_bytes})
|
|
219
|
+
return response # type: ignore[no-any-return]
|
|
220
|
+
|
|
221
|
+
except ClientError as e:
|
|
222
|
+
error_code = e.response.get("Error", {}).get("Code", "")
|
|
223
|
+
error_message = e.response.get("Error", {}).get("Message", str(e))
|
|
224
|
+
|
|
225
|
+
# Categorize errors
|
|
226
|
+
if error_code in ("ThrottlingException", "ProvisionedThroughputExceededException"):
|
|
227
|
+
raise ProviderTransientError(f"Rate limit exceeded: {error_message}") from e
|
|
228
|
+
elif error_code in ("InvalidParameterException", "UnsupportedDocumentException"):
|
|
229
|
+
raise ProviderPermanentError(f"Invalid document: {error_message}") from e
|
|
230
|
+
elif error_code in ("AccessDeniedException", "InvalidS3ObjectException"):
|
|
231
|
+
raise ProviderConfigError(f"AWS access error: {error_message}") from e
|
|
232
|
+
else:
|
|
233
|
+
raise ProviderTransientError(f"AWS Textract error: {error_message}") from e
|
|
234
|
+
except Exception as e:
|
|
235
|
+
raise ProviderTransientError(f"Unexpected error calling Textract: {e}") from e
|
|
236
|
+
|
|
237
|
+
def _analyze_multipage_document(self, file_path: str) -> dict[str, Any]:
|
|
238
|
+
"""
|
|
239
|
+
Analyze a multi-page document using AWS Textract async API.
|
|
240
|
+
|
|
241
|
+
For PDFs, Textract requires using S3 + async operations for multi-page.
|
|
242
|
+
This method handles single-page PDFs and images via synchronous API,
|
|
243
|
+
and falls back to page-by-page processing for multi-page PDFs.
|
|
244
|
+
|
|
245
|
+
:param file_path: Path to the document file
|
|
246
|
+
:return: Combined Textract response
|
|
247
|
+
"""
|
|
248
|
+
path = Path(file_path)
|
|
249
|
+
suffix = path.suffix.lower()
|
|
250
|
+
|
|
251
|
+
# For images, use direct synchronous API
|
|
252
|
+
if suffix in {".png", ".jpg", ".jpeg", ".tiff", ".tif"}:
|
|
253
|
+
return self._analyze_document(file_path)
|
|
254
|
+
|
|
255
|
+
# For PDFs, convert each page to image and process
|
|
256
|
+
try:
|
|
257
|
+
from pdf2image import convert_from_path
|
|
258
|
+
except ImportError as e:
|
|
259
|
+
raise ProviderConfigError("pdf2image package not installed. Run: pip install pdf2image") from e
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
images = convert_from_path(file_path, dpi=300)
|
|
263
|
+
except Exception as e:
|
|
264
|
+
raise ProviderPermanentError(f"Failed to convert PDF to images: {e}") from e
|
|
265
|
+
|
|
266
|
+
all_blocks: list[dict[str, Any]] = []
|
|
267
|
+
current_page = 0
|
|
268
|
+
|
|
269
|
+
for page_num, image in enumerate(images):
|
|
270
|
+
# Convert PIL image to bytes, resizing if needed for Textract limits
|
|
271
|
+
img_bytes = self._resize_image_for_textract(image)
|
|
272
|
+
|
|
273
|
+
# Analyze this page
|
|
274
|
+
feature_types = ["LAYOUT"]
|
|
275
|
+
if self._detect_tables:
|
|
276
|
+
feature_types.append("TABLES")
|
|
277
|
+
if self._detect_forms:
|
|
278
|
+
feature_types.append("FORMS")
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
from botocore.exceptions import ClientError
|
|
282
|
+
|
|
283
|
+
if feature_types:
|
|
284
|
+
response = self._textract_client.analyze_document(
|
|
285
|
+
Document={"Bytes": img_bytes},
|
|
286
|
+
FeatureTypes=feature_types,
|
|
287
|
+
)
|
|
288
|
+
else:
|
|
289
|
+
response = self._textract_client.detect_document_text(Document={"Bytes": img_bytes})
|
|
290
|
+
|
|
291
|
+
# Add page number to blocks and accumulate
|
|
292
|
+
for block in response.get("Blocks", []):
|
|
293
|
+
block["Page"] = page_num + 1
|
|
294
|
+
all_blocks.append(block)
|
|
295
|
+
|
|
296
|
+
current_page = page_num + 1
|
|
297
|
+
|
|
298
|
+
except ClientError as e:
|
|
299
|
+
error_code = e.response.get("Error", {}).get("Code", "")
|
|
300
|
+
error_message = e.response.get("Error", {}).get("Message", str(e))
|
|
301
|
+
|
|
302
|
+
if error_code in ("ThrottlingException", "ProvisionedThroughputExceededException"):
|
|
303
|
+
raise ProviderTransientError(f"Rate limit exceeded: {error_message}") from e
|
|
304
|
+
elif error_code in ("InvalidParameterException", "UnsupportedDocumentException"):
|
|
305
|
+
raise ProviderPermanentError(f"Invalid document: {error_message}") from e
|
|
306
|
+
else:
|
|
307
|
+
raise ProviderTransientError(f"AWS Textract error: {error_message}") from e
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
"Blocks": all_blocks,
|
|
311
|
+
"DocumentMetadata": {"Pages": current_page},
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
def _convert_to_markdown(self, textract_response: dict[str, Any]) -> dict[str, Any]:
|
|
315
|
+
"""
|
|
316
|
+
Convert Textract response to markdown format with HTML tables.
|
|
317
|
+
|
|
318
|
+
Uses the amazon-textract-textractor library to properly parse
|
|
319
|
+
and convert tables to HTML while preserving their visual structure.
|
|
320
|
+
|
|
321
|
+
:param textract_response: Raw Textract API response
|
|
322
|
+
:return: Dict with pages and markdown content
|
|
323
|
+
"""
|
|
324
|
+
try:
|
|
325
|
+
from textractor.parsers import response_parser
|
|
326
|
+
except ImportError as e:
|
|
327
|
+
raise ProviderConfigError(
|
|
328
|
+
"amazon-textract-textractor package not installed. Run: pip install amazon-textract-textractor"
|
|
329
|
+
) from e
|
|
330
|
+
|
|
331
|
+
# Parse the response using textractor
|
|
332
|
+
document = response_parser.parse(textract_response)
|
|
333
|
+
|
|
334
|
+
# Get number of pages
|
|
335
|
+
num_pages = textract_response.get("DocumentMetadata", {}).get("Pages", 1)
|
|
336
|
+
|
|
337
|
+
pages_content: dict[int, list[str]] = {i: [] for i in range(1, num_pages + 1)}
|
|
338
|
+
|
|
339
|
+
# Process each page — interleave lines and tables by y-position
|
|
340
|
+
for page in document.pages:
|
|
341
|
+
page_num = page.page_num
|
|
342
|
+
|
|
343
|
+
# Collect all elements with their y-positions for reading order
|
|
344
|
+
elements: list[tuple[float, str]] = []
|
|
345
|
+
|
|
346
|
+
for line in page.lines:
|
|
347
|
+
# Skip lines that are part of tables
|
|
348
|
+
if not self._is_in_table(line, page):
|
|
349
|
+
y_pos = line.bbox.y if hasattr(line, "bbox") and line.bbox else 0.0
|
|
350
|
+
elements.append((y_pos, line.text))
|
|
351
|
+
|
|
352
|
+
if self._detect_tables and self._output_tables_as_html:
|
|
353
|
+
for table in page.tables:
|
|
354
|
+
y_pos = table.bbox.y if hasattr(table, "bbox") and table.bbox else 0.0
|
|
355
|
+
# Use textractor's built-in to_html() which handles colspan/rowspan
|
|
356
|
+
html_table = table.to_html() if hasattr(table, "to_html") else ""
|
|
357
|
+
if html_table:
|
|
358
|
+
elements.append((y_pos, html_table))
|
|
359
|
+
|
|
360
|
+
# Sort by y-position to reconstruct reading order
|
|
361
|
+
elements.sort(key=lambda x: x[0])
|
|
362
|
+
pages_content[page_num] = [elem[1] for elem in elements]
|
|
363
|
+
|
|
364
|
+
# Build page-level markdown
|
|
365
|
+
pages_data = []
|
|
366
|
+
for page_num in range(1, num_pages + 1):
|
|
367
|
+
content = pages_content.get(page_num, [])
|
|
368
|
+
markdown = "\n\n".join(content)
|
|
369
|
+
pages_data.append(
|
|
370
|
+
{
|
|
371
|
+
"page_index": page_num - 1,
|
|
372
|
+
"markdown": markdown,
|
|
373
|
+
}
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# Build full document markdown
|
|
377
|
+
full_markdown = "\n\n".join(page["markdown"] for page in pages_data if page["markdown"]) # type: ignore[misc]
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
"pages": pages_data,
|
|
381
|
+
"markdown": full_markdown,
|
|
382
|
+
"num_pages": num_pages,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
def _is_in_table(self, line: Any, page: Any) -> bool:
|
|
386
|
+
"""
|
|
387
|
+
Check if a line is contained within any table on the page.
|
|
388
|
+
|
|
389
|
+
:param line: A textractor Line object
|
|
390
|
+
:param page: A textractor Page object
|
|
391
|
+
:return: True if line is within a table
|
|
392
|
+
"""
|
|
393
|
+
if not hasattr(page, "tables") or not page.tables:
|
|
394
|
+
return False
|
|
395
|
+
|
|
396
|
+
line_bbox = line.bbox if hasattr(line, "bbox") else None
|
|
397
|
+
if not line_bbox:
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
for table in page.tables:
|
|
401
|
+
table_bbox = table.bbox if hasattr(table, "bbox") else None
|
|
402
|
+
if table_bbox and self._bbox_contains(table_bbox, line_bbox):
|
|
403
|
+
return True
|
|
404
|
+
return False
|
|
405
|
+
|
|
406
|
+
def _bbox_contains(self, outer: Any, inner: Any) -> bool:
|
|
407
|
+
"""
|
|
408
|
+
Check if outer bounding box contains inner bounding box.
|
|
409
|
+
|
|
410
|
+
:param outer: Outer bounding box
|
|
411
|
+
:param inner: Inner bounding box
|
|
412
|
+
:return: True if outer contains inner
|
|
413
|
+
"""
|
|
414
|
+
try:
|
|
415
|
+
return ( # type: ignore[no-any-return]
|
|
416
|
+
outer.x <= inner.x
|
|
417
|
+
and outer.y <= inner.y
|
|
418
|
+
and (outer.x + outer.width) >= (inner.x + inner.width)
|
|
419
|
+
and (outer.y + outer.height) >= (inner.y + inner.height)
|
|
420
|
+
)
|
|
421
|
+
except AttributeError:
|
|
422
|
+
return False
|
|
423
|
+
|
|
424
|
+
def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
|
|
425
|
+
"""
|
|
426
|
+
Run inference and return raw results.
|
|
427
|
+
|
|
428
|
+
:param pipeline: Pipeline specification
|
|
429
|
+
:param request: Inference request
|
|
430
|
+
:return: Raw inference result
|
|
431
|
+
:raises ProviderError: For any provider-related failures
|
|
432
|
+
"""
|
|
433
|
+
if request.product_type != ProductType.PARSE:
|
|
434
|
+
raise ProviderPermanentError(
|
|
435
|
+
f"TextractProvider only supports PARSE product type, got {request.product_type}"
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
source_path = Path(request.source_file_path)
|
|
439
|
+
if not source_path.exists():
|
|
440
|
+
raise ProviderPermanentError(f"Source file not found: {source_path}")
|
|
441
|
+
|
|
442
|
+
# Check file extension
|
|
443
|
+
supported_extensions = {".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".tif"}
|
|
444
|
+
if source_path.suffix.lower() not in supported_extensions:
|
|
445
|
+
raise ProviderPermanentError(
|
|
446
|
+
f"TextractProvider only supports {supported_extensions}, got {source_path.suffix}"
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# Apply config overrides from pipeline
|
|
450
|
+
config = pipeline.config or {}
|
|
451
|
+
if "output_tables_as_html" in config:
|
|
452
|
+
self._output_tables_as_html = config["output_tables_as_html"]
|
|
453
|
+
if "detect_tables" in config:
|
|
454
|
+
self._detect_tables = config["detect_tables"]
|
|
455
|
+
if "detect_forms" in config:
|
|
456
|
+
self._detect_forms = config["detect_forms"]
|
|
457
|
+
|
|
458
|
+
started_at = datetime.now()
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
# Analyze the document
|
|
462
|
+
textract_response = self._analyze_multipage_document(str(source_path))
|
|
463
|
+
|
|
464
|
+
completed_at = datetime.now()
|
|
465
|
+
latency_ms = int((completed_at - started_at).total_seconds() * 1000)
|
|
466
|
+
|
|
467
|
+
raw_output: dict[str, Any] = {
|
|
468
|
+
"textract_response": textract_response,
|
|
469
|
+
"config": {
|
|
470
|
+
"output_tables_as_html": self._output_tables_as_html,
|
|
471
|
+
"detect_tables": self._detect_tables,
|
|
472
|
+
"detect_forms": self._detect_forms,
|
|
473
|
+
},
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
num_pages = int(textract_response.get("DocumentMetadata", {}).get("Pages", 0) or 0)
|
|
477
|
+
if num_pages > 0:
|
|
478
|
+
cost_per_page = _TEXTRACT_COST_PER_PAGE_USD[(self._detect_tables, self._detect_forms)]
|
|
479
|
+
raw_output["num_pages"] = num_pages
|
|
480
|
+
raw_output["cost_per_page_usd"] = cost_per_page
|
|
481
|
+
raw_output["cost_usd"] = cost_per_page * num_pages
|
|
482
|
+
|
|
483
|
+
return RawInferenceResult(
|
|
484
|
+
request=request,
|
|
485
|
+
pipeline=pipeline,
|
|
486
|
+
pipeline_name=pipeline.pipeline_name,
|
|
487
|
+
product_type=request.product_type,
|
|
488
|
+
raw_output=raw_output,
|
|
489
|
+
started_at=started_at,
|
|
490
|
+
completed_at=completed_at,
|
|
491
|
+
latency_in_ms=latency_ms,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
except (ProviderPermanentError, ProviderTransientError, ProviderConfigError):
|
|
495
|
+
raise
|
|
496
|
+
except Exception as e:
|
|
497
|
+
raise ProviderPermanentError(f"Unexpected error during inference: {e}") from e
|
|
498
|
+
|
|
499
|
+
def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
|
|
500
|
+
"""
|
|
501
|
+
Normalize raw inference result to produce ParseOutput.
|
|
502
|
+
|
|
503
|
+
:param raw_result: Raw inference result from run_inference()
|
|
504
|
+
:return: Inference result with both raw and normalized outputs
|
|
505
|
+
:raises ProviderError: For any normalization failures
|
|
506
|
+
"""
|
|
507
|
+
if raw_result.product_type != ProductType.PARSE:
|
|
508
|
+
raise ProviderPermanentError(
|
|
509
|
+
f"TextractProvider only supports PARSE product type, got {raw_result.product_type}"
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
# Extract config from raw output
|
|
513
|
+
config = raw_result.raw_output.get("config", {})
|
|
514
|
+
self._output_tables_as_html = config.get("output_tables_as_html", True)
|
|
515
|
+
self._detect_tables = config.get("detect_tables", True)
|
|
516
|
+
|
|
517
|
+
# Convert Textract response to markdown
|
|
518
|
+
textract_response = raw_result.raw_output.get("textract_response", {})
|
|
519
|
+
markdown_result = self._convert_to_markdown(textract_response)
|
|
520
|
+
|
|
521
|
+
# Build page-level output
|
|
522
|
+
pages: list[PageIR] = []
|
|
523
|
+
for page_data in markdown_result.get("pages", []):
|
|
524
|
+
pages.append(
|
|
525
|
+
PageIR(
|
|
526
|
+
page_index=page_data["page_index"],
|
|
527
|
+
markdown=page_data["markdown"],
|
|
528
|
+
)
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# Build layout_pages for layout cross-evaluation
|
|
532
|
+
blocks = textract_response.get("Blocks", [])
|
|
533
|
+
layout_pages = _build_layout_pages(blocks)
|
|
534
|
+
|
|
535
|
+
output = ParseOutput(
|
|
536
|
+
task_type="parse",
|
|
537
|
+
example_id=raw_result.request.example_id,
|
|
538
|
+
pipeline_name=raw_result.pipeline_name,
|
|
539
|
+
pages=pages,
|
|
540
|
+
layout_pages=layout_pages,
|
|
541
|
+
markdown=markdown_result.get("markdown", ""),
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
return InferenceResult(
|
|
545
|
+
request=raw_result.request,
|
|
546
|
+
pipeline_name=raw_result.pipeline_name,
|
|
547
|
+
product_type=raw_result.product_type,
|
|
548
|
+
raw_output=raw_result.raw_output,
|
|
549
|
+
output=output,
|
|
550
|
+
started_at=raw_result.started_at,
|
|
551
|
+
completed_at=raw_result.completed_at,
|
|
552
|
+
latency_in_ms=raw_result.latency_in_ms,
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _build_layout_pages(blocks: list[dict[str, Any]]) -> list[ParseLayoutPageIR]:
|
|
557
|
+
"""Build layout_pages from Textract LAYOUT_* blocks for layout cross-evaluation.
|
|
558
|
+
|
|
559
|
+
Groups LAYOUT_* blocks by page and converts each block's normalized [0,1]
|
|
560
|
+
BoundingBox into a LayoutSegmentIR with canonical label mapping.
|
|
561
|
+
Text content is extracted by traversing child LINE blocks.
|
|
562
|
+
"""
|
|
563
|
+
from collections import defaultdict
|
|
564
|
+
|
|
565
|
+
# Build block ID index for child traversal
|
|
566
|
+
block_index: dict[str, dict[str, Any]] = {}
|
|
567
|
+
for block in blocks:
|
|
568
|
+
block_id = block.get("Id")
|
|
569
|
+
if block_id:
|
|
570
|
+
block_index[block_id] = block
|
|
571
|
+
|
|
572
|
+
# Group LAYOUT_* blocks by page
|
|
573
|
+
pages_blocks: dict[int, list[dict[str, Any]]] = defaultdict(list)
|
|
574
|
+
for block in blocks:
|
|
575
|
+
block_type = block.get("BlockType", "")
|
|
576
|
+
if block_type in TEXTRACT_LABEL_MAP:
|
|
577
|
+
page_num = block.get("Page", 1)
|
|
578
|
+
pages_blocks[page_num].append(block)
|
|
579
|
+
|
|
580
|
+
# Group SELECTION_ELEMENT blocks (checkboxes) by page
|
|
581
|
+
pages_selection_blocks: dict[int, list[dict[str, Any]]] = defaultdict(list)
|
|
582
|
+
for block in blocks:
|
|
583
|
+
if block.get("BlockType") != "SELECTION_ELEMENT":
|
|
584
|
+
continue
|
|
585
|
+
status = block.get("SelectionStatus", "")
|
|
586
|
+
if status not in TEXTRACT_SELECTION_LABEL_MAP:
|
|
587
|
+
continue
|
|
588
|
+
page_num = block.get("Page", 1)
|
|
589
|
+
pages_selection_blocks[page_num].append(block)
|
|
590
|
+
|
|
591
|
+
layout_pages: list[ParseLayoutPageIR] = []
|
|
592
|
+
all_page_nums = sorted(set(pages_blocks.keys()) | set(pages_selection_blocks.keys()))
|
|
593
|
+
for page_num in all_page_nums:
|
|
594
|
+
page_blocks = pages_blocks.get(page_num, [])
|
|
595
|
+
items: list[LayoutItemIR] = []
|
|
596
|
+
|
|
597
|
+
for block in page_blocks:
|
|
598
|
+
block_type = block.get("BlockType", "")
|
|
599
|
+
canonical_label = TEXTRACT_LABEL_MAP.get(block_type)
|
|
600
|
+
if canonical_label is None:
|
|
601
|
+
continue
|
|
602
|
+
|
|
603
|
+
# Extract bbox (normalized [0,1] xywh)
|
|
604
|
+
bbox = block.get("Geometry", {}).get("BoundingBox", {})
|
|
605
|
+
left = float(bbox.get("Left", 0.0))
|
|
606
|
+
top = float(bbox.get("Top", 0.0))
|
|
607
|
+
width = float(bbox.get("Width", 0.0))
|
|
608
|
+
height = float(bbox.get("Height", 0.0))
|
|
609
|
+
|
|
610
|
+
confidence = float(block.get("Confidence", 100.0)) / 100.0
|
|
611
|
+
|
|
612
|
+
seg = LayoutSegmentIR(
|
|
613
|
+
x=left,
|
|
614
|
+
y=top,
|
|
615
|
+
w=width,
|
|
616
|
+
h=height,
|
|
617
|
+
confidence=confidence,
|
|
618
|
+
label=canonical_label,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
# Extract text from child LINE blocks
|
|
622
|
+
content = _get_block_text(block, block_index)
|
|
623
|
+
|
|
624
|
+
norm_label = canonical_label.strip().lower()
|
|
625
|
+
if norm_label == "table":
|
|
626
|
+
item_type = "table"
|
|
627
|
+
elif norm_label == "picture":
|
|
628
|
+
item_type = "image"
|
|
629
|
+
else:
|
|
630
|
+
item_type = "text"
|
|
631
|
+
|
|
632
|
+
items.append(
|
|
633
|
+
LayoutItemIR(
|
|
634
|
+
type=item_type,
|
|
635
|
+
value=content,
|
|
636
|
+
bbox=seg,
|
|
637
|
+
layout_segments=[seg],
|
|
638
|
+
)
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
for block in pages_selection_blocks.get(page_num, []):
|
|
642
|
+
status = block.get("SelectionStatus", "")
|
|
643
|
+
canonical_label = TEXTRACT_SELECTION_LABEL_MAP[status]
|
|
644
|
+
|
|
645
|
+
bbox = block.get("Geometry", {}).get("BoundingBox", {})
|
|
646
|
+
left = float(bbox.get("Left", 0.0))
|
|
647
|
+
top = float(bbox.get("Top", 0.0))
|
|
648
|
+
width = float(bbox.get("Width", 0.0))
|
|
649
|
+
height = float(bbox.get("Height", 0.0))
|
|
650
|
+
confidence = float(block.get("Confidence", 100.0)) / 100.0
|
|
651
|
+
|
|
652
|
+
seg = LayoutSegmentIR(
|
|
653
|
+
x=left,
|
|
654
|
+
y=top,
|
|
655
|
+
w=width,
|
|
656
|
+
h=height,
|
|
657
|
+
confidence=confidence,
|
|
658
|
+
label=canonical_label,
|
|
659
|
+
)
|
|
660
|
+
items.append(
|
|
661
|
+
LayoutItemIR(
|
|
662
|
+
type="text",
|
|
663
|
+
value="",
|
|
664
|
+
bbox=seg,
|
|
665
|
+
layout_segments=[seg],
|
|
666
|
+
)
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
layout_pages.append(
|
|
670
|
+
ParseLayoutPageIR(
|
|
671
|
+
page_number=page_num,
|
|
672
|
+
width=_VIRTUAL_PAGE_DIM,
|
|
673
|
+
height=_VIRTUAL_PAGE_DIM,
|
|
674
|
+
items=items,
|
|
675
|
+
)
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
return layout_pages
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _get_block_text(block: dict[str, Any], block_index: dict[str, dict[str, Any]]) -> str:
|
|
682
|
+
"""Extract text from a LAYOUT block by traversing child LINE blocks."""
|
|
683
|
+
relationships = block.get("Relationships", [])
|
|
684
|
+
lines: list[str] = []
|
|
685
|
+
for rel in relationships:
|
|
686
|
+
if rel.get("Type") != "CHILD":
|
|
687
|
+
continue
|
|
688
|
+
for child_id in rel.get("Ids", []):
|
|
689
|
+
child = block_index.get(child_id)
|
|
690
|
+
if child and child.get("BlockType") == "LINE":
|
|
691
|
+
text = child.get("Text", "")
|
|
692
|
+
if text:
|
|
693
|
+
lines.append(text)
|
|
694
|
+
return "\n".join(lines)
|