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,704 @@
|
|
|
1
|
+
"""Provider for Infinity-Parser2 PARSE via infinity_parser2 SDK with vLLM server."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import traceback
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pdf2image import convert_from_path
|
|
12
|
+
from PIL import Image as PILImage
|
|
13
|
+
|
|
14
|
+
from parse_bench.inference.providers.base import (
|
|
15
|
+
Provider,
|
|
16
|
+
ProviderConfigError,
|
|
17
|
+
ProviderPermanentError,
|
|
18
|
+
ProviderTransientError,
|
|
19
|
+
)
|
|
20
|
+
from parse_bench.inference.providers.registry import register_provider
|
|
21
|
+
from parse_bench.schemas.parse_output import PageIR, ParseLayoutPageIR, ParseOutput
|
|
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
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
DEFAULT_MODEL_NAME = "infly/Infinity-Parser2-Flash"
|
|
33
|
+
|
|
34
|
+
# Infinity-Parser2 category → Canonical17 label mapping
|
|
35
|
+
INFINITY_CATEGORY_MAP: dict[str, str] = {
|
|
36
|
+
"header": "Page-header",
|
|
37
|
+
"title": "Section-header",
|
|
38
|
+
"text": "Text",
|
|
39
|
+
"figure": "Picture",
|
|
40
|
+
"table": "Table",
|
|
41
|
+
"formula": "Formula",
|
|
42
|
+
"figure_caption": "Caption",
|
|
43
|
+
"table_caption": "Caption",
|
|
44
|
+
"formula_caption": "Caption",
|
|
45
|
+
"figure_footnote": "Footnote",
|
|
46
|
+
"table_footnote": "Footnote",
|
|
47
|
+
"page_footnote": "Footnote",
|
|
48
|
+
"footer": "Page-footer",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@register_provider("infinity_parser2")
|
|
53
|
+
class InfinityParser2Provider(Provider):
|
|
54
|
+
"""
|
|
55
|
+
Provider for Infinity-Parser2 via the infinity_parser2 SDK.
|
|
56
|
+
|
|
57
|
+
Infinity-Parser2 is a document understanding model that converts PDFs
|
|
58
|
+
and images to structured markdown/JSON. This provider uses the
|
|
59
|
+
``vllm-server`` backend which communicates with a running vLLM OpenAI-
|
|
60
|
+
compatible server over HTTP. This avoids thread-safety issues in the
|
|
61
|
+
``vllm-engine`` backend when running concurrent requests.
|
|
62
|
+
|
|
63
|
+
Configuration options:
|
|
64
|
+
- model_name (str, default="infly/Infinity-Parser2-Flash"): Model name (must match server)
|
|
65
|
+
- api_url (str, default="http://localhost:8000/v1/chat/completions"): vLLM server endpoint
|
|
66
|
+
- api_key (str, default="EMPTY"): API key for the server
|
|
67
|
+
- timeout (int, default=900): Request timeout in seconds (the pro model can
|
|
68
|
+
exceed 300 s on dense pages)
|
|
69
|
+
- task_type (str, default="doc2json"): Parse task type
|
|
70
|
+
- output_format (str, default="json"): Output format (json returns per-element layout with bboxes)
|
|
71
|
+
- batch_size (int, default=4): Batch size for processing
|
|
72
|
+
- max_new_tokens (int, default=None): Override max tokens for generation
|
|
73
|
+
- temperature (float, default=0.0): Sampling temperature
|
|
74
|
+
- deep_parsing_mode (bool, default=True): Parse figure content.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, provider_name: str, base_config: dict[str, Any] | None = None):
|
|
78
|
+
super().__init__(provider_name, base_config)
|
|
79
|
+
|
|
80
|
+
self._model_name = self.base_config.get("model_name", DEFAULT_MODEL_NAME)
|
|
81
|
+
self._api_url = self.base_config.get("api_url", "http://localhost:8000/v1/chat/completions")
|
|
82
|
+
self._api_key = self.base_config.get("api_key", "EMPTY")
|
|
83
|
+
self._timeout = self.base_config.get("timeout", 900)
|
|
84
|
+
self._task_type = self.base_config.get("task_type", "doc2json")
|
|
85
|
+
self._output_format = self.base_config.get("output_format", "json")
|
|
86
|
+
self._batch_size = self.base_config.get("batch_size", 4)
|
|
87
|
+
self._max_new_tokens = self.base_config.get("max_new_tokens")
|
|
88
|
+
self._temperature = self.base_config.get("temperature", 0.0)
|
|
89
|
+
self._deep_parsing_mode = self.base_config.get("deep_parsing_mode", True)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
from infinity_parser2 import InfinityParser2
|
|
93
|
+
except ImportError as e:
|
|
94
|
+
traceback.print_exc()
|
|
95
|
+
raise ProviderConfigError("import infinity_parser2 failed") from e
|
|
96
|
+
|
|
97
|
+
kwargs: dict[str, Any] = {
|
|
98
|
+
"model_name": self._model_name,
|
|
99
|
+
"backend": "vllm-server",
|
|
100
|
+
"api_url": self._api_url,
|
|
101
|
+
"api_key": self._api_key,
|
|
102
|
+
"timeout": self._timeout,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
self._parser = InfinityParser2(**kwargs)
|
|
106
|
+
|
|
107
|
+
def _parse_page(self, pil_image: PILImage.Image, page_width: float, page_height: float) -> dict[str, Any]:
|
|
108
|
+
"""Parse a single rendered page image and return the legacy one-page raw shape."""
|
|
109
|
+
parse_kwargs: dict[str, Any] = {
|
|
110
|
+
"task_type": self._task_type,
|
|
111
|
+
"batch_size": self._batch_size,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if self._output_format:
|
|
115
|
+
parse_kwargs["output_format"] = self._output_format
|
|
116
|
+
|
|
117
|
+
if self._max_new_tokens is not None:
|
|
118
|
+
parse_kwargs["max_new_tokens"] = self._max_new_tokens
|
|
119
|
+
|
|
120
|
+
if "temperature" in self.base_config:
|
|
121
|
+
parse_kwargs["temperature"] = self._temperature
|
|
122
|
+
|
|
123
|
+
result = self._parser.parse(pil_image, **parse_kwargs)
|
|
124
|
+
|
|
125
|
+
if self._deep_parsing_mode:
|
|
126
|
+
result = self._apply_deep_parsing(result, pil_image)
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
"result": result,
|
|
130
|
+
"_config": {
|
|
131
|
+
"model_name": self._model_name,
|
|
132
|
+
"backend": "vllm-server",
|
|
133
|
+
"api_url": self._api_url,
|
|
134
|
+
"task_type": self._task_type,
|
|
135
|
+
"output_format": self._output_format,
|
|
136
|
+
"batch_size": self._batch_size,
|
|
137
|
+
"page_width": page_width,
|
|
138
|
+
"page_height": page_height,
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
def _parse_pages(self, pages: list[tuple[PILImage.Image, float, float]]) -> dict[str, Any]:
|
|
143
|
+
"""Parse every rendered page in order.
|
|
144
|
+
|
|
145
|
+
Single-page inputs keep the legacy raw_output shape; multi-page inputs
|
|
146
|
+
add ``page_results`` (one legacy-shaped dict per page) which
|
|
147
|
+
``normalize`` reassembles in page order. The previous implementation
|
|
148
|
+
rendered only the first PDF page, silently parsing just the cover page
|
|
149
|
+
of multi-page documents.
|
|
150
|
+
"""
|
|
151
|
+
results = [self._parse_page(image, width, height) for image, width, height in pages]
|
|
152
|
+
first = results[0]
|
|
153
|
+
if len(results) == 1:
|
|
154
|
+
return first
|
|
155
|
+
merged = dict(first)
|
|
156
|
+
merged["page_results"] = results
|
|
157
|
+
return merged
|
|
158
|
+
|
|
159
|
+
def _parse_document(self, file_path: str) -> dict[str, Any]:
|
|
160
|
+
"""
|
|
161
|
+
Parse a document using InfinityParser2.
|
|
162
|
+
|
|
163
|
+
:param file_path: Path to the PDF or image file
|
|
164
|
+
:return: Raw parsing result
|
|
165
|
+
"""
|
|
166
|
+
try:
|
|
167
|
+
return self._parse_pages(load_images(file_path))
|
|
168
|
+
|
|
169
|
+
except Exception as e:
|
|
170
|
+
error_str = str(e).lower()
|
|
171
|
+
transient_keywords = ["timeout", "network", "connection", "cuda", "out of memory", "oom"]
|
|
172
|
+
if any(keyword in error_str for keyword in transient_keywords):
|
|
173
|
+
raise ProviderTransientError(f"Error during parsing (GPU/memory): {e}") from e
|
|
174
|
+
raise ProviderPermanentError(f"Error parsing document: {e}") from e
|
|
175
|
+
|
|
176
|
+
def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
|
|
177
|
+
if request.product_type != ProductType.PARSE:
|
|
178
|
+
raise ProviderPermanentError(
|
|
179
|
+
f"InfinityParser2Provider only supports PARSE product type, got {request.product_type}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
file_path = Path(request.source_file_path)
|
|
183
|
+
if not file_path.exists():
|
|
184
|
+
raise ProviderPermanentError(f"Source file not found: {file_path}")
|
|
185
|
+
|
|
186
|
+
started_at = datetime.now()
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
raw_output = self._parse_document(str(file_path))
|
|
190
|
+
completed_at = datetime.now()
|
|
191
|
+
latency_ms = int((completed_at - started_at).total_seconds() * 1000)
|
|
192
|
+
|
|
193
|
+
return RawInferenceResult(
|
|
194
|
+
request=request,
|
|
195
|
+
pipeline=pipeline,
|
|
196
|
+
pipeline_name=pipeline.pipeline_name,
|
|
197
|
+
product_type=request.product_type,
|
|
198
|
+
raw_output=raw_output,
|
|
199
|
+
started_at=started_at,
|
|
200
|
+
completed_at=completed_at,
|
|
201
|
+
latency_in_ms=latency_ms,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
except (ProviderPermanentError, ProviderTransientError, ProviderConfigError):
|
|
205
|
+
raise
|
|
206
|
+
except Exception as e:
|
|
207
|
+
raise ProviderPermanentError(f"Unexpected error during inference: {e}") from e
|
|
208
|
+
|
|
209
|
+
def _build_layout_segment(self, bbox: list, label: str) -> dict:
|
|
210
|
+
"""Build a LayoutSegmentIR from a bbox."""
|
|
211
|
+
if len(bbox) == 4:
|
|
212
|
+
x1, y1, x2, y2 = bbox
|
|
213
|
+
x, y, w, h = float(x1), float(y1), float(x2 - x1), float(y2 - y1)
|
|
214
|
+
else:
|
|
215
|
+
x, y, w, h = 0.0, 0.0, 0.0, 0.0
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
"x": x,
|
|
219
|
+
"y": y,
|
|
220
|
+
"w": w,
|
|
221
|
+
"h": h,
|
|
222
|
+
"confidence": 1.0,
|
|
223
|
+
"label": label,
|
|
224
|
+
"start_index": None,
|
|
225
|
+
"end_index": None,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
def _reassemble_text(self, label: str, text: str) -> str:
|
|
229
|
+
"""Reassemble text content based on label."""
|
|
230
|
+
if not text:
|
|
231
|
+
return ""
|
|
232
|
+
|
|
233
|
+
if label == "Section-header":
|
|
234
|
+
return f"# {text.lstrip('# ')}"
|
|
235
|
+
elif label == "Formula":
|
|
236
|
+
stripped = re.sub(r"^[\s$\(\)\[\]]+|[\s$\(\)\[\]]+$", "", text)
|
|
237
|
+
return f"$${stripped}$$"
|
|
238
|
+
elif label == "Picture":
|
|
239
|
+
text = _convert_nonstandard_table(text)
|
|
240
|
+
return text
|
|
241
|
+
elif label == "Table":
|
|
242
|
+
return _convert_table_header(text)
|
|
243
|
+
else:
|
|
244
|
+
return text
|
|
245
|
+
|
|
246
|
+
def _build_layout_item(self, elem: dict, label: str) -> dict:
|
|
247
|
+
"""Build a single LayoutItemIR from an infinity-parser2 JSON element."""
|
|
248
|
+
bbox = elem.get("bbox", [0, 0, 0, 0])
|
|
249
|
+
text = elem.get("text", "")
|
|
250
|
+
|
|
251
|
+
layout_seg = self._build_layout_segment(bbox, label)
|
|
252
|
+
text = self._reassemble_text(label, text)
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
"type": label,
|
|
256
|
+
"md": text,
|
|
257
|
+
"html": text if label == "Table" else "",
|
|
258
|
+
"value": text,
|
|
259
|
+
"bbox": layout_seg,
|
|
260
|
+
"layout_segments": [layout_seg],
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
def _apply_deep_parsing(
|
|
264
|
+
self,
|
|
265
|
+
result: str,
|
|
266
|
+
pil_image: PILImage.Image,
|
|
267
|
+
) -> str:
|
|
268
|
+
"""Apply deep parsing on figure elements, re-parsing cropped figure images as markdown tables.
|
|
269
|
+
|
|
270
|
+
Extracts all ``figure`` elements from the parsed JSON, crops each figure region from
|
|
271
|
+
``pil_image``, re-parses the cropped images with a custom table-extraction prompt,
|
|
272
|
+
and overwrites ``elem["text"]`` in place before serializing back to JSON.
|
|
273
|
+
|
|
274
|
+
Returns the (possibly modified) JSON string.
|
|
275
|
+
"""
|
|
276
|
+
try:
|
|
277
|
+
elements: list[dict] = json.loads(result)
|
|
278
|
+
if not isinstance(elements, list):
|
|
279
|
+
return result
|
|
280
|
+
|
|
281
|
+
figure_elements = [elem for elem in elements if elem.get("category", "").strip().lower() == "figure"]
|
|
282
|
+
if not figure_elements:
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
pil_figure_images = [
|
|
286
|
+
pil_image.crop(
|
|
287
|
+
(
|
|
288
|
+
max(0, int(elem["bbox"][0])),
|
|
289
|
+
max(0, int(elem["bbox"][1])),
|
|
290
|
+
min(pil_image.width, int(elem["bbox"][2])),
|
|
291
|
+
min(pil_image.height, int(elem["bbox"][3])),
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
for elem in figure_elements
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
deep_parse_kwargs = {
|
|
298
|
+
"task_type": "custom",
|
|
299
|
+
"custom_prompt": "please convert the image to a markdown table",
|
|
300
|
+
"max_new_tokens": 2048,
|
|
301
|
+
}
|
|
302
|
+
deep_results = [self._parser.parse(img, **deep_parse_kwargs) for img in pil_figure_images]
|
|
303
|
+
for elem, deep_result in zip(figure_elements, deep_results, strict=True):
|
|
304
|
+
elem["text"] = deep_result
|
|
305
|
+
|
|
306
|
+
return json.dumps(elements)
|
|
307
|
+
|
|
308
|
+
except Exception:
|
|
309
|
+
logger.exception("Deep parsing pass failed; returning shallow parse result")
|
|
310
|
+
return result
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _load_elements(result_str: Any) -> list[dict]:
|
|
314
|
+
"""Decode the model's JSON element list, tolerating malformed output."""
|
|
315
|
+
if isinstance(result_str, list):
|
|
316
|
+
return [e for e in result_str if isinstance(e, dict)]
|
|
317
|
+
if not isinstance(result_str, str) or not result_str:
|
|
318
|
+
return []
|
|
319
|
+
try:
|
|
320
|
+
elements = json.loads(result_str)
|
|
321
|
+
except json.JSONDecodeError:
|
|
322
|
+
return []
|
|
323
|
+
if not isinstance(elements, list):
|
|
324
|
+
return []
|
|
325
|
+
return [e for e in elements if isinstance(e, dict)]
|
|
326
|
+
|
|
327
|
+
def _normalize(self, raw_result: RawInferenceResult) -> ParseOutput:
|
|
328
|
+
"""Normalize JSON layout result into ParseOutput with pages, layout_pages, and markdown.
|
|
329
|
+
|
|
330
|
+
Multi-page raw outputs carry one legacy-shaped dict per page under
|
|
331
|
+
``page_results``; each is normalized as its own page (page numbers are
|
|
332
|
+
assigned from render order, not from the model's ``page`` field, which
|
|
333
|
+
is always 1 for a single rendered image).
|
|
334
|
+
"""
|
|
335
|
+
page_results = raw_result.raw_output.get("page_results")
|
|
336
|
+
if not isinstance(page_results, list) or not page_results:
|
|
337
|
+
page_results = [raw_result.raw_output]
|
|
338
|
+
|
|
339
|
+
if not any(page_raw.get("result") for page_raw in page_results):
|
|
340
|
+
raise ProviderPermanentError(f"Empty result from InfinityParser2 for {raw_result.pipeline_name}")
|
|
341
|
+
|
|
342
|
+
pages: list[PageIR] = []
|
|
343
|
+
layout_pages: list[ParseLayoutPageIR] = []
|
|
344
|
+
markdown_parts: list[str] = []
|
|
345
|
+
|
|
346
|
+
for page_num, page_raw in enumerate(page_results, start=1):
|
|
347
|
+
page_config = page_raw.get("_config") or {}
|
|
348
|
+
page_width = page_config.get("page_width", 0)
|
|
349
|
+
page_height = page_config.get("page_height", 0)
|
|
350
|
+
page_elements = self._load_elements(page_raw.get("result", ""))
|
|
351
|
+
|
|
352
|
+
header_items: list[dict] = []
|
|
353
|
+
footer_items: list[dict] = []
|
|
354
|
+
regular_items: list[dict] = []
|
|
355
|
+
|
|
356
|
+
for elem in page_elements:
|
|
357
|
+
raw_cat = elem.get("category", "text").strip().lower()
|
|
358
|
+
norm_cat = INFINITY_CATEGORY_MAP.get(raw_cat, "Text")
|
|
359
|
+
item = self._build_layout_item(elem, norm_cat)
|
|
360
|
+
if norm_cat == "Page-header":
|
|
361
|
+
header_items.append(item)
|
|
362
|
+
elif norm_cat == "Page-footer":
|
|
363
|
+
footer_items.append(item)
|
|
364
|
+
else:
|
|
365
|
+
regular_items.append(item)
|
|
366
|
+
|
|
367
|
+
page_items = header_items + regular_items + footer_items
|
|
368
|
+
page_md_parts = [item.get("md", "") for item in page_items if item.get("md")]
|
|
369
|
+
page_md = "\n\n".join(page_md_parts)
|
|
370
|
+
|
|
371
|
+
header_md = " ".join(c.get("value", "") for c in header_items)
|
|
372
|
+
footer_md = " ".join(c.get("value", "") for c in footer_items)
|
|
373
|
+
|
|
374
|
+
layout_pages.append(
|
|
375
|
+
ParseLayoutPageIR(
|
|
376
|
+
page_number=page_num,
|
|
377
|
+
width=page_width,
|
|
378
|
+
height=page_height,
|
|
379
|
+
md=page_md,
|
|
380
|
+
text=page_md,
|
|
381
|
+
page_header_markdown=header_md,
|
|
382
|
+
page_footer_markdown=footer_md,
|
|
383
|
+
printed_page_number="",
|
|
384
|
+
original_orientation_angle=0,
|
|
385
|
+
items=page_items,
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
pages.append(PageIR(page_index=page_num - 1, markdown=page_md))
|
|
390
|
+
if page_md:
|
|
391
|
+
markdown_parts.append(page_md)
|
|
392
|
+
|
|
393
|
+
full_markdown = "\n\n".join(markdown_parts)
|
|
394
|
+
|
|
395
|
+
return ParseOutput(
|
|
396
|
+
task_type="parse",
|
|
397
|
+
example_id=raw_result.request.example_id,
|
|
398
|
+
pipeline_name=raw_result.pipeline_name,
|
|
399
|
+
pages=pages,
|
|
400
|
+
layout_pages=layout_pages,
|
|
401
|
+
markdown=full_markdown,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
|
|
405
|
+
if raw_result.product_type != ProductType.PARSE:
|
|
406
|
+
raise ProviderPermanentError(
|
|
407
|
+
f"InfinityParser2Provider only supports PARSE product type, got {raw_result.product_type}"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
output = self._normalize(raw_result)
|
|
411
|
+
|
|
412
|
+
return InferenceResult(
|
|
413
|
+
request=raw_result.request,
|
|
414
|
+
pipeline_name=raw_result.pipeline_name,
|
|
415
|
+
product_type=raw_result.product_type,
|
|
416
|
+
raw_output=raw_result.raw_output,
|
|
417
|
+
output=output,
|
|
418
|
+
started_at=raw_result.started_at,
|
|
419
|
+
completed_at=raw_result.completed_at,
|
|
420
|
+
latency_in_ms=raw_result.latency_in_ms,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def load_images(file_path: str) -> list[tuple[PILImage.Image, float, float]]:
|
|
425
|
+
"""Load a PDF or image file as PIL Images (one per page) with their dimensions.
|
|
426
|
+
|
|
427
|
+
- PDF: renders every page to an RGB image at 300 DPI, in source order.
|
|
428
|
+
- Image: opens and converts to RGB (single page).
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
List of (PIL Image, width, height) tuples, width/height in pixels.
|
|
432
|
+
"""
|
|
433
|
+
path = Path(file_path)
|
|
434
|
+
if path.suffix.lower() == ".pdf":
|
|
435
|
+
images = convert_from_path(str(path), dpi=300)
|
|
436
|
+
if not images:
|
|
437
|
+
raise ProviderPermanentError(f"Failed to render PDF pages: {file_path}")
|
|
438
|
+
pil_images = [image.convert("RGB") for image in images]
|
|
439
|
+
else:
|
|
440
|
+
pil_images = [PILImage.open(str(path)).convert("RGB")]
|
|
441
|
+
|
|
442
|
+
return [(image, float(image.size[0]), float(image.size[1])) for image in pil_images]
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def load_image(file_path: str) -> tuple[PILImage.Image, float, float]:
|
|
446
|
+
"""Load the first page of a PDF (or an image) as a PIL Image with its dimensions."""
|
|
447
|
+
return load_images(file_path)[0]
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# =============================================================================
|
|
451
|
+
# Postprocess for chart2table
|
|
452
|
+
# =============================================================================
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _is_valid_md_table(table_text: str) -> bool:
|
|
456
|
+
"""Check if a markdown table is valid (non-empty)."""
|
|
457
|
+
if not table_text or not table_text.strip():
|
|
458
|
+
return False
|
|
459
|
+
|
|
460
|
+
if not all(ch in table_text for ch in ["|", "-", "\n"]):
|
|
461
|
+
return False
|
|
462
|
+
|
|
463
|
+
stripped = table_text[table_text.find("|") : table_text.rfind("|") + 1]
|
|
464
|
+
stripped = re.sub(r"^\s*\|[\s\-:|]+\|\s*$", "", stripped, flags=re.MULTILINE)
|
|
465
|
+
if not stripped.replace(" ", "").replace("\n", "").replace("|", ""):
|
|
466
|
+
return False
|
|
467
|
+
|
|
468
|
+
return True
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _is_nonstandard_table(text: str) -> bool:
|
|
472
|
+
"""Check if text is a non-standard markdown table (no leading '|', contains '&' separators)."""
|
|
473
|
+
if not text:
|
|
474
|
+
return False
|
|
475
|
+
stripped = text.strip()
|
|
476
|
+
if stripped.startswith("|"):
|
|
477
|
+
return False
|
|
478
|
+
return "&" in text
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _find_column_number(text: str) -> int:
|
|
482
|
+
"""Find the column number from a nonstandard table.
|
|
483
|
+
|
|
484
|
+
Split the text by '&' and count '|' in each segment. The header row always
|
|
485
|
+
has the most pipes (full cells). Column count = max(pipe_counts) + 1.
|
|
486
|
+
"""
|
|
487
|
+
if "&" not in text:
|
|
488
|
+
return 0
|
|
489
|
+
raw_segments = text.split("&")
|
|
490
|
+
segments = [s.strip() for s in raw_segments if s.strip()]
|
|
491
|
+
if not segments:
|
|
492
|
+
return 0
|
|
493
|
+
pipe_counts = [s.count("|") for s in segments]
|
|
494
|
+
return max(pipe_counts) + 1
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _find_all_separator_indices(text: str, col_num: int) -> list[int]:
|
|
498
|
+
"""Identify which '&' characters are row-group separators based on pipe counts."""
|
|
499
|
+
if col_num == 0:
|
|
500
|
+
return []
|
|
501
|
+
expected_pipes = col_num - 1
|
|
502
|
+
sep_positions = []
|
|
503
|
+
prev_sep = -1
|
|
504
|
+
i = 0
|
|
505
|
+
while i < len(text):
|
|
506
|
+
amp = text.find("&", i)
|
|
507
|
+
if amp == -1:
|
|
508
|
+
break
|
|
509
|
+
|
|
510
|
+
# Count pipes between prev_sep+1 and amp-1
|
|
511
|
+
pipe_count = 0
|
|
512
|
+
for j in range(prev_sep + 1, amp):
|
|
513
|
+
if text[j] == "|":
|
|
514
|
+
pipe_count += 1
|
|
515
|
+
|
|
516
|
+
if pipe_count == expected_pipes:
|
|
517
|
+
sep_positions.append(amp)
|
|
518
|
+
prev_sep = amp
|
|
519
|
+
|
|
520
|
+
i = amp + 1
|
|
521
|
+
return sep_positions
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _convert_nonstandard_table(text: str) -> str:
|
|
525
|
+
"""Convert a non-standard markdown table (with '&' row-group separators) to proper markdown table format."""
|
|
526
|
+
if not _is_nonstandard_table(text):
|
|
527
|
+
return text
|
|
528
|
+
|
|
529
|
+
col_num = _find_column_number(text)
|
|
530
|
+
if col_num == 0:
|
|
531
|
+
return text
|
|
532
|
+
|
|
533
|
+
sep_indices = _find_all_separator_indices(text, col_num)
|
|
534
|
+
if not sep_indices:
|
|
535
|
+
return text
|
|
536
|
+
|
|
537
|
+
segments = []
|
|
538
|
+
prev = 0
|
|
539
|
+
for idx in sep_indices:
|
|
540
|
+
segments.append(text[prev:idx].strip())
|
|
541
|
+
prev = idx + 1
|
|
542
|
+
segments.append(text[prev:].strip())
|
|
543
|
+
|
|
544
|
+
header = segments[0]
|
|
545
|
+
if not header.startswith("|"):
|
|
546
|
+
header = "| " + header
|
|
547
|
+
if not header.rstrip().endswith("|"):
|
|
548
|
+
header = header.rstrip() + " |"
|
|
549
|
+
|
|
550
|
+
separator = "| " + " | ".join(["---"] * col_num) + " |"
|
|
551
|
+
normalized_lines = [header, separator]
|
|
552
|
+
|
|
553
|
+
for seg in segments[1:]:
|
|
554
|
+
if not seg:
|
|
555
|
+
continue
|
|
556
|
+
cells = [c.strip() for c in seg.split("|") if c.strip()]
|
|
557
|
+
padded = cells + [""] * max(0, col_num - len(cells))
|
|
558
|
+
row = "| " + " | ".join(padded[:col_num]).rstrip() + " |"
|
|
559
|
+
normalized_lines.append(row)
|
|
560
|
+
|
|
561
|
+
return "\n".join(normalized_lines)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
# =============================================================================
|
|
565
|
+
# Postprocess for HTML table header
|
|
566
|
+
# =============================================================================
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _is_year_cell(text: str) -> bool:
|
|
570
|
+
"""Return True if text looks like a date/year (yyyy, yyyymm, yyyymmdd, etc.)."""
|
|
571
|
+
text = text.strip()
|
|
572
|
+
return bool(re.fullmatch(r"(19|20)\d{2,4}([-/]?\d{2}([-/]?\d{2})?)?", text))
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _is_gender_cell(text: str) -> bool:
|
|
576
|
+
"""Return True if text looks like gender."""
|
|
577
|
+
text = text.strip().lower()
|
|
578
|
+
return text in ("male", "female", "non-binary", "other", "undisclosed")
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _is_pure_text_cell(text: str) -> bool:
|
|
582
|
+
"""Return True if text contains no digits at all."""
|
|
583
|
+
text = text.strip()
|
|
584
|
+
return bool(text) and any(c.isalpha() for c in text)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _is_pure_number_cell(text: str) -> bool:
|
|
588
|
+
"""Return True if text looks like a pure numeric value.
|
|
589
|
+
|
|
590
|
+
Accepts numbers with commas, decimals, dollar sign, percent sign,
|
|
591
|
+
plus/minus sign, and parentheses (for negative numbers).
|
|
592
|
+
"""
|
|
593
|
+
text = text.strip()
|
|
594
|
+
if not text:
|
|
595
|
+
return False
|
|
596
|
+
# Allow: digits, comma, dot, minus, plus, $, %, parentheses
|
|
597
|
+
allowed = set("0123456789,.-+()$% ")
|
|
598
|
+
return all(c in allowed for c in text)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _determine_header_row_count(rows: list) -> int:
|
|
602
|
+
"""Determine how many top rows are header rows (year/gender/value rules + rowspan fallback)."""
|
|
603
|
+
if not rows:
|
|
604
|
+
return 0
|
|
605
|
+
|
|
606
|
+
def non_empty_cells(row):
|
|
607
|
+
return [td.get_text(strip=True) for td in row.find_all("td", recursive=False) if td.get_text(strip=True)]
|
|
608
|
+
|
|
609
|
+
def stats(row_list):
|
|
610
|
+
"""Return (pure_text_count, pure_number_count, total) for a list of rows."""
|
|
611
|
+
text_count = number_count = total = 0
|
|
612
|
+
for row in row_list:
|
|
613
|
+
for cell in non_empty_cells(row):
|
|
614
|
+
total += 1
|
|
615
|
+
if _is_pure_text_cell(cell):
|
|
616
|
+
text_count += 1
|
|
617
|
+
elif _is_pure_number_cell(cell):
|
|
618
|
+
number_count += 1
|
|
619
|
+
return text_count, number_count, total
|
|
620
|
+
|
|
621
|
+
# Rule 1: Year
|
|
622
|
+
for i, row in enumerate(rows):
|
|
623
|
+
if i > 3:
|
|
624
|
+
break
|
|
625
|
+
cells = non_empty_cells(row)
|
|
626
|
+
if not cells:
|
|
627
|
+
continue
|
|
628
|
+
year_count = sum(1 for c in cells if _is_year_cell(c))
|
|
629
|
+
if year_count / len(cells) >= 0.5:
|
|
630
|
+
return i + 1
|
|
631
|
+
|
|
632
|
+
# Rule 2: Gender
|
|
633
|
+
for i, row in enumerate(rows):
|
|
634
|
+
if i > 3:
|
|
635
|
+
break
|
|
636
|
+
cells = non_empty_cells(row)
|
|
637
|
+
if not cells:
|
|
638
|
+
continue
|
|
639
|
+
gender_count = sum(1 for c in cells if _is_gender_cell(c))
|
|
640
|
+
if gender_count / len(cells) >= 0.5:
|
|
641
|
+
return i + 1
|
|
642
|
+
|
|
643
|
+
# Rule 3: Value (pure-text header region followed by pure-number data region)
|
|
644
|
+
best_i = -1
|
|
645
|
+
best_score = -1.0
|
|
646
|
+
for i in range(3):
|
|
647
|
+
header_rows = rows[: i + 1]
|
|
648
|
+
data_rows = rows[i + 1 :]
|
|
649
|
+
if not header_rows or not data_rows:
|
|
650
|
+
continue
|
|
651
|
+
header_text, header_num, header_total = stats(header_rows)
|
|
652
|
+
data_text, data_num, data_total = stats(data_rows)
|
|
653
|
+
if header_total == 0 or data_total == 0:
|
|
654
|
+
continue
|
|
655
|
+
if header_text / header_total >= 0.5 and data_num / data_total >= 0.5:
|
|
656
|
+
score = header_text / header_total + data_num / data_total
|
|
657
|
+
if score > best_score:
|
|
658
|
+
best_score = score
|
|
659
|
+
best_i = i
|
|
660
|
+
if best_i >= 0:
|
|
661
|
+
return best_i + 1
|
|
662
|
+
|
|
663
|
+
# Rule 4: Fallback — max rowspan in row 0
|
|
664
|
+
first_row = rows[0]
|
|
665
|
+
max_rowspan = 1
|
|
666
|
+
for td in first_row.find_all("td", recursive=False):
|
|
667
|
+
rowspan = int(td.get("rowspan", 1))
|
|
668
|
+
if rowspan > max_rowspan:
|
|
669
|
+
max_rowspan = rowspan
|
|
670
|
+
return max_rowspan
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _convert_table_header(html: str) -> str:
|
|
674
|
+
"""Convert <td> tags in HTML table header rows to <th> for TEDS/GriTS evaluation."""
|
|
675
|
+
if not html or "<table" not in html.lower():
|
|
676
|
+
return html
|
|
677
|
+
|
|
678
|
+
try:
|
|
679
|
+
from bs4 import BeautifulSoup
|
|
680
|
+
except ImportError:
|
|
681
|
+
return html
|
|
682
|
+
|
|
683
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
684
|
+
tables = soup.find_all("table")
|
|
685
|
+
|
|
686
|
+
for table in tables:
|
|
687
|
+
rows = table.find_all("tr", recursive=False)
|
|
688
|
+
if not rows:
|
|
689
|
+
continue
|
|
690
|
+
|
|
691
|
+
header_row_count = _determine_header_row_count(rows)
|
|
692
|
+
|
|
693
|
+
for i, row in enumerate(rows):
|
|
694
|
+
if i >= header_row_count:
|
|
695
|
+
break
|
|
696
|
+
tds = row.find_all("td", recursive=False)
|
|
697
|
+
for td in tds:
|
|
698
|
+
new_th = soup.new_tag("th")
|
|
699
|
+
for key, value in td.attrs.items():
|
|
700
|
+
new_th[key] = value
|
|
701
|
+
new_th.string = td.get_text()
|
|
702
|
+
td.replace_with(new_th)
|
|
703
|
+
|
|
704
|
+
return str(soup)
|