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,3327 @@
|
|
|
1
|
+
"""KDL-Frontier-Parser-nano — standalone ParseBench provider.
|
|
2
|
+
|
|
3
|
+
Self-contained reimplementation of the KDL inference pipeline used to produce
|
|
4
|
+
the submitted ParseBench scores for KDLAI/KDL-Frontier-Parser-nano: a 2-stage
|
|
5
|
+
multi-region pipeline (layout detection -> per-region crop -> per-category
|
|
6
|
+
recognition) against ONE vLLM OpenAI-compatible endpoint serving the public
|
|
7
|
+
1.2B weights, followed by deterministic rule-based post-processing. No other
|
|
8
|
+
learned models, classifiers, or ensembles are involved.
|
|
9
|
+
|
|
10
|
+
Serve the weights with:
|
|
11
|
+
|
|
12
|
+
vllm serve KDLAI/KDL-Frontier-Parser-nano \
|
|
13
|
+
--served-model-name kdl-frontier-parser-nano \
|
|
14
|
+
--max-model-len 8192 --gpu-memory-utilization 0.85 \
|
|
15
|
+
--max-num-seqs 24 --trust-remote-code \
|
|
16
|
+
--limit-mm-per-prompt '{"image":1}'
|
|
17
|
+
|
|
18
|
+
Then:
|
|
19
|
+
|
|
20
|
+
KDL_NANO_ENDPOINT_URL=http://localhost:8000/v1 \
|
|
21
|
+
uv run parse-bench run kdl_frontier_nano --input_dir data ...
|
|
22
|
+
|
|
23
|
+
Config (env):
|
|
24
|
+
KDL_NANO_ENDPOINT_URL vLLM base URL ending in /v1 (required)
|
|
25
|
+
KDL_NANO_MODEL served model name (default kdl-frontier-parser-nano)
|
|
26
|
+
KDL_NANO_MAX_CONCURRENT per-document request concurrency (default 8)
|
|
27
|
+
KDL_NANO_LAYOUT_MAX_TOKENS / KDL_NANO_TABLE_MAX_TOKENS / KDL_NANO_TEXT_MAX_TOKENS /
|
|
28
|
+
KDL_NANO_PICTURE_MAX_TOKENS / KDL_NANO_FORMULA_MAX_TOKENS
|
|
29
|
+
stage budgets (defaults 6000/5500/2048/4096/128 — the
|
|
30
|
+
submitted-run values; layout+table must fit max-model-len 8192)
|
|
31
|
+
|
|
32
|
+
Most of this file is vendored verbatim from the (closed-source) KDL DeepParser
|
|
33
|
+
orchestrator so the maintainers can reproduce the submitted numbers end to end;
|
|
34
|
+
section banners mark the vendored module boundaries.
|
|
35
|
+
"""
|
|
36
|
+
import asyncio
|
|
37
|
+
import base64
|
|
38
|
+
import enum
|
|
39
|
+
import html
|
|
40
|
+
import io
|
|
41
|
+
import itertools
|
|
42
|
+
import json
|
|
43
|
+
import logging
|
|
44
|
+
import math
|
|
45
|
+
import os
|
|
46
|
+
import re
|
|
47
|
+
import unicodedata
|
|
48
|
+
from collections import Counter
|
|
49
|
+
from datetime import datetime
|
|
50
|
+
from decimal import Decimal
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
from typing import Any, Dict, List, Literal, Match, Tuple, Union, cast
|
|
53
|
+
|
|
54
|
+
import httpx
|
|
55
|
+
import markdown as _markdown_lib
|
|
56
|
+
from PIL import Image
|
|
57
|
+
from pydantic import BaseModel, Field, computed_field, model_validator
|
|
58
|
+
|
|
59
|
+
from parse_bench.inference.providers.base import (
|
|
60
|
+
Provider,
|
|
61
|
+
ProviderConfigError,
|
|
62
|
+
ProviderPermanentError,
|
|
63
|
+
ProviderTransientError,
|
|
64
|
+
)
|
|
65
|
+
from parse_bench.inference.providers.registry import register_provider
|
|
66
|
+
from parse_bench.schemas.parse_output import (
|
|
67
|
+
LayoutItemIR,
|
|
68
|
+
LayoutSegmentIR,
|
|
69
|
+
PageIR,
|
|
70
|
+
ParseLayoutPageIR,
|
|
71
|
+
ParseOutput,
|
|
72
|
+
)
|
|
73
|
+
from parse_bench.schemas.pipeline import PipelineSpec
|
|
74
|
+
from parse_bench.schemas.pipeline_io import (
|
|
75
|
+
InferenceRequest,
|
|
76
|
+
InferenceResult,
|
|
77
|
+
RawInferenceResult,
|
|
78
|
+
)
|
|
79
|
+
from parse_bench.schemas.product import ProductType
|
|
80
|
+
|
|
81
|
+
logger = logging.getLogger("kdl_frontier_nano")
|
|
82
|
+
|
|
83
|
+
# vendored modules below were written against `from enum import Enum`-style
|
|
84
|
+
# imports; provide the same names without the original package layout.
|
|
85
|
+
Enum = enum.Enum
|
|
86
|
+
|
|
87
|
+
# from deepparser_v2/config/model_flow.py (verbatim values)
|
|
88
|
+
from typing import Literal as _Literal # explicit: alias below needs Literal at module init
|
|
89
|
+
|
|
90
|
+
RecognitionStage = _Literal["text", "table", "picture", "formula"]
|
|
91
|
+
RECOGNITION_STAGES: tuple = ("text", "table", "picture", "formula")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ==========================================================================
|
|
95
|
+
# [vendored] element_schema(ElementCategory)
|
|
96
|
+
# ==========================================================================
|
|
97
|
+
class ElementCategory(str, Enum):
|
|
98
|
+
"""문서 요소 카테고리"""
|
|
99
|
+
|
|
100
|
+
# 텍스트 요소
|
|
101
|
+
TITLE = "Title"
|
|
102
|
+
SECTION_HEADER = "Section-header"
|
|
103
|
+
TEXT = "Text"
|
|
104
|
+
PAGE_HEADER = "Page-header"
|
|
105
|
+
PAGE_FOOTER = "Page-footer"
|
|
106
|
+
LIST_ITEM = "List-item"
|
|
107
|
+
CAPTION = "Caption"
|
|
108
|
+
FOOTNOTE = "Footnote"
|
|
109
|
+
|
|
110
|
+
# 시각적 요소
|
|
111
|
+
TABLE = "Table"
|
|
112
|
+
PICTURE = "Picture"
|
|
113
|
+
DATA_CHART = "Chart"
|
|
114
|
+
FLOW_DIAGRAM = "Flowchart"
|
|
115
|
+
|
|
116
|
+
# 수학 요소
|
|
117
|
+
FORMULA = "Formula"
|
|
118
|
+
|
|
119
|
+
# ==========================================================================
|
|
120
|
+
# [vendored] recognition_contract
|
|
121
|
+
# ==========================================================================
|
|
122
|
+
"""Recognition bucket contract shared by planning and layout grouping."""
|
|
123
|
+
|
|
124
|
+
# [vendor-strip] from __future__ import annotations
|
|
125
|
+
|
|
126
|
+
from typing import Sequence
|
|
127
|
+
|
|
128
|
+
# [vendor-strip] from ..schemas.element_schema import ElementCategory
|
|
129
|
+
# [vendor-strip] from .model_flow import RECOGNITION_STAGES, RecognitionStage
|
|
130
|
+
|
|
131
|
+
RecognitionBucket = RecognitionStage
|
|
132
|
+
|
|
133
|
+
RECOGNITION_BUCKETS: tuple[RecognitionBucket, ...] = RECOGNITION_STAGES
|
|
134
|
+
|
|
135
|
+
CATEGORY_TO_RECOGNITION_BUCKET: dict[str, RecognitionBucket] = {
|
|
136
|
+
ElementCategory.TITLE.value: "text",
|
|
137
|
+
ElementCategory.SECTION_HEADER.value: "text",
|
|
138
|
+
ElementCategory.TEXT.value: "text",
|
|
139
|
+
ElementCategory.PAGE_HEADER.value: "text",
|
|
140
|
+
ElementCategory.PAGE_FOOTER.value: "text",
|
|
141
|
+
ElementCategory.LIST_ITEM.value: "text",
|
|
142
|
+
ElementCategory.CAPTION.value: "text",
|
|
143
|
+
ElementCategory.FOOTNOTE.value: "text",
|
|
144
|
+
ElementCategory.TABLE.value: "table",
|
|
145
|
+
ElementCategory.PICTURE.value: "picture",
|
|
146
|
+
ElementCategory.DATA_CHART.value: "picture",
|
|
147
|
+
ElementCategory.FLOW_DIAGRAM.value: "picture",
|
|
148
|
+
ElementCategory.FORMULA.value: "formula",
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def category_to_recognition_bucket(
|
|
153
|
+
category: str | ElementCategory,
|
|
154
|
+
) -> RecognitionBucket:
|
|
155
|
+
"""Return the recognition bucket for a public element category."""
|
|
156
|
+
return CATEGORY_TO_RECOGNITION_BUCKET.get(_category_value(category), "text")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def target_categories_to_recognition_buckets(
|
|
160
|
+
target_categories: Sequence[str] | None,
|
|
161
|
+
) -> frozenset[RecognitionBucket] | None:
|
|
162
|
+
"""Map an optional public category filter to executable recognition buckets."""
|
|
163
|
+
if not target_categories:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
return frozenset(
|
|
167
|
+
CATEGORY_TO_RECOGNITION_BUCKET[category]
|
|
168
|
+
for category in (_category_value(category) for category in target_categories)
|
|
169
|
+
if category in CATEGORY_TO_RECOGNITION_BUCKET
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _category_value(category: str | ElementCategory) -> str:
|
|
174
|
+
if isinstance(category, ElementCategory):
|
|
175
|
+
return category.value
|
|
176
|
+
return str(category).strip()
|
|
177
|
+
|
|
178
|
+
# ==========================================================================
|
|
179
|
+
# [vendored] layout_contract
|
|
180
|
+
# ==========================================================================
|
|
181
|
+
"""Layout category contract shared by layout provider adapters."""
|
|
182
|
+
|
|
183
|
+
from collections.abc import Mapping
|
|
184
|
+
from typing import Any
|
|
185
|
+
|
|
186
|
+
# [vendor-strip] from ..config.recognition_contract import category_to_recognition_bucket
|
|
187
|
+
# [vendor-strip] from ..schemas.element_schema import ElementCategory
|
|
188
|
+
|
|
189
|
+
CANONICAL_LAYOUT_CATEGORIES = (
|
|
190
|
+
ElementCategory.TITLE.value,
|
|
191
|
+
ElementCategory.SECTION_HEADER.value,
|
|
192
|
+
ElementCategory.TEXT.value,
|
|
193
|
+
ElementCategory.LIST_ITEM.value,
|
|
194
|
+
ElementCategory.TABLE.value,
|
|
195
|
+
ElementCategory.PICTURE.value,
|
|
196
|
+
ElementCategory.DATA_CHART.value,
|
|
197
|
+
ElementCategory.FLOW_DIAGRAM.value,
|
|
198
|
+
ElementCategory.FORMULA.value,
|
|
199
|
+
ElementCategory.CAPTION.value,
|
|
200
|
+
ElementCategory.FOOTNOTE.value,
|
|
201
|
+
ElementCategory.PAGE_HEADER.value,
|
|
202
|
+
ElementCategory.PAGE_FOOTER.value,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
DEFAULT_LAYOUT_CATEGORY = ElementCategory.TEXT.value
|
|
206
|
+
|
|
207
|
+
_CANONICAL_BY_CASEFOLD = {
|
|
208
|
+
category.casefold(): category for category in CANONICAL_LAYOUT_CATEGORIES
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def normalize_layout_category(
|
|
213
|
+
raw_category: Any,
|
|
214
|
+
provider_category_map: Mapping[str, str] | None = None,
|
|
215
|
+
) -> str:
|
|
216
|
+
"""Normalize a provider label into the layout-stage category contract."""
|
|
217
|
+
mapped_category = _map_provider_category(raw_category, provider_category_map)
|
|
218
|
+
return _canonicalize_category(mapped_category)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def layout_recognition_bucket(category: Any) -> str:
|
|
222
|
+
"""Return the recognition bucket used immediately after layout detection."""
|
|
223
|
+
normalized_category = normalize_layout_category(category)
|
|
224
|
+
return category_to_recognition_bucket(normalized_category)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _map_provider_category(
|
|
228
|
+
raw_category: Any,
|
|
229
|
+
provider_category_map: Mapping[str, str] | None,
|
|
230
|
+
) -> str:
|
|
231
|
+
category = _stringify_category(raw_category)
|
|
232
|
+
if not provider_category_map:
|
|
233
|
+
return category
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
provider_category_map.get(category)
|
|
237
|
+
or provider_category_map.get(category.casefold())
|
|
238
|
+
or category
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _canonicalize_category(category: Any) -> str:
|
|
243
|
+
canonical = _CANONICAL_BY_CASEFOLD.get(_stringify_category(category).casefold())
|
|
244
|
+
if canonical is None:
|
|
245
|
+
return DEFAULT_LAYOUT_CATEGORY
|
|
246
|
+
return canonical
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _stringify_category(category: Any) -> str:
|
|
250
|
+
if category is None:
|
|
251
|
+
return ""
|
|
252
|
+
return str(category).strip()
|
|
253
|
+
|
|
254
|
+
# ==========================================================================
|
|
255
|
+
# [vendored] image_preprocessing
|
|
256
|
+
# ==========================================================================
|
|
257
|
+
# [vendor-strip] from __future__ import annotations
|
|
258
|
+
|
|
259
|
+
import math
|
|
260
|
+
from typing import Tuple
|
|
261
|
+
|
|
262
|
+
# [vendor-strip] from loguru import logger
|
|
263
|
+
from PIL import Image
|
|
264
|
+
from pydantic import BaseModel, Field
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# 이미지 처리 설정값
|
|
268
|
+
class ImageConfig:
|
|
269
|
+
"""이미지 처리 관련 설정값"""
|
|
270
|
+
|
|
271
|
+
# DotsOCR 설정
|
|
272
|
+
FACTOR = 28
|
|
273
|
+
MIN_PIXELS = 147384 * 2
|
|
274
|
+
MAX_PIXELS = 2822400
|
|
275
|
+
# MAX_PIXELS = 1638400
|
|
276
|
+
MAX_ASPECT_RATIO = 200
|
|
277
|
+
|
|
278
|
+
# 공통 설정
|
|
279
|
+
DEFAULT_BACKGROUND_COLOR = (255, 255, 255) # 흰색 배경
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class PageContentMetrics(BaseModel):
|
|
283
|
+
"""Lightweight page-content signals used to separate blank pages from misses."""
|
|
284
|
+
|
|
285
|
+
foreground_ratio: float = Field(..., ge=0.0, le=1.0)
|
|
286
|
+
edge_ratio: float = Field(..., ge=0.0, le=1.0)
|
|
287
|
+
intensity_variance: float = Field(..., ge=0.0)
|
|
288
|
+
is_blank: bool
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
CONTENT_ANALYSIS_MAX_DIMENSION = 192
|
|
292
|
+
FOREGROUND_DELTA_THRESHOLD = 12
|
|
293
|
+
FOREGROUND_DARK_THRESHOLD = 250
|
|
294
|
+
EDGE_DELTA_THRESHOLD = 18
|
|
295
|
+
BLANK_FOREGROUND_RATIO_THRESHOLD = 0.002
|
|
296
|
+
BLANK_EDGE_RATIO_THRESHOLD = 0.001
|
|
297
|
+
BLANK_INTENSITY_VARIANCE_THRESHOLD = 4.0
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _round_by_factor(number: int, factor: int) -> int:
|
|
301
|
+
"""주어진 factor의 배수로 반올림"""
|
|
302
|
+
return round(number / factor) * factor
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _ceil_by_factor(number: int, factor: int) -> int:
|
|
306
|
+
"""주어진 factor의 배수로 올림"""
|
|
307
|
+
return math.ceil(number / factor) * factor
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _floor_by_factor(number: int, factor: int) -> int:
|
|
311
|
+
"""주어진 factor의 배수로 내림"""
|
|
312
|
+
return math.floor(number / factor) * factor
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def normalize_image_mode(image: Image.Image, target_mode: str = "RGB") -> Image.Image:
|
|
316
|
+
"""이미지를 지정된 모드로 변환 (투명 배경 처리 포함)"""
|
|
317
|
+
if image.mode == target_mode:
|
|
318
|
+
return image
|
|
319
|
+
|
|
320
|
+
if image.mode == "RGBA" and target_mode == "RGB":
|
|
321
|
+
# RGBA를 RGB로 변환 시 흰색 배경 추가
|
|
322
|
+
background = Image.new("RGB", image.size, ImageConfig.DEFAULT_BACKGROUND_COLOR)
|
|
323
|
+
background.paste(image, mask=image.split()[3]) # 알파 채널을 마스크로 사용
|
|
324
|
+
return background
|
|
325
|
+
|
|
326
|
+
return image.convert(target_mode)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def calculate_dimensions(
|
|
330
|
+
width: int,
|
|
331
|
+
height: int,
|
|
332
|
+
factor: int = ImageConfig.FACTOR,
|
|
333
|
+
min_pixels: int = ImageConfig.MIN_PIXELS,
|
|
334
|
+
max_pixels: int = ImageConfig.MAX_PIXELS,
|
|
335
|
+
) -> Tuple[int, int]:
|
|
336
|
+
"""이미지 크기 계산"""
|
|
337
|
+
# 종횡비 검증
|
|
338
|
+
aspect_ratio = max(height, width) / min(height, width)
|
|
339
|
+
if aspect_ratio > ImageConfig.MAX_ASPECT_RATIO:
|
|
340
|
+
raise ValueError(
|
|
341
|
+
f"종횡비가 너무 큽니다. 최대 {ImageConfig.MAX_ASPECT_RATIO}:1, "
|
|
342
|
+
f"현재 {aspect_ratio:.1f}:1"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# factor의 배수로 조정
|
|
346
|
+
target_height = max(factor, _round_by_factor(height, factor))
|
|
347
|
+
target_width = max(factor, _round_by_factor(width, factor))
|
|
348
|
+
|
|
349
|
+
# 최대 픽셀 수 초과 시 축소
|
|
350
|
+
if target_height * target_width > max_pixels:
|
|
351
|
+
scale = math.sqrt((height * width) / max_pixels)
|
|
352
|
+
target_height = max(factor, _floor_by_factor(int(height / scale), factor))
|
|
353
|
+
target_width = max(factor, _floor_by_factor(int(width / scale), factor))
|
|
354
|
+
|
|
355
|
+
# 최소 픽셀 수 미만 시 확대
|
|
356
|
+
elif target_height * target_width < min_pixels:
|
|
357
|
+
scale = math.sqrt(min_pixels / (height * width))
|
|
358
|
+
target_height = _ceil_by_factor(int(height * scale), factor)
|
|
359
|
+
target_width = _ceil_by_factor(int(width * scale), factor)
|
|
360
|
+
|
|
361
|
+
# 확대 후 최대 픽셀 수 재검사
|
|
362
|
+
if target_height * target_width > max_pixels:
|
|
363
|
+
scale = math.sqrt((target_height * target_width) / max_pixels)
|
|
364
|
+
target_height = max(
|
|
365
|
+
factor, _floor_by_factor(int(target_height / scale), factor)
|
|
366
|
+
)
|
|
367
|
+
target_width = max(
|
|
368
|
+
factor, _floor_by_factor(int(target_width / scale), factor)
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
return target_width, target_height
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def smart_resize(image: Image.Image) -> Image.Image:
|
|
375
|
+
"""단일 PIL.Image 객체를 받아 리사이즈 후 반환"""
|
|
376
|
+
w, h = image.size
|
|
377
|
+
try:
|
|
378
|
+
target_width, target_height = calculate_dimensions(w, h)
|
|
379
|
+
if (target_width, target_height) != (w, h):
|
|
380
|
+
resized_img = image.resize(
|
|
381
|
+
(target_width, target_height),
|
|
382
|
+
resample=Image.Resampling.BICUBIC,
|
|
383
|
+
)
|
|
384
|
+
return resized_img
|
|
385
|
+
else:
|
|
386
|
+
return image
|
|
387
|
+
except Exception:
|
|
388
|
+
raise ValueError(f"Failed to resize image: {image}")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def is_monochromatic(image: Image.Image) -> bool:
|
|
392
|
+
"""
|
|
393
|
+
이미지가 완전 단색인지 확인합니다.
|
|
394
|
+
|
|
395
|
+
모든 픽셀이 동일한 색상인 경우 True를 반환합니다.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
image: PIL 이미지
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
bool: 단색이면 True, 아니면 False
|
|
402
|
+
"""
|
|
403
|
+
try:
|
|
404
|
+
# getcolors()는 [(픽셀수, (R,G,B)), ...] 형태로 반환
|
|
405
|
+
# 색상이 1개만 있으면 단색
|
|
406
|
+
colors = image.getcolors()
|
|
407
|
+
return colors is not None and len(colors) == 1
|
|
408
|
+
except Exception as e:
|
|
409
|
+
logger.warning(f"Failed to check monochromatic: {e}")
|
|
410
|
+
return False
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def analyze_page_content(image: Image.Image) -> PageContentMetrics:
|
|
414
|
+
"""Estimate whether a page has visible content without calling a detector."""
|
|
415
|
+
try:
|
|
416
|
+
normalized = normalize_image_mode(image, "RGB")
|
|
417
|
+
width, height = normalized.size
|
|
418
|
+
scale = min(1.0, CONTENT_ANALYSIS_MAX_DIMENSION / max(width, height))
|
|
419
|
+
if scale < 1.0:
|
|
420
|
+
sample_size = (max(1, int(width * scale)), max(1, int(height * scale)))
|
|
421
|
+
normalized = normalized.resize(
|
|
422
|
+
sample_size,
|
|
423
|
+
resample=Image.Resampling.BILINEAR,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
grayscale = normalized.convert("L")
|
|
427
|
+
pixels = list(grayscale.getdata())
|
|
428
|
+
total_pixels = len(pixels)
|
|
429
|
+
if total_pixels == 0:
|
|
430
|
+
return PageContentMetrics(
|
|
431
|
+
foreground_ratio=0.0,
|
|
432
|
+
edge_ratio=0.0,
|
|
433
|
+
intensity_variance=0.0,
|
|
434
|
+
is_blank=True,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
sorted_pixels = sorted(pixels)
|
|
438
|
+
background_index = min(
|
|
439
|
+
total_pixels - 1,
|
|
440
|
+
int(total_pixels * 0.95),
|
|
441
|
+
)
|
|
442
|
+
background_level = sorted_pixels[background_index]
|
|
443
|
+
foreground_pixels = sum(
|
|
444
|
+
1
|
|
445
|
+
for value in pixels
|
|
446
|
+
if (
|
|
447
|
+
background_level - value >= FOREGROUND_DELTA_THRESHOLD
|
|
448
|
+
and value < FOREGROUND_DARK_THRESHOLD
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
foreground_ratio = foreground_pixels / total_pixels
|
|
452
|
+
|
|
453
|
+
mean = sum(pixels) / total_pixels
|
|
454
|
+
intensity_variance = sum((value - mean) ** 2 for value in pixels) / total_pixels
|
|
455
|
+
|
|
456
|
+
sample_width, sample_height = grayscale.size
|
|
457
|
+
edge_pairs = 0
|
|
458
|
+
edge_hits = 0
|
|
459
|
+
for y in range(sample_height):
|
|
460
|
+
row_offset = y * sample_width
|
|
461
|
+
for x in range(sample_width):
|
|
462
|
+
value = pixels[row_offset + x]
|
|
463
|
+
if x + 1 < sample_width:
|
|
464
|
+
edge_pairs += 1
|
|
465
|
+
if abs(value - pixels[row_offset + x + 1]) >= EDGE_DELTA_THRESHOLD:
|
|
466
|
+
edge_hits += 1
|
|
467
|
+
if y + 1 < sample_height:
|
|
468
|
+
edge_pairs += 1
|
|
469
|
+
if (
|
|
470
|
+
abs(value - pixels[row_offset + sample_width + x])
|
|
471
|
+
>= EDGE_DELTA_THRESHOLD
|
|
472
|
+
):
|
|
473
|
+
edge_hits += 1
|
|
474
|
+
|
|
475
|
+
edge_ratio = edge_hits / edge_pairs if edge_pairs else 0.0
|
|
476
|
+
is_blank = (
|
|
477
|
+
foreground_ratio < BLANK_FOREGROUND_RATIO_THRESHOLD
|
|
478
|
+
and edge_ratio < BLANK_EDGE_RATIO_THRESHOLD
|
|
479
|
+
and intensity_variance < BLANK_INTENSITY_VARIANCE_THRESHOLD
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
return PageContentMetrics(
|
|
483
|
+
foreground_ratio=round(foreground_ratio, 6),
|
|
484
|
+
edge_ratio=round(edge_ratio, 6),
|
|
485
|
+
intensity_variance=round(float(intensity_variance), 6),
|
|
486
|
+
is_blank=is_blank,
|
|
487
|
+
)
|
|
488
|
+
except Exception as e:
|
|
489
|
+
logger.warning(f"Failed to analyze page content: {e}")
|
|
490
|
+
return PageContentMetrics(
|
|
491
|
+
foreground_ratio=0.0,
|
|
492
|
+
edge_ratio=0.0,
|
|
493
|
+
intensity_variance=0.0,
|
|
494
|
+
is_blank=False,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def preprocess_for_vlm(image: Image.Image) -> Image.Image:
|
|
499
|
+
"""
|
|
500
|
+
VLM 입력을 위한 이미지 전처리
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
image: 원본 PIL 이미지
|
|
504
|
+
|
|
505
|
+
Returns:
|
|
506
|
+
전처리된 PIL 이미지
|
|
507
|
+
"""
|
|
508
|
+
# 1. 이미지 모드 정규화
|
|
509
|
+
image = normalize_image_mode(image, "RGB")
|
|
510
|
+
|
|
511
|
+
# 2. 스마트 리사이즈
|
|
512
|
+
image = smart_resize(image)
|
|
513
|
+
|
|
514
|
+
return image
|
|
515
|
+
|
|
516
|
+
# ==========================================================================
|
|
517
|
+
# [vendored] native_layout
|
|
518
|
+
# ==========================================================================
|
|
519
|
+
"""the model layout token parsing utilities."""
|
|
520
|
+
|
|
521
|
+
import re
|
|
522
|
+
from typing import Any
|
|
523
|
+
|
|
524
|
+
from PIL import Image
|
|
525
|
+
from pydantic import BaseModel, Field
|
|
526
|
+
|
|
527
|
+
# [vendor-strip] from .layout_contract import normalize_layout_category
|
|
528
|
+
|
|
529
|
+
NATIVE_LAYOUT_IMAGE_SIZE = (1036, 1036)
|
|
530
|
+
|
|
531
|
+
_NATIVE_LAYOUT_RE = re.compile(
|
|
532
|
+
r"<\|box_start\|>(\d+)\s+(\d+)\s+(\d+)\s+(\d+)"
|
|
533
|
+
r"<\|box_end\|><\|ref_start\|>([^<]+?)<\|ref_end\|>"
|
|
534
|
+
r"(?:(<\|rotate_(?:up|right|down|left)\|>))?",
|
|
535
|
+
re.DOTALL,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
_ROTATION_ANGLES = {
|
|
539
|
+
"<|rotate_up|>": 0,
|
|
540
|
+
"<|rotate_right|>": 90,
|
|
541
|
+
"<|rotate_down|>": 180,
|
|
542
|
+
"<|rotate_left|>": 270,
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
NATIVE_LAYOUT_CATEGORY_MAP = {
|
|
546
|
+
"algorithm": "Text",
|
|
547
|
+
"aside_text": "Text",
|
|
548
|
+
"chart": "Chart",
|
|
549
|
+
"code": "Text",
|
|
550
|
+
"code_caption": "Caption",
|
|
551
|
+
"equation": "Formula",
|
|
552
|
+
"equation_block": "Formula",
|
|
553
|
+
"footer": "Page-footer",
|
|
554
|
+
"header": "Page-header",
|
|
555
|
+
"image": "Picture",
|
|
556
|
+
"image_block": "Picture",
|
|
557
|
+
"image_caption": "Caption",
|
|
558
|
+
"image_footnote": "Footnote",
|
|
559
|
+
"inline_formula": "Formula",
|
|
560
|
+
"list": "List-item",
|
|
561
|
+
"list_item": "List-item",
|
|
562
|
+
"page_footnote": "Footnote",
|
|
563
|
+
"page_number": "Page-footer",
|
|
564
|
+
"phonetic": "Text",
|
|
565
|
+
"ref_text": "List-item",
|
|
566
|
+
"table": "Table",
|
|
567
|
+
"table_caption": "Caption",
|
|
568
|
+
"table_footnote": "Footnote",
|
|
569
|
+
"text": "Text",
|
|
570
|
+
"title": "Title",
|
|
571
|
+
"unknown": "Text",
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
_LIST_CHILD_CATEGORIES = {"text", "list_item", "ref_text"}
|
|
575
|
+
_IMAGE_BLOCK_CHILD_CATEGORIES = {
|
|
576
|
+
"chart",
|
|
577
|
+
"image",
|
|
578
|
+
"image_caption",
|
|
579
|
+
"image_footnote",
|
|
580
|
+
}
|
|
581
|
+
_EQUATION_CHILD_CATEGORIES = {"equation", "inline_formula"}
|
|
582
|
+
|
|
583
|
+
_ATTACHMENT_PARENT_RAW_CATEGORIES = {
|
|
584
|
+
"table_caption": {"table"},
|
|
585
|
+
"table_footnote": {"table"},
|
|
586
|
+
"image_caption": {"image", "chart"},
|
|
587
|
+
"image_footnote": {"image", "chart"},
|
|
588
|
+
"code_caption": {"code", "algorithm"},
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
class NativeLayoutItem(BaseModel):
|
|
593
|
+
"""Raw the model layout item parsed from special tokens."""
|
|
594
|
+
|
|
595
|
+
bbox: list[float]
|
|
596
|
+
raw_bbox: list[int]
|
|
597
|
+
raw_category: str
|
|
598
|
+
layout_order: int
|
|
599
|
+
angle: int = 0
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
class NormalizedNativeLayoutItem(BaseModel):
|
|
603
|
+
"""the model layout item normalized to DeepParser categories."""
|
|
604
|
+
|
|
605
|
+
bbox: list[float]
|
|
606
|
+
category: str
|
|
607
|
+
layout_order: int
|
|
608
|
+
raw_category: str
|
|
609
|
+
angle: int = 0
|
|
610
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
611
|
+
|
|
612
|
+
def to_output_dict(self) -> dict[str, Any]:
|
|
613
|
+
data = self.model_dump()
|
|
614
|
+
if not data["metadata"]:
|
|
615
|
+
data.pop("metadata")
|
|
616
|
+
return data
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def is_native_layout_response(content: Any) -> bool:
|
|
620
|
+
return isinstance(content, str) and "<|box_start|>" in content
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def prepare_native_layout_image(image: Image.Image) -> Image.Image:
|
|
624
|
+
return image.convert("RGB").resize(
|
|
625
|
+
NATIVE_LAYOUT_IMAGE_SIZE,
|
|
626
|
+
Image.Resampling.BICUBIC,
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def parse_native_raw_layout_tokens(content: str) -> list[NativeLayoutItem]:
|
|
631
|
+
items: list[NativeLayoutItem] = []
|
|
632
|
+
for match in _NATIVE_LAYOUT_RE.finditer(content):
|
|
633
|
+
converted_bbox = _convert_native_layout_bbox(
|
|
634
|
+
(match.group(1), match.group(2), match.group(3), match.group(4))
|
|
635
|
+
)
|
|
636
|
+
if converted_bbox is None:
|
|
637
|
+
continue
|
|
638
|
+
|
|
639
|
+
raw_bbox, bbox = converted_bbox
|
|
640
|
+
raw_category = match.group(5).strip().lower()
|
|
641
|
+
angle = _ROTATION_ANGLES.get(match.group(6) or "", 0)
|
|
642
|
+
|
|
643
|
+
items.append(
|
|
644
|
+
NativeLayoutItem(
|
|
645
|
+
bbox=bbox,
|
|
646
|
+
raw_bbox=raw_bbox,
|
|
647
|
+
raw_category=raw_category,
|
|
648
|
+
layout_order=len(items),
|
|
649
|
+
angle=angle,
|
|
650
|
+
)
|
|
651
|
+
)
|
|
652
|
+
return items
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def parse_native_layout_tokens(content: str) -> list[dict[str, Any]]:
|
|
656
|
+
raw_items = parse_native_raw_layout_tokens(content)
|
|
657
|
+
normalized_items = normalize_native_layout_items(raw_items)
|
|
658
|
+
return [item.to_output_dict() for item in normalized_items]
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def normalize_native_layout_items(
|
|
662
|
+
raw_items: list[NativeLayoutItem],
|
|
663
|
+
) -> list[NormalizedNativeLayoutItem]:
|
|
664
|
+
list_containers = [
|
|
665
|
+
item
|
|
666
|
+
for item in raw_items
|
|
667
|
+
if item.raw_category == "list"
|
|
668
|
+
and _has_contained_child(item, raw_items, _LIST_CHILD_CATEGORIES)
|
|
669
|
+
]
|
|
670
|
+
image_blocks = [
|
|
671
|
+
item
|
|
672
|
+
for item in raw_items
|
|
673
|
+
if item.raw_category == "image_block"
|
|
674
|
+
and _has_contained_child(item, raw_items, _IMAGE_BLOCK_CHILD_CATEGORIES)
|
|
675
|
+
]
|
|
676
|
+
equation_blocks = [
|
|
677
|
+
item for item in raw_items if item.raw_category == "equation_block"
|
|
678
|
+
]
|
|
679
|
+
equation_child_orders = {
|
|
680
|
+
child.layout_order
|
|
681
|
+
for block in equation_blocks
|
|
682
|
+
for child in raw_items
|
|
683
|
+
if child.raw_category in _EQUATION_CHILD_CATEGORIES
|
|
684
|
+
and _bbox_contains(block.bbox, child.bbox)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
normalized_items: list[NormalizedNativeLayoutItem] = []
|
|
688
|
+
for item in raw_items:
|
|
689
|
+
metadata: dict[str, Any] = {}
|
|
690
|
+
|
|
691
|
+
if item.raw_category == "list" and item in list_containers:
|
|
692
|
+
continue
|
|
693
|
+
|
|
694
|
+
if item.raw_category == "image_block" and item in image_blocks:
|
|
695
|
+
continue
|
|
696
|
+
|
|
697
|
+
if item.layout_order in equation_child_orders:
|
|
698
|
+
continue
|
|
699
|
+
|
|
700
|
+
if item.raw_category == "equation_block":
|
|
701
|
+
children = [
|
|
702
|
+
_attachment_from_raw_item(child)
|
|
703
|
+
for child in raw_items
|
|
704
|
+
if child.raw_category in _EQUATION_CHILD_CATEGORIES
|
|
705
|
+
and _bbox_contains(item.bbox, child.bbox)
|
|
706
|
+
]
|
|
707
|
+
if children:
|
|
708
|
+
metadata["children"] = children
|
|
709
|
+
else:
|
|
710
|
+
list_parent = _find_containing_parent(item, list_containers)
|
|
711
|
+
if list_parent is not None and item.raw_category in _LIST_CHILD_CATEGORIES:
|
|
712
|
+
metadata.update(_parent_metadata(list_parent))
|
|
713
|
+
|
|
714
|
+
image_parent = _find_containing_parent(item, image_blocks)
|
|
715
|
+
if image_parent is not None:
|
|
716
|
+
metadata.update(_parent_metadata(image_parent))
|
|
717
|
+
|
|
718
|
+
normalized_items.append(
|
|
719
|
+
NormalizedNativeLayoutItem(
|
|
720
|
+
bbox=item.bbox,
|
|
721
|
+
category=_category_for_item(item, metadata),
|
|
722
|
+
layout_order=item.layout_order,
|
|
723
|
+
raw_category=item.raw_category,
|
|
724
|
+
angle=item.angle,
|
|
725
|
+
metadata=metadata,
|
|
726
|
+
)
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
_attach_caption_and_footnote_refs(normalized_items)
|
|
730
|
+
return normalized_items
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _convert_native_layout_bbox(
|
|
734
|
+
raw_bbox: tuple[str, str, str, str],
|
|
735
|
+
) -> tuple[list[int], list[float]] | None:
|
|
736
|
+
try:
|
|
737
|
+
coords = [int(value) for value in raw_bbox]
|
|
738
|
+
except ValueError:
|
|
739
|
+
return None
|
|
740
|
+
|
|
741
|
+
if any(coord < 0 or coord > 1000 for coord in coords):
|
|
742
|
+
return None
|
|
743
|
+
|
|
744
|
+
x1, y1, x2, y2 = coords
|
|
745
|
+
if x2 < x1:
|
|
746
|
+
x1, x2 = x2, x1
|
|
747
|
+
if y2 < y1:
|
|
748
|
+
y1, y2 = y2, y1
|
|
749
|
+
if x1 == x2 or y1 == y2:
|
|
750
|
+
return None
|
|
751
|
+
|
|
752
|
+
raw_coords = [x1, y1, x2, y2]
|
|
753
|
+
normalized_coords = [round(value / 1000.0, 4) for value in raw_coords]
|
|
754
|
+
return raw_coords, normalized_coords
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _category_for_item(item: NativeLayoutItem, metadata: dict[str, Any]) -> str:
|
|
758
|
+
if item.raw_category in _LIST_CHILD_CATEGORIES and (
|
|
759
|
+
item.raw_category in {"list_item", "ref_text"}
|
|
760
|
+
or metadata.get("parent_raw_category") == "list"
|
|
761
|
+
):
|
|
762
|
+
return "List-item"
|
|
763
|
+
return normalize_layout_category(item.raw_category, NATIVE_LAYOUT_CATEGORY_MAP)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _bbox_area(bbox: list[float]) -> float:
|
|
767
|
+
return max(0.0, bbox[2] - bbox[0]) * max(0.0, bbox[3] - bbox[1])
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _bbox_contains(
|
|
771
|
+
parent_bbox: list[float],
|
|
772
|
+
child_bbox: list[float],
|
|
773
|
+
*,
|
|
774
|
+
min_child_overlap: float = 0.85,
|
|
775
|
+
) -> bool:
|
|
776
|
+
child_area = _bbox_area(child_bbox)
|
|
777
|
+
if child_area <= 0:
|
|
778
|
+
return False
|
|
779
|
+
|
|
780
|
+
inter_x1 = max(parent_bbox[0], child_bbox[0])
|
|
781
|
+
inter_y1 = max(parent_bbox[1], child_bbox[1])
|
|
782
|
+
inter_x2 = min(parent_bbox[2], child_bbox[2])
|
|
783
|
+
inter_y2 = min(parent_bbox[3], child_bbox[3])
|
|
784
|
+
intersection = _bbox_area([inter_x1, inter_y1, inter_x2, inter_y2])
|
|
785
|
+
return intersection / child_area >= min_child_overlap
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _has_contained_child(
|
|
789
|
+
parent: NativeLayoutItem,
|
|
790
|
+
items: list[NativeLayoutItem],
|
|
791
|
+
child_categories: set[str],
|
|
792
|
+
) -> bool:
|
|
793
|
+
return any(
|
|
794
|
+
child.layout_order != parent.layout_order
|
|
795
|
+
and child.raw_category in child_categories
|
|
796
|
+
and _bbox_contains(parent.bbox, child.bbox)
|
|
797
|
+
for child in items
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _find_containing_parent(
|
|
802
|
+
item: NativeLayoutItem,
|
|
803
|
+
parents: list[NativeLayoutItem],
|
|
804
|
+
) -> NativeLayoutItem | None:
|
|
805
|
+
candidates = [
|
|
806
|
+
parent
|
|
807
|
+
for parent in parents
|
|
808
|
+
if parent.layout_order != item.layout_order
|
|
809
|
+
and _bbox_contains(parent.bbox, item.bbox)
|
|
810
|
+
]
|
|
811
|
+
return min(candidates, key=lambda parent: _bbox_area(parent.bbox), default=None)
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _parent_metadata(parent: NativeLayoutItem) -> dict[str, Any]:
|
|
815
|
+
return {
|
|
816
|
+
"parent_raw_category": parent.raw_category,
|
|
817
|
+
"parent_bbox": parent.bbox,
|
|
818
|
+
"parent_layout_order": parent.layout_order,
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _attachment_from_raw_item(item: NativeLayoutItem) -> dict[str, Any]:
|
|
823
|
+
return {
|
|
824
|
+
"bbox": item.bbox,
|
|
825
|
+
"raw_category": item.raw_category,
|
|
826
|
+
"layout_order": item.layout_order,
|
|
827
|
+
"angle": item.angle,
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _attachment_from_normalized_item(
|
|
832
|
+
item: NormalizedNativeLayoutItem,
|
|
833
|
+
) -> dict[str, Any]:
|
|
834
|
+
return {
|
|
835
|
+
"bbox": item.bbox,
|
|
836
|
+
"raw_category": item.raw_category,
|
|
837
|
+
"layout_order": item.layout_order,
|
|
838
|
+
"angle": item.angle,
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _attach_caption_and_footnote_refs(
|
|
843
|
+
items: list[NormalizedNativeLayoutItem],
|
|
844
|
+
) -> None:
|
|
845
|
+
for item in items:
|
|
846
|
+
parent_raw_categories = _ATTACHMENT_PARENT_RAW_CATEGORIES.get(item.raw_category)
|
|
847
|
+
if not parent_raw_categories:
|
|
848
|
+
continue
|
|
849
|
+
|
|
850
|
+
parent = _find_nearest_attachment_parent(item, items, parent_raw_categories)
|
|
851
|
+
if parent is None:
|
|
852
|
+
continue
|
|
853
|
+
|
|
854
|
+
key = (
|
|
855
|
+
"attached_footnotes"
|
|
856
|
+
if item.raw_category.endswith("_footnote")
|
|
857
|
+
else "attached_captions"
|
|
858
|
+
)
|
|
859
|
+
parent.metadata.setdefault(key, []).append(
|
|
860
|
+
_attachment_from_normalized_item(item)
|
|
861
|
+
)
|
|
862
|
+
item.metadata.update(
|
|
863
|
+
{
|
|
864
|
+
"attached_to_raw_category": parent.raw_category,
|
|
865
|
+
"attached_to_category": parent.category,
|
|
866
|
+
"attached_to_bbox": parent.bbox,
|
|
867
|
+
"attached_to_layout_order": parent.layout_order,
|
|
868
|
+
}
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _find_nearest_attachment_parent(
|
|
873
|
+
item: NormalizedNativeLayoutItem,
|
|
874
|
+
items: list[NormalizedNativeLayoutItem],
|
|
875
|
+
parent_raw_categories: set[str],
|
|
876
|
+
) -> NormalizedNativeLayoutItem | None:
|
|
877
|
+
candidates = [
|
|
878
|
+
candidate
|
|
879
|
+
for candidate in items
|
|
880
|
+
if candidate.layout_order != item.layout_order
|
|
881
|
+
and candidate.raw_category in parent_raw_categories
|
|
882
|
+
]
|
|
883
|
+
return min(
|
|
884
|
+
candidates,
|
|
885
|
+
key=lambda candidate: _attachment_distance(candidate, item),
|
|
886
|
+
default=None,
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _attachment_distance(
|
|
891
|
+
parent: NormalizedNativeLayoutItem,
|
|
892
|
+
child: NormalizedNativeLayoutItem,
|
|
893
|
+
) -> float:
|
|
894
|
+
parent_center_x = (parent.bbox[0] + parent.bbox[2]) / 2
|
|
895
|
+
child_center_x = (child.bbox[0] + child.bbox[2]) / 2
|
|
896
|
+
horizontal_gap = abs(parent_center_x - child_center_x)
|
|
897
|
+
|
|
898
|
+
if child.bbox[1] >= parent.bbox[3]:
|
|
899
|
+
vertical_gap = child.bbox[1] - parent.bbox[3]
|
|
900
|
+
elif parent.bbox[1] >= child.bbox[3]:
|
|
901
|
+
vertical_gap = parent.bbox[1] - child.bbox[3]
|
|
902
|
+
else:
|
|
903
|
+
vertical_gap = 0.0
|
|
904
|
+
|
|
905
|
+
return vertical_gap + horizontal_gap
|
|
906
|
+
|
|
907
|
+
# ==========================================================================
|
|
908
|
+
# [vendored] otsl_converter
|
|
909
|
+
# ==========================================================================
|
|
910
|
+
# Copyright (c) 2024 PaddlePaddle Authors. All Rights Reserved.
|
|
911
|
+
#
|
|
912
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
913
|
+
# you may not use this file except in compliance with the License.
|
|
914
|
+
# You may obtain a copy of the License at
|
|
915
|
+
#
|
|
916
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
917
|
+
#
|
|
918
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
919
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
920
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
921
|
+
# See the License for the specific language governing permissions and
|
|
922
|
+
# limitations under the License.
|
|
923
|
+
|
|
924
|
+
import html
|
|
925
|
+
import itertools
|
|
926
|
+
import re
|
|
927
|
+
from typing import Any, Dict, List, Tuple, cast
|
|
928
|
+
|
|
929
|
+
# [vendor-strip] from loguru import logger
|
|
930
|
+
from pydantic import BaseModel, computed_field, model_validator
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
class TableCell(BaseModel):
|
|
934
|
+
"""
|
|
935
|
+
TableCell represents a single cell in a table.
|
|
936
|
+
|
|
937
|
+
Attributes:
|
|
938
|
+
row_span (int): Number of rows spanned.
|
|
939
|
+
col_span (int): Number of columns spanned.
|
|
940
|
+
start_row_offset_idx (int): Start row index.
|
|
941
|
+
end_row_offset_idx (int): End row index (exclusive).
|
|
942
|
+
start_col_offset_idx (int): Start column index.
|
|
943
|
+
end_col_offset_idx (int): End column index (exclusive).
|
|
944
|
+
text (str): Cell text content.
|
|
945
|
+
column_header (bool): Whether this cell is a column header.
|
|
946
|
+
row_header (bool): Whether this cell is a row header.
|
|
947
|
+
row_section (bool): Whether this cell is a row section.
|
|
948
|
+
"""
|
|
949
|
+
|
|
950
|
+
row_span: int = 1
|
|
951
|
+
col_span: int = 1
|
|
952
|
+
start_row_offset_idx: int
|
|
953
|
+
end_row_offset_idx: int
|
|
954
|
+
start_col_offset_idx: int
|
|
955
|
+
end_col_offset_idx: int
|
|
956
|
+
text: str
|
|
957
|
+
column_header: bool = False
|
|
958
|
+
row_header: bool = False
|
|
959
|
+
row_section: bool = False
|
|
960
|
+
|
|
961
|
+
@model_validator(mode="before")
|
|
962
|
+
@classmethod
|
|
963
|
+
def from_dict_format(cls, data: Any) -> Any:
|
|
964
|
+
"""
|
|
965
|
+
Create TableCell from dict, extracting 'text' property correctly.
|
|
966
|
+
|
|
967
|
+
Args:
|
|
968
|
+
data (Any): Input data.
|
|
969
|
+
|
|
970
|
+
Returns:
|
|
971
|
+
Any: TableCell-compatible dict.
|
|
972
|
+
"""
|
|
973
|
+
if isinstance(data, Dict):
|
|
974
|
+
if "text" in data:
|
|
975
|
+
return data
|
|
976
|
+
text = data["bbox"].get("token", "")
|
|
977
|
+
if not len(text):
|
|
978
|
+
text_cells = data.pop("text_cell_bboxes", None)
|
|
979
|
+
if text_cells:
|
|
980
|
+
for el in text_cells:
|
|
981
|
+
text += el["token"] + " "
|
|
982
|
+
text = text.strip()
|
|
983
|
+
data["text"] = text
|
|
984
|
+
return data
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
class TableData(BaseModel):
|
|
988
|
+
"""
|
|
989
|
+
TableData holds a table's cells, row and column counts, and provides a grid property.
|
|
990
|
+
|
|
991
|
+
Attributes:
|
|
992
|
+
table_cells (List[TableCell]): List of table cells.
|
|
993
|
+
num_rows (int): Number of rows.
|
|
994
|
+
num_cols (int): Number of columns.
|
|
995
|
+
"""
|
|
996
|
+
|
|
997
|
+
table_cells: List[TableCell] = []
|
|
998
|
+
num_rows: int = 0
|
|
999
|
+
num_cols: int = 0
|
|
1000
|
+
|
|
1001
|
+
@computed_field
|
|
1002
|
+
def grid(self) -> List[List[TableCell]]:
|
|
1003
|
+
"""
|
|
1004
|
+
Returns a 2D grid of TableCell objects for the table.
|
|
1005
|
+
|
|
1006
|
+
Returns:
|
|
1007
|
+
List[List[TableCell]]: Table as 2D grid.
|
|
1008
|
+
"""
|
|
1009
|
+
table_data = [
|
|
1010
|
+
[
|
|
1011
|
+
TableCell(
|
|
1012
|
+
text="",
|
|
1013
|
+
start_row_offset_idx=i,
|
|
1014
|
+
end_row_offset_idx=i + 1,
|
|
1015
|
+
start_col_offset_idx=j,
|
|
1016
|
+
end_col_offset_idx=j + 1,
|
|
1017
|
+
)
|
|
1018
|
+
for j in range(self.num_cols)
|
|
1019
|
+
]
|
|
1020
|
+
for i in range(self.num_rows)
|
|
1021
|
+
]
|
|
1022
|
+
for cell in self.table_cells:
|
|
1023
|
+
for i in range(
|
|
1024
|
+
min(cell.start_row_offset_idx, self.num_rows),
|
|
1025
|
+
min(cell.end_row_offset_idx, self.num_rows),
|
|
1026
|
+
):
|
|
1027
|
+
for j in range(
|
|
1028
|
+
min(cell.start_col_offset_idx, self.num_cols),
|
|
1029
|
+
min(cell.end_col_offset_idx, self.num_cols),
|
|
1030
|
+
):
|
|
1031
|
+
table_data[i][j] = cell
|
|
1032
|
+
return table_data
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
# OTSL tag constants
|
|
1036
|
+
OTSL_NL = "<nl>"
|
|
1037
|
+
OTSL_FCEL = "<fcel>"
|
|
1038
|
+
OTSL_ECEL = "<ecel>"
|
|
1039
|
+
OTSL_LCEL = "<lcel>"
|
|
1040
|
+
OTSL_UCEL = "<ucel>"
|
|
1041
|
+
OTSL_XCEL = "<xcel>"
|
|
1042
|
+
|
|
1043
|
+
NON_CAPTURING_TAG_GROUP = "(?:<fcel>|<ecel>|<nl>|<lcel>|<ucel>|<xcel>)"
|
|
1044
|
+
OTSL_FIND_PATTERN = re.compile(
|
|
1045
|
+
f"{NON_CAPTURING_TAG_GROUP}.*?(?={NON_CAPTURING_TAG_GROUP}|$)", flags=re.DOTALL
|
|
1046
|
+
)
|
|
1047
|
+
IGNORABLE_OTSL_TAIL_PATTERN = re.compile(
|
|
1048
|
+
r"(?:\s|<br\s*/?>|<nl>|</otsl>)+",
|
|
1049
|
+
flags=re.IGNORECASE,
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def otsl_extract_tokens_and_text(s: str) -> Tuple[List[str], List[str]]:
|
|
1054
|
+
"""
|
|
1055
|
+
Extract OTSL tags and text parts from the input string.
|
|
1056
|
+
|
|
1057
|
+
Args:
|
|
1058
|
+
s (str): OTSL string.
|
|
1059
|
+
|
|
1060
|
+
Returns:
|
|
1061
|
+
Tuple[List[str], List[str]]: (tokens, text_parts)
|
|
1062
|
+
"""
|
|
1063
|
+
pattern = (
|
|
1064
|
+
r"("
|
|
1065
|
+
+ r"|".join([OTSL_NL, OTSL_FCEL, OTSL_ECEL, OTSL_LCEL, OTSL_UCEL, OTSL_XCEL])
|
|
1066
|
+
+ r")"
|
|
1067
|
+
)
|
|
1068
|
+
tokens = re.findall(pattern, s)
|
|
1069
|
+
text_parts = re.split(pattern, s)
|
|
1070
|
+
text_parts = [token for token in text_parts if token.strip()]
|
|
1071
|
+
return tokens, text_parts
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def otsl_parse_texts(
|
|
1075
|
+
texts: List[str], tokens: List[str]
|
|
1076
|
+
) -> Tuple[List[TableCell], List[List[str]]]:
|
|
1077
|
+
"""
|
|
1078
|
+
Parse OTSL text and tags into TableCell objects and tag structure.
|
|
1079
|
+
|
|
1080
|
+
Args:
|
|
1081
|
+
texts (List[str]): List of tokens and text.
|
|
1082
|
+
tokens (List[str]): List of OTSL tags.
|
|
1083
|
+
|
|
1084
|
+
Returns:
|
|
1085
|
+
Tuple[List[TableCell], List[List[str]]]: (table_cells, split_row_tokens)
|
|
1086
|
+
"""
|
|
1087
|
+
split_word = OTSL_NL
|
|
1088
|
+
split_row_tokens = [
|
|
1089
|
+
list(y)
|
|
1090
|
+
for x, y in itertools.groupby(tokens, lambda z: z == split_word)
|
|
1091
|
+
if not x
|
|
1092
|
+
]
|
|
1093
|
+
table_cells: List[TableCell] = []
|
|
1094
|
+
r_idx = 0
|
|
1095
|
+
c_idx = 0
|
|
1096
|
+
|
|
1097
|
+
# Ensure matrix completeness
|
|
1098
|
+
if split_row_tokens:
|
|
1099
|
+
max_cols = max(len(row) for row in split_row_tokens)
|
|
1100
|
+
for row in split_row_tokens:
|
|
1101
|
+
while len(row) < max_cols:
|
|
1102
|
+
row.append(OTSL_ECEL)
|
|
1103
|
+
new_texts = []
|
|
1104
|
+
text_idx = 0
|
|
1105
|
+
for row in split_row_tokens:
|
|
1106
|
+
for token in row:
|
|
1107
|
+
new_texts.append(token)
|
|
1108
|
+
if text_idx < len(texts) and texts[text_idx] == token:
|
|
1109
|
+
text_idx += 1
|
|
1110
|
+
if text_idx < len(texts) and texts[text_idx] not in [
|
|
1111
|
+
OTSL_NL,
|
|
1112
|
+
OTSL_FCEL,
|
|
1113
|
+
OTSL_ECEL,
|
|
1114
|
+
OTSL_LCEL,
|
|
1115
|
+
OTSL_UCEL,
|
|
1116
|
+
OTSL_XCEL,
|
|
1117
|
+
]:
|
|
1118
|
+
new_texts.append(texts[text_idx])
|
|
1119
|
+
text_idx += 1
|
|
1120
|
+
new_texts.append(OTSL_NL)
|
|
1121
|
+
if text_idx < len(texts) and texts[text_idx] == OTSL_NL:
|
|
1122
|
+
text_idx += 1
|
|
1123
|
+
texts = new_texts
|
|
1124
|
+
|
|
1125
|
+
def count_right(
|
|
1126
|
+
tokens: List[List[str]], c_idx: int, r_idx: int, which_tokens: List[str]
|
|
1127
|
+
) -> int:
|
|
1128
|
+
span = 0
|
|
1129
|
+
c_idx_iter = c_idx
|
|
1130
|
+
while tokens[r_idx][c_idx_iter] in which_tokens:
|
|
1131
|
+
c_idx_iter += 1
|
|
1132
|
+
span += 1
|
|
1133
|
+
if c_idx_iter >= len(tokens[r_idx]):
|
|
1134
|
+
return span
|
|
1135
|
+
return span
|
|
1136
|
+
|
|
1137
|
+
def count_down(
|
|
1138
|
+
tokens: List[List[str]], c_idx: int, r_idx: int, which_tokens: List[str]
|
|
1139
|
+
) -> int:
|
|
1140
|
+
span = 0
|
|
1141
|
+
r_idx_iter = r_idx
|
|
1142
|
+
while tokens[r_idx_iter][c_idx] in which_tokens:
|
|
1143
|
+
r_idx_iter += 1
|
|
1144
|
+
span += 1
|
|
1145
|
+
if r_idx_iter >= len(tokens):
|
|
1146
|
+
return span
|
|
1147
|
+
return span
|
|
1148
|
+
|
|
1149
|
+
for i, text in enumerate(texts):
|
|
1150
|
+
cell_text = ""
|
|
1151
|
+
if text in [OTSL_FCEL, OTSL_ECEL]:
|
|
1152
|
+
row_span = 1
|
|
1153
|
+
col_span = 1
|
|
1154
|
+
right_offset = 1
|
|
1155
|
+
if text != OTSL_ECEL:
|
|
1156
|
+
cell_text = texts[i + 1]
|
|
1157
|
+
right_offset = 2
|
|
1158
|
+
|
|
1159
|
+
next_right_cell = (
|
|
1160
|
+
texts[i + right_offset] if i + right_offset < len(texts) else ""
|
|
1161
|
+
)
|
|
1162
|
+
next_bottom_cell = ""
|
|
1163
|
+
if r_idx + 1 < len(split_row_tokens):
|
|
1164
|
+
if c_idx < len(split_row_tokens[r_idx + 1]):
|
|
1165
|
+
next_bottom_cell = split_row_tokens[r_idx + 1][c_idx]
|
|
1166
|
+
|
|
1167
|
+
if next_right_cell in [OTSL_LCEL, OTSL_XCEL]:
|
|
1168
|
+
col_span += count_right(
|
|
1169
|
+
split_row_tokens, c_idx + 1, r_idx, [OTSL_LCEL, OTSL_XCEL]
|
|
1170
|
+
)
|
|
1171
|
+
if next_bottom_cell in [OTSL_UCEL, OTSL_XCEL]:
|
|
1172
|
+
row_span += count_down(
|
|
1173
|
+
split_row_tokens, c_idx, r_idx + 1, [OTSL_UCEL, OTSL_XCEL]
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
table_cells.append(
|
|
1177
|
+
TableCell(
|
|
1178
|
+
text=cell_text.strip(),
|
|
1179
|
+
row_span=row_span,
|
|
1180
|
+
col_span=col_span,
|
|
1181
|
+
start_row_offset_idx=r_idx,
|
|
1182
|
+
end_row_offset_idx=r_idx + row_span,
|
|
1183
|
+
start_col_offset_idx=c_idx,
|
|
1184
|
+
end_col_offset_idx=c_idx + col_span,
|
|
1185
|
+
)
|
|
1186
|
+
)
|
|
1187
|
+
if text in [OTSL_FCEL, OTSL_ECEL, OTSL_LCEL, OTSL_UCEL, OTSL_XCEL]:
|
|
1188
|
+
c_idx += 1
|
|
1189
|
+
if text == OTSL_NL:
|
|
1190
|
+
r_idx += 1
|
|
1191
|
+
c_idx = 0
|
|
1192
|
+
return table_cells, split_row_tokens
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def export_to_html(table_data: TableData) -> str:
|
|
1196
|
+
"""
|
|
1197
|
+
Export TableData to HTML table.
|
|
1198
|
+
|
|
1199
|
+
Args:
|
|
1200
|
+
table_data (TableData): TableData object.
|
|
1201
|
+
|
|
1202
|
+
Returns:
|
|
1203
|
+
str: HTML string.
|
|
1204
|
+
"""
|
|
1205
|
+
nrows = table_data.num_rows
|
|
1206
|
+
ncols = table_data.num_cols
|
|
1207
|
+
if len(table_data.table_cells) == 0:
|
|
1208
|
+
return ""
|
|
1209
|
+
body = ""
|
|
1210
|
+
grid = table_data.grid() if callable(table_data.grid) else table_data.grid
|
|
1211
|
+
for i in range(nrows):
|
|
1212
|
+
body += "<tr>"
|
|
1213
|
+
for j in range(ncols):
|
|
1214
|
+
cell: TableCell = grid[i][j]
|
|
1215
|
+
rowspan, rowstart = (cell.row_span, cell.start_row_offset_idx)
|
|
1216
|
+
colspan, colstart = (cell.col_span, cell.start_col_offset_idx)
|
|
1217
|
+
if rowstart != i or colstart != j:
|
|
1218
|
+
continue
|
|
1219
|
+
content = html.escape(cell.text.strip()).replace("\r\n", "\n")
|
|
1220
|
+
content = content.replace("\r", "\n").replace("\n", "<br>")
|
|
1221
|
+
celltag = "th" if cell.column_header else "td"
|
|
1222
|
+
opening_tag = f"{celltag}"
|
|
1223
|
+
if rowspan > 1:
|
|
1224
|
+
opening_tag += f' rowspan="{rowspan}"'
|
|
1225
|
+
if colspan > 1:
|
|
1226
|
+
opening_tag += f' colspan="{colspan}"'
|
|
1227
|
+
body += f"<{opening_tag}>{content}</{celltag}>"
|
|
1228
|
+
body += "</tr>"
|
|
1229
|
+
body = f"<table>{body}</table>"
|
|
1230
|
+
return body
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def otsl_pad_to_sqr_v2(otsl_str: str) -> str:
|
|
1234
|
+
"""
|
|
1235
|
+
Pad OTSL string to a square (rectangular) format, ensuring each row has equal number of cells.
|
|
1236
|
+
|
|
1237
|
+
Args:
|
|
1238
|
+
otsl_str (str): OTSL string.
|
|
1239
|
+
|
|
1240
|
+
Returns:
|
|
1241
|
+
str: Padded OTSL string.
|
|
1242
|
+
"""
|
|
1243
|
+
assert isinstance(otsl_str, str)
|
|
1244
|
+
otsl_str = otsl_str.strip()
|
|
1245
|
+
if OTSL_NL not in otsl_str:
|
|
1246
|
+
return otsl_str + OTSL_NL
|
|
1247
|
+
lines = otsl_str.split(OTSL_NL)
|
|
1248
|
+
row_data: List[Dict[str, Any]] = []
|
|
1249
|
+
for line in lines:
|
|
1250
|
+
if not line:
|
|
1251
|
+
continue
|
|
1252
|
+
raw_cells = OTSL_FIND_PATTERN.findall(line)
|
|
1253
|
+
if not raw_cells:
|
|
1254
|
+
continue
|
|
1255
|
+
total_len = len(raw_cells)
|
|
1256
|
+
min_len = 0
|
|
1257
|
+
for i, cell_str in enumerate(raw_cells):
|
|
1258
|
+
if cell_str.startswith(OTSL_FCEL):
|
|
1259
|
+
min_len = i + 1
|
|
1260
|
+
row_data.append(
|
|
1261
|
+
{"raw_cells": raw_cells, "total_len": total_len, "min_len": min_len}
|
|
1262
|
+
)
|
|
1263
|
+
if not row_data:
|
|
1264
|
+
return OTSL_NL
|
|
1265
|
+
global_min_width = max(row["min_len"] for row in row_data) if row_data else 0
|
|
1266
|
+
max_total_len = max(row["total_len"] for row in row_data) if row_data else 0
|
|
1267
|
+
search_start = global_min_width
|
|
1268
|
+
search_end = max(global_min_width, max_total_len)
|
|
1269
|
+
min_total_cost = float("inf")
|
|
1270
|
+
optimal_width = search_end
|
|
1271
|
+
|
|
1272
|
+
for width in range(search_start, search_end + 1):
|
|
1273
|
+
current_total_cost = sum(abs(row["total_len"] - width) for row in row_data)
|
|
1274
|
+
if current_total_cost < min_total_cost:
|
|
1275
|
+
min_total_cost = current_total_cost
|
|
1276
|
+
optimal_width = width
|
|
1277
|
+
|
|
1278
|
+
repaired_lines = []
|
|
1279
|
+
for row in row_data:
|
|
1280
|
+
cells = cast(List[str], row["raw_cells"])
|
|
1281
|
+
current_len = len(cells)
|
|
1282
|
+
# Never truncate rows here. Merge markers such as <lcel>/<ucel>/<xcel>
|
|
1283
|
+
# carry structural information, so cutting longer rows can drop content.
|
|
1284
|
+
if current_len > optimal_width:
|
|
1285
|
+
new_cells = cells
|
|
1286
|
+
else:
|
|
1287
|
+
padding = [OTSL_ECEL] * (optimal_width - current_len)
|
|
1288
|
+
new_cells = cells + padding
|
|
1289
|
+
repaired_lines.append("".join(new_cells))
|
|
1290
|
+
return OTSL_NL.join(repaired_lines) + OTSL_NL
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def convert_otsl_to_html(otsl_content: str) -> str:
|
|
1294
|
+
"""
|
|
1295
|
+
Convert OTSL-v1.0 string to HTML. Only 6 tags allowed: <fcel>, <ecel>, <nl>, <lcel>, <ucel>, <xcel>.
|
|
1296
|
+
|
|
1297
|
+
Args:
|
|
1298
|
+
otsl_content (str): OTSL string.
|
|
1299
|
+
|
|
1300
|
+
Returns:
|
|
1301
|
+
str: HTML table.
|
|
1302
|
+
"""
|
|
1303
|
+
otsl_content = otsl_pad_to_sqr_v2(otsl_content)
|
|
1304
|
+
tokens, mixed_texts = otsl_extract_tokens_and_text(otsl_content)
|
|
1305
|
+
table_cells, split_row_tokens = otsl_parse_texts(mixed_texts, tokens)
|
|
1306
|
+
table_data = TableData(
|
|
1307
|
+
num_rows=len(split_row_tokens),
|
|
1308
|
+
num_cols=(max(len(row) for row in split_row_tokens) if split_row_tokens else 0),
|
|
1309
|
+
table_cells=table_cells,
|
|
1310
|
+
)
|
|
1311
|
+
return export_to_html(table_data)
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def _collapse_placeholder_with_ignorable_tail(
|
|
1315
|
+
otsl_content: str, placeholders: List[str]
|
|
1316
|
+
) -> str:
|
|
1317
|
+
stripped = otsl_content.strip()
|
|
1318
|
+
for placeholder in placeholders:
|
|
1319
|
+
if stripped == placeholder:
|
|
1320
|
+
return placeholder
|
|
1321
|
+
if stripped.startswith(placeholder):
|
|
1322
|
+
tail = stripped[len(placeholder) :].strip()
|
|
1323
|
+
if tail and IGNORABLE_OTSL_TAIL_PATTERN.fullmatch(tail):
|
|
1324
|
+
return placeholder
|
|
1325
|
+
return otsl_content
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
_OTSL_STRUCTURAL_TAG_RE = re.compile(r"<(?:otsl|fcel|ecel|lcel|ucel|xcel)>")
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def _flatten_top_level_table_placeholders(
|
|
1332
|
+
otsl_content: str, placeholder_to_html: Dict[str, str]
|
|
1333
|
+
) -> str | None:
|
|
1334
|
+
"""If the top level is one or more table placeholders plus only non-OTSL
|
|
1335
|
+
text (sibling tables and/or a the model-appended caption/note after
|
|
1336
|
+
``</otsl>``), return the concatenated HTML of those tables in order of
|
|
1337
|
+
appearance; otherwise ``None``.
|
|
1338
|
+
|
|
1339
|
+
Without this, a placeholder-only top-level string has no OTSL cell tags,
|
|
1340
|
+
so ``convert_otsl_to_html`` yields an empty table and every table —
|
|
1341
|
+
sibling tables and all cell line breaks included — is silently dropped
|
|
1342
|
+
(issue #176). A genuinely nested table keeps structural OTSL tags at the
|
|
1343
|
+
top level, so the remainder check returns ``None`` and it falls through
|
|
1344
|
+
to the normal render path unchanged.
|
|
1345
|
+
"""
|
|
1346
|
+
present = [p for p in placeholder_to_html if p in otsl_content]
|
|
1347
|
+
if not present:
|
|
1348
|
+
return None
|
|
1349
|
+
remainder = otsl_content
|
|
1350
|
+
for placeholder in present:
|
|
1351
|
+
remainder = remainder.replace(placeholder, "")
|
|
1352
|
+
if _OTSL_STRUCTURAL_TAG_RE.search(remainder):
|
|
1353
|
+
return None
|
|
1354
|
+
ordered = sorted(present, key=otsl_content.index)
|
|
1355
|
+
return "".join(placeholder_to_html[p] for p in ordered)
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def convert_otsl_to_html_v2(otsl_content: str, debug: bool = False) -> str:
|
|
1359
|
+
"""
|
|
1360
|
+
Convert OTSL-v1.0 string to HTML with support for nested tables.
|
|
1361
|
+
Recursively processes nested <otsl>...</otsl> blocks from innermost to outermost.
|
|
1362
|
+
|
|
1363
|
+
Args:
|
|
1364
|
+
otsl_content (str): OTSL string, possibly with nested <otsl> blocks.
|
|
1365
|
+
debug (bool): Enable debug output.
|
|
1366
|
+
|
|
1367
|
+
Returns:
|
|
1368
|
+
str: HTML table with nested tables properly rendered.
|
|
1369
|
+
"""
|
|
1370
|
+
import uuid
|
|
1371
|
+
|
|
1372
|
+
if debug:
|
|
1373
|
+
logger.debug("OTSL conversion started", input_preview=otsl_content[:200])
|
|
1374
|
+
|
|
1375
|
+
# Bug G guard: a genuinely nested table needs >= 2 <otsl> opens. When
|
|
1376
|
+
# there is at most one open but more </otsl> closes than opens, the
|
|
1377
|
+
# input is a single (malformed) table with a stray/premature </otsl>
|
|
1378
|
+
# (the model sometimes emits this). Running the non-greedy nested regex
|
|
1379
|
+
# on it splits the table at the first </otsl> and silently drops the
|
|
1380
|
+
# rest. Strip every otsl tag and convert it flat instead. Well-formed
|
|
1381
|
+
# single (1 open / 1 close) and genuine nested (>= 2 opens) are NOT
|
|
1382
|
+
# affected — they fall through to the unchanged path below.
|
|
1383
|
+
n_open = otsl_content.count("<otsl>")
|
|
1384
|
+
if n_open <= 1 and otsl_content.count("</otsl>") > n_open:
|
|
1385
|
+
flat = otsl_content.replace("<otsl>", "").replace("</otsl>", "")
|
|
1386
|
+
try:
|
|
1387
|
+
return convert_otsl_to_html(flat)
|
|
1388
|
+
except Exception as e: # noqa: BLE001
|
|
1389
|
+
if debug:
|
|
1390
|
+
logger.error("Bug-G flat conversion failed", error=str(e))
|
|
1391
|
+
return "<table></table>"
|
|
1392
|
+
|
|
1393
|
+
# 1단계: 모든 중첩 테이블을 placeholder로 치환하고 OTSL 저장
|
|
1394
|
+
# placeholder -> OTSL 매핑 (아직 HTML 아님!)
|
|
1395
|
+
placeholder_to_otsl: Dict[str, str] = {}
|
|
1396
|
+
|
|
1397
|
+
# 가장 안쪽 <otsl>...</otsl> 블록을 찾는 정규식
|
|
1398
|
+
nested_pattern = r"<otsl>((?:(?!<otsl>).)*?)</otsl>"
|
|
1399
|
+
|
|
1400
|
+
iteration = 0
|
|
1401
|
+
max_iterations = 100
|
|
1402
|
+
|
|
1403
|
+
while iteration < max_iterations:
|
|
1404
|
+
match = re.search(nested_pattern, otsl_content, re.DOTALL)
|
|
1405
|
+
if not match:
|
|
1406
|
+
break
|
|
1407
|
+
|
|
1408
|
+
inner_otsl = match.group(1) # <otsl> 태그 제외한 OTSL 내용
|
|
1409
|
+
|
|
1410
|
+
if debug:
|
|
1411
|
+
logger.debug(
|
|
1412
|
+
"Found nested OTSL",
|
|
1413
|
+
iteration=iteration,
|
|
1414
|
+
nested_preview=inner_otsl[:100],
|
|
1415
|
+
)
|
|
1416
|
+
|
|
1417
|
+
# placeholder 생성하고 OTSL 저장 (HTML 변환은 나중에!)
|
|
1418
|
+
placeholder = f"__NESTED_TABLE_{uuid.uuid4().hex}__"
|
|
1419
|
+
placeholder_to_otsl[placeholder] = inner_otsl
|
|
1420
|
+
|
|
1421
|
+
# 원본에서 해당 블록을 placeholder로 치환
|
|
1422
|
+
otsl_content = (
|
|
1423
|
+
otsl_content[: match.start()] + placeholder + otsl_content[match.end() :]
|
|
1424
|
+
)
|
|
1425
|
+
iteration += 1
|
|
1426
|
+
|
|
1427
|
+
if debug:
|
|
1428
|
+
logger.debug("After all nested replacement", content_preview=otsl_content[:200])
|
|
1429
|
+
logger.debug("Total nested levels found", total_levels=len(placeholder_to_otsl))
|
|
1430
|
+
|
|
1431
|
+
# 최상위 <otsl> 태그 제거
|
|
1432
|
+
otsl_content = re.sub(r"^<otsl>|</otsl>$", "", otsl_content.strip())
|
|
1433
|
+
otsl_content = _collapse_placeholder_with_ignorable_tail(
|
|
1434
|
+
otsl_content, list(placeholder_to_otsl)
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1437
|
+
# 2단계: 모든 레벨의 OTSL을 HTML로 변환 (placeholder 포함된 채로)
|
|
1438
|
+
placeholder_to_html: Dict[str, str] = {}
|
|
1439
|
+
|
|
1440
|
+
for placeholder, otsl in placeholder_to_otsl.items():
|
|
1441
|
+
try:
|
|
1442
|
+
inner_html = convert_otsl_to_html(otsl)
|
|
1443
|
+
placeholder_to_html[placeholder] = inner_html
|
|
1444
|
+
if debug:
|
|
1445
|
+
logger.debug(
|
|
1446
|
+
f"Converted {placeholder[:30]}... to HTML: {inner_html[:80]}..."
|
|
1447
|
+
)
|
|
1448
|
+
except Exception as e:
|
|
1449
|
+
placeholder_to_html[placeholder] = "<table></table>"
|
|
1450
|
+
if debug:
|
|
1451
|
+
logger.error(
|
|
1452
|
+
"OTSL conversion failed", placeholder=placeholder, error=str(e)
|
|
1453
|
+
)
|
|
1454
|
+
|
|
1455
|
+
# 최상위 레벨도 HTML로 변환
|
|
1456
|
+
top_stripped = otsl_content.strip()
|
|
1457
|
+
flat_html = _flatten_top_level_table_placeholders(top_stripped, placeholder_to_html)
|
|
1458
|
+
if top_stripped in placeholder_to_otsl:
|
|
1459
|
+
# 전체가 하나의 중첩 테이블인 경우
|
|
1460
|
+
html_result = placeholder_to_html.get(top_stripped, "<table></table>")
|
|
1461
|
+
elif flat_html is not None:
|
|
1462
|
+
# placeholder(들) + (표 아닌) 잡텍스트: 형제 표를 통째로 버리지 말고
|
|
1463
|
+
# 등장 순서대로 이어붙인다. the model full-page 출력은 표를 여러 개 쌓거나
|
|
1464
|
+
# 표 뒤에 비고/캡션을 덧붙인다 (이슈 #176).
|
|
1465
|
+
html_result = flat_html
|
|
1466
|
+
else:
|
|
1467
|
+
try:
|
|
1468
|
+
html_result = convert_otsl_to_html(otsl_content)
|
|
1469
|
+
if debug:
|
|
1470
|
+
logger.debug("Top-level HTML generated", html_preview=html_result[:150])
|
|
1471
|
+
except Exception as e:
|
|
1472
|
+
if debug:
|
|
1473
|
+
logger.error("Top-level conversion failed", error=str(e))
|
|
1474
|
+
return "<table></table>"
|
|
1475
|
+
|
|
1476
|
+
# 3단계: 모든 placeholder를 실제 HTML로 치환 (가장 안쪽부터)
|
|
1477
|
+
# 여러 번 반복하여 모든 레벨의 placeholder를 치환
|
|
1478
|
+
max_replace_iterations = 10
|
|
1479
|
+
for _ in range(max_replace_iterations):
|
|
1480
|
+
replaced = False
|
|
1481
|
+
for placeholder, inner_html in placeholder_to_html.items():
|
|
1482
|
+
escaped_placeholder = html.escape(placeholder)
|
|
1483
|
+
if placeholder in html_result or escaped_placeholder in html_result:
|
|
1484
|
+
html_result = html_result.replace(placeholder, inner_html)
|
|
1485
|
+
html_result = html_result.replace(escaped_placeholder, inner_html)
|
|
1486
|
+
replaced = True
|
|
1487
|
+
if not replaced:
|
|
1488
|
+
break
|
|
1489
|
+
|
|
1490
|
+
if debug:
|
|
1491
|
+
logger.debug("OTSL conversion completed", result_preview=html_result[:200])
|
|
1492
|
+
|
|
1493
|
+
return str(html_result)
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
if __name__ == "__main__":
|
|
1497
|
+
# 기존 함수 테스트 (중첩 테이블 없는 경우)
|
|
1498
|
+
simple_otsl = "<fcel>A<fcel>B<nl><fcel>C<fcel>D<nl>"
|
|
1499
|
+
print("=== Simple table (v1) ===")
|
|
1500
|
+
print(convert_otsl_to_html(simple_otsl))
|
|
1501
|
+
print()
|
|
1502
|
+
|
|
1503
|
+
# v2 함수 테스트 (중첩 테이블 있는 경우)
|
|
1504
|
+
nested_otsl = "<otsl><fcel><otsl><fcel>연 번<fcel>품명<lcel><fcel>규격<fcel>신고인<fcel>증정국(인)<fcel>선물<br>가액<nl><ucel><fcel>사진<fcel>품목<ucel><ucel><ucel><ucel><nl><fcel>1<ecel><ecel><ecel><ecel><ecel><ecel><nl><fcel>(작성<br>예시)<fcel><otsl><fcel><nl></otsl><fcel>다기<fcel>24cm×26cm❙<fcel>김동연<br>(도지사)<fcel>중국<br>(천리)<fcel>추정가<br>(약 15만원<br>또는<br>가액 추정 불가<nl></otsl><lcel><lcel><nl><fcel>장<fcel>관<fcel>항 목<nl><fcel>200세외수입<lcel><lcel><nl><ecel><fcel>210경상적 세외수입<lcel><nl><ecel><fcel>220임시적 세외수입<lcel><nl><ecel><ecel><fcel>221재산매각수입<nl><ucel><ecel><fcel>222자치단체간 부담금<nl><ucel><ecel><fcel>223보조금 반환수입<nl><ucel><ecel><fcel>224기타수입<nl><ucel><ecel><fcel>225지난년도 수입<nl><ucel><ecel><fcel>230지방행정제재부과금등<nl><ucel><fcel>240지난년도 수입<lcel><nl><ucel><ecel><fcel>241지난년도 수입<nl><ucel><ecel><fcel>241-01지난년도 수입<nl></otsl>"
|
|
1505
|
+
print("=== Nested table (v2) ===")
|
|
1506
|
+
print(convert_otsl_to_html_v2(nested_otsl))
|
|
1507
|
+
print()
|
|
1508
|
+
|
|
1509
|
+
# 간단한 중첩 테이블 테스트
|
|
1510
|
+
simple_nested = "<otsl><fcel>Outer A<fcel><otsl><fcel>Inner 1<fcel>Inner 2<nl></otsl><nl><fcel>Outer B<fcel>Outer C<nl></otsl>"
|
|
1511
|
+
print("=== Simple nested (v2) ===")
|
|
1512
|
+
print(convert_otsl_to_html_v2(simple_nested, debug=True))
|
|
1513
|
+
|
|
1514
|
+
# ==========================================================================
|
|
1515
|
+
# [vendored] truncate_repeat
|
|
1516
|
+
# ==========================================================================
|
|
1517
|
+
import re
|
|
1518
|
+
import unicodedata
|
|
1519
|
+
from collections import Counter
|
|
1520
|
+
from typing import Tuple, Union
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def find_shortest_repeating_substring(s: str) -> Union[str, None]:
|
|
1524
|
+
"""
|
|
1525
|
+
Find the shortest substring that repeats to form the entire string.
|
|
1526
|
+
|
|
1527
|
+
Args:
|
|
1528
|
+
s (str): Input string.
|
|
1529
|
+
|
|
1530
|
+
Returns:
|
|
1531
|
+
str or None: Shortest repeating substring, or None if not found.
|
|
1532
|
+
"""
|
|
1533
|
+
n = len(s)
|
|
1534
|
+
for i in range(1, n // 2 + 1):
|
|
1535
|
+
if n % i == 0:
|
|
1536
|
+
substring = s[:i]
|
|
1537
|
+
if substring * (n // i) == s:
|
|
1538
|
+
return substring
|
|
1539
|
+
return None
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
def find_repeating_suffix(
|
|
1543
|
+
s: str, min_len: int = 8, min_repeats: int = 5
|
|
1544
|
+
) -> Union[Tuple[str, str, int], None]:
|
|
1545
|
+
"""
|
|
1546
|
+
Detect if string ends with a repeating phrase.
|
|
1547
|
+
|
|
1548
|
+
Args:
|
|
1549
|
+
s (str): Input string.
|
|
1550
|
+
min_len (int): Minimum length of unit.
|
|
1551
|
+
min_repeats (int): Minimum repeat count.
|
|
1552
|
+
|
|
1553
|
+
Returns:
|
|
1554
|
+
Tuple[str, str, int] or None: (prefix, unit, count) if found, else None.
|
|
1555
|
+
"""
|
|
1556
|
+
for i in range(len(s) // (min_repeats), min_len - 1, -1):
|
|
1557
|
+
unit = s[-i:]
|
|
1558
|
+
if s.endswith(unit * min_repeats):
|
|
1559
|
+
count = 0
|
|
1560
|
+
temp_s = s
|
|
1561
|
+
while temp_s.endswith(unit):
|
|
1562
|
+
temp_s = temp_s[:-i]
|
|
1563
|
+
count += 1
|
|
1564
|
+
start_index = len(s) - (count * i)
|
|
1565
|
+
return s[:start_index], unit, count
|
|
1566
|
+
return None
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def remove_ngram_repeats(text: str, ngram_size: int = 2, min_repeats: int = 3) -> str:
|
|
1570
|
+
"""
|
|
1571
|
+
Remove intermediate n-gram repetitions in text.
|
|
1572
|
+
|
|
1573
|
+
Args:
|
|
1574
|
+
text: Input text.
|
|
1575
|
+
ngram_size: Size of n-gram to detect (default: 2 for bigram).
|
|
1576
|
+
min_repeats: Minimum number of repeats to trigger removal.
|
|
1577
|
+
|
|
1578
|
+
Returns:
|
|
1579
|
+
Text with intermediate n-gram repetitions removed.
|
|
1580
|
+
|
|
1581
|
+
Example:
|
|
1582
|
+
>>> remove_ngram_repeats("재직한 기간에 재직한 기간에 재직한 기간에")
|
|
1583
|
+
"재직한 기간에"
|
|
1584
|
+
"""
|
|
1585
|
+
if not text:
|
|
1586
|
+
return text
|
|
1587
|
+
|
|
1588
|
+
words = text.split()
|
|
1589
|
+
|
|
1590
|
+
if len(words) < ngram_size * min_repeats:
|
|
1591
|
+
return text
|
|
1592
|
+
|
|
1593
|
+
# n-gram 패턴 추출 및 반복 탐지
|
|
1594
|
+
i = 0
|
|
1595
|
+
result_words = []
|
|
1596
|
+
|
|
1597
|
+
while i < len(words):
|
|
1598
|
+
# 남은 단어가 ngram_size보다 적으면 그대로 추가
|
|
1599
|
+
if i + ngram_size > len(words):
|
|
1600
|
+
result_words.extend(words[i:])
|
|
1601
|
+
break
|
|
1602
|
+
|
|
1603
|
+
# 현재 n-gram
|
|
1604
|
+
current_ngram = tuple(words[i : i + ngram_size])
|
|
1605
|
+
|
|
1606
|
+
# 앞으로 이 n-gram이 얼마나 반복되는지 확인
|
|
1607
|
+
repeat_count = 1
|
|
1608
|
+
j = i + ngram_size
|
|
1609
|
+
|
|
1610
|
+
while j + ngram_size <= len(words):
|
|
1611
|
+
next_ngram = tuple(words[j : j + ngram_size])
|
|
1612
|
+
if next_ngram == current_ngram:
|
|
1613
|
+
repeat_count += 1
|
|
1614
|
+
j += ngram_size
|
|
1615
|
+
else:
|
|
1616
|
+
break
|
|
1617
|
+
|
|
1618
|
+
# 반복이 최소 횟수 이상이면 첫 번째만 남기고 건너뜀
|
|
1619
|
+
if repeat_count >= min_repeats:
|
|
1620
|
+
result_words.extend(words[i : i + ngram_size]) # 첫 번째만 추가
|
|
1621
|
+
i = j # 반복된 부분 모두 건너뜀
|
|
1622
|
+
else:
|
|
1623
|
+
result_words.append(words[i]) # 한 단어씩 추가
|
|
1624
|
+
i += 1
|
|
1625
|
+
|
|
1626
|
+
return " ".join(result_words)
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def truncate_repetitive_content(
|
|
1630
|
+
content: str,
|
|
1631
|
+
line_threshold: int = 10,
|
|
1632
|
+
char_threshold: int = 10,
|
|
1633
|
+
min_len: int = 10,
|
|
1634
|
+
preserve_line_breaks: bool = False,
|
|
1635
|
+
) -> str:
|
|
1636
|
+
"""
|
|
1637
|
+
Detect and truncate character-level, phrase-level, or line-level repetition in content.
|
|
1638
|
+
|
|
1639
|
+
Args:
|
|
1640
|
+
content (str): Input text.
|
|
1641
|
+
line_threshold (int): Min lines for line-level truncation.
|
|
1642
|
+
char_threshold (int): Min repeats for char-level truncation.
|
|
1643
|
+
min_len (int): Min length for char-level check.
|
|
1644
|
+
preserve_line_breaks (bool): If True, preserve line breaks (for markdown tables).
|
|
1645
|
+
|
|
1646
|
+
Returns:
|
|
1647
|
+
Union[str, str]: (truncated_content, info_string)
|
|
1648
|
+
"""
|
|
1649
|
+
# 줄바꿈 유지 모드: 라인 단위로 처리
|
|
1650
|
+
if preserve_line_breaks:
|
|
1651
|
+
lines = content.split("\n")
|
|
1652
|
+
processed_lines = []
|
|
1653
|
+
for line in lines:
|
|
1654
|
+
# 빈 줄은 그대로 유지
|
|
1655
|
+
if not line.strip():
|
|
1656
|
+
processed_lines.append(line)
|
|
1657
|
+
else:
|
|
1658
|
+
# 각 라인을 개별적으로 처리 (줄바꿈 유지 안 함)
|
|
1659
|
+
processed = truncate_repetitive_content(
|
|
1660
|
+
line,
|
|
1661
|
+
line_threshold,
|
|
1662
|
+
char_threshold,
|
|
1663
|
+
min_len,
|
|
1664
|
+
preserve_line_breaks=False,
|
|
1665
|
+
)
|
|
1666
|
+
processed_lines.append(processed)
|
|
1667
|
+
return "\n".join(processed_lines)
|
|
1668
|
+
|
|
1669
|
+
stripped_content = content.strip()
|
|
1670
|
+
|
|
1671
|
+
# Priority 0: 1-gram repetition (single word repeats like "안녕 안녕 안녕")
|
|
1672
|
+
if " " in stripped_content and len(stripped_content.split()) >= 4:
|
|
1673
|
+
# 같은 단어가 4번 이상 반복되면 첫 번째만 남김
|
|
1674
|
+
words = stripped_content.split()
|
|
1675
|
+
if len(set(words)) == 1: # 모든 단어가 같음
|
|
1676
|
+
content = words[0]
|
|
1677
|
+
stripped_content = content.strip()
|
|
1678
|
+
|
|
1679
|
+
# Priority 0.5: N-gram repetition (intermediate phrase repeats like "재직한 기간에 재직한 기간에")
|
|
1680
|
+
# 여러 n-gram 크기를 시도 (가장 긴 패턴부터)
|
|
1681
|
+
if " " in stripped_content and len(stripped_content.split()) >= 6:
|
|
1682
|
+
|
|
1683
|
+
def _dedup_ngrams(line: str) -> str:
|
|
1684
|
+
# 3-gram 우선, 그 다음 2-gram을 시도해 더 많이 제거된(더 짧은) 결과 선택
|
|
1685
|
+
line_3gram = remove_ngram_repeats(line, ngram_size=3, min_repeats=3)
|
|
1686
|
+
line_2gram = remove_ngram_repeats(line, ngram_size=2, min_repeats=3)
|
|
1687
|
+
candidate = line_3gram if len(line_3gram) < len(line_2gram) else line_2gram
|
|
1688
|
+
# remove_ngram_repeats는 반복이 없어도 항상 공백을 평탄화하므로,
|
|
1689
|
+
# 반복이 실제로 제거된 경우(평탄화된 원본보다 짧을 때)에만 교체한다.
|
|
1690
|
+
# 그렇지 않으면 원본 줄을 그대로 유지해 줄바꿈/공백 손실을 막는다 (이슈 #171).
|
|
1691
|
+
normalized = " ".join(line.split())
|
|
1692
|
+
return candidate if len(candidate) < len(normalized) else line
|
|
1693
|
+
|
|
1694
|
+
# 줄 단위로 처리해 줄바꿈 구조를 보존한다 (n-gram 반복은 줄 내부 구문 루프가 대상).
|
|
1695
|
+
content = "\n".join(
|
|
1696
|
+
_dedup_ngrams(line) if line.strip() else line
|
|
1697
|
+
for line in content.split("\n")
|
|
1698
|
+
)
|
|
1699
|
+
stripped_content = content.strip()
|
|
1700
|
+
|
|
1701
|
+
# Priority 0: Remove long sequences of separator/symbol characters
|
|
1702
|
+
# Uses Unicode category to detect punctuation (P), space (Zs), and symbol (S) characters
|
|
1703
|
+
# This handles dots, underscores, dashes, box drawings, waves, etc. elegantly
|
|
1704
|
+
def is_separator_symbol(char: str) -> bool:
|
|
1705
|
+
"""Check if character is a separator/symbol that should be removed when repeated."""
|
|
1706
|
+
cat = unicodedata.category(char)
|
|
1707
|
+
# P*: Punctuation, Zs: Space separator, S*: All symbols (Sm, Sc, Sk, So)
|
|
1708
|
+
return cat.startswith("P") or cat == "Zs" or cat.startswith("S")
|
|
1709
|
+
|
|
1710
|
+
# Find and remove repeated separator/symbol characters (5+ repeats)
|
|
1711
|
+
match = re.search(r"(.)\1{4,}", stripped_content)
|
|
1712
|
+
if match and is_separator_symbol(match.group(1)):
|
|
1713
|
+
repeated_char = match.group(1)
|
|
1714
|
+
content = re.sub(re.escape(repeated_char) + r"{5,}", "", content)
|
|
1715
|
+
stripped_content = content.strip()
|
|
1716
|
+
|
|
1717
|
+
if not stripped_content:
|
|
1718
|
+
return content
|
|
1719
|
+
|
|
1720
|
+
# Priority 1: Phrase-level suffix repetition in long single lines.
|
|
1721
|
+
if "\n" not in stripped_content and len(stripped_content) > 100:
|
|
1722
|
+
suffix_match = find_repeating_suffix(stripped_content, min_len=8, min_repeats=5)
|
|
1723
|
+
if suffix_match:
|
|
1724
|
+
prefix, repeating_unit, count = suffix_match
|
|
1725
|
+
if len(repeating_unit) * count > len(stripped_content) * 0.5:
|
|
1726
|
+
return prefix
|
|
1727
|
+
|
|
1728
|
+
# Priority 2: Full-string character-level repetition (e.g., 'ababab')
|
|
1729
|
+
if "\n" not in stripped_content and len(stripped_content) > min_len:
|
|
1730
|
+
repeating_substring = find_shortest_repeating_substring(stripped_content)
|
|
1731
|
+
if repeating_substring:
|
|
1732
|
+
count = len(stripped_content) // len(repeating_substring)
|
|
1733
|
+
if count >= char_threshold:
|
|
1734
|
+
return repeating_substring
|
|
1735
|
+
|
|
1736
|
+
# Priority 3: Line-level repetition (e.g., same line repeated many times)
|
|
1737
|
+
lines = [line.strip() for line in content.split("\n") if line.strip()]
|
|
1738
|
+
if not lines:
|
|
1739
|
+
return content
|
|
1740
|
+
total_lines = len(lines)
|
|
1741
|
+
if total_lines < line_threshold:
|
|
1742
|
+
return content
|
|
1743
|
+
line_counts = Counter(lines)
|
|
1744
|
+
most_common_line, count = line_counts.most_common(1)[0]
|
|
1745
|
+
if count >= line_threshold and (count / total_lines) >= 0.8:
|
|
1746
|
+
return most_common_line
|
|
1747
|
+
|
|
1748
|
+
return content
|
|
1749
|
+
|
|
1750
|
+
# ==========================================================================
|
|
1751
|
+
# [vendored] picture_response_validator
|
|
1752
|
+
# ==========================================================================
|
|
1753
|
+
"""
|
|
1754
|
+
Picture Response Validator
|
|
1755
|
+
|
|
1756
|
+
VLM에서 반환된 picture/chart/flowchart 응답의 유효성을 검증합니다.
|
|
1757
|
+
"""
|
|
1758
|
+
|
|
1759
|
+
import re
|
|
1760
|
+
from decimal import Decimal
|
|
1761
|
+
|
|
1762
|
+
# [vendor-strip] from loguru import logger
|
|
1763
|
+
|
|
1764
|
+
_SEPARATOR_PATTERN = re.compile(r"^\s*\|(?:\s*:?-+:?\s*\|)+\s*$")
|
|
1765
|
+
_KRW_EXPR_PATTERN = re.compile(
|
|
1766
|
+
r"(?P<expr>(?:\d[\d,]*\s*(?:조원|억원|만원|조|억|만|원)\s*)+)"
|
|
1767
|
+
)
|
|
1768
|
+
_KRW_SEGMENT_PATTERN = re.compile(r"(\d[\d,]*)\s*(조원|억원|만원|조|억|만|원)")
|
|
1769
|
+
|
|
1770
|
+
_KRW_UNIT_MULTIPLIERS = {
|
|
1771
|
+
"조원": 10**12,
|
|
1772
|
+
"억원": 10**8,
|
|
1773
|
+
"만원": 10**4,
|
|
1774
|
+
"조": 10**12,
|
|
1775
|
+
"억": 10**8,
|
|
1776
|
+
"만": 10**4,
|
|
1777
|
+
"원": 1,
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
_YEAR_HEADER_PATTERN = re.compile(r"^(year|년도|연도|年)$", re.IGNORECASE)
|
|
1781
|
+
_AMOUNT_HEADER_PATTERN = re.compile(
|
|
1782
|
+
r"(amount|value|금액|예산|budget|won|krw|억원|억\s*원|億ウォン|兆ウォン|trillion|billion|million)",
|
|
1783
|
+
re.IGNORECASE,
|
|
1784
|
+
)
|
|
1785
|
+
_PERCENT_PATTERN = re.compile(r"^\d[\d,]*(?:\.\d+)?%$")
|
|
1786
|
+
_PLAIN_NUMBER_PATTERN = re.compile(r"^\d[\d,]*(?:\.\d+)?$")
|
|
1787
|
+
_KRW_VALUE_PATTERN = re.compile(r"^KRW [\d,]+$")
|
|
1788
|
+
|
|
1789
|
+
|
|
1790
|
+
def split_markdown_table_row(line: str) -> list[str]:
|
|
1791
|
+
"""Split a Markdown table row whether the trailing pipe is present or not."""
|
|
1792
|
+
row = line.strip()
|
|
1793
|
+
if row.startswith("|"):
|
|
1794
|
+
row = row[1:]
|
|
1795
|
+
if row.endswith("|"):
|
|
1796
|
+
row = row[:-1]
|
|
1797
|
+
return [cell.strip() for cell in row.split("|")]
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
def normalize_markdown_table_content(content: str) -> str:
|
|
1801
|
+
"""
|
|
1802
|
+
차트/표 응답에서 흔히 발생하는 Markdown table 포맷 문제를 정규화합니다.
|
|
1803
|
+
|
|
1804
|
+
정규화 항목:
|
|
1805
|
+
1. 전각 파이프(|) -> ASCII 파이프(|)
|
|
1806
|
+
2. 헤더 구분선이 없는 경우 자동 삽입
|
|
1807
|
+
"""
|
|
1808
|
+
if not content or not isinstance(content, str):
|
|
1809
|
+
return content
|
|
1810
|
+
|
|
1811
|
+
normalized = content.replace("|", "|")
|
|
1812
|
+
lines = normalized.splitlines()
|
|
1813
|
+
table_line_indices = [
|
|
1814
|
+
idx for idx, line in enumerate(lines) if line.strip().startswith("|")
|
|
1815
|
+
]
|
|
1816
|
+
|
|
1817
|
+
if len(table_line_indices) < 2:
|
|
1818
|
+
return normalized
|
|
1819
|
+
|
|
1820
|
+
first_idx = table_line_indices[0]
|
|
1821
|
+
header_line = lines[first_idx].strip()
|
|
1822
|
+
|
|
1823
|
+
if first_idx + 1 < len(lines) and _SEPARATOR_PATTERN.match(
|
|
1824
|
+
lines[first_idx + 1].strip()
|
|
1825
|
+
):
|
|
1826
|
+
return normalized
|
|
1827
|
+
|
|
1828
|
+
header_cells = split_markdown_table_row(header_line)
|
|
1829
|
+
if not header_cells:
|
|
1830
|
+
return normalized
|
|
1831
|
+
|
|
1832
|
+
separator_line = "|" + "|".join([" --- "] * len(header_cells)) + "|"
|
|
1833
|
+
lines.insert(first_idx + 1, separator_line)
|
|
1834
|
+
return "\n".join(lines)
|
|
1835
|
+
|
|
1836
|
+
|
|
1837
|
+
def normalize_krw_expressions(content: str) -> str:
|
|
1838
|
+
"""
|
|
1839
|
+
한국식 금액 표기(조/억/만/원)를 full numeric KRW 형식으로 정규화합니다.
|
|
1840
|
+
|
|
1841
|
+
예:
|
|
1842
|
+
- 34조 7,398억원 -> KRW 34,739,800,000,000
|
|
1843
|
+
- 26,656억원 -> KRW 2,665,600,000,000
|
|
1844
|
+
"""
|
|
1845
|
+
if not content or not isinstance(content, str):
|
|
1846
|
+
return content
|
|
1847
|
+
|
|
1848
|
+
def _replace(match: re.Match[str]) -> str:
|
|
1849
|
+
expr = match.group("expr")
|
|
1850
|
+
total = 0
|
|
1851
|
+
found = False
|
|
1852
|
+
for raw_number, unit in _KRW_SEGMENT_PATTERN.findall(expr):
|
|
1853
|
+
found = True
|
|
1854
|
+
total += int(raw_number.replace(",", "")) * _KRW_UNIT_MULTIPLIERS[unit]
|
|
1855
|
+
|
|
1856
|
+
if not found:
|
|
1857
|
+
return expr
|
|
1858
|
+
return f"KRW {total:,}"
|
|
1859
|
+
|
|
1860
|
+
return _KRW_EXPR_PATTERN.sub(_replace, content)
|
|
1861
|
+
|
|
1862
|
+
|
|
1863
|
+
def normalize_chart_table_currency_values(content: str) -> str:
|
|
1864
|
+
"""
|
|
1865
|
+
차트 마크다운 테이블에서 금액 컬럼을 full numeric KRW 형식으로 정규화합니다.
|
|
1866
|
+
|
|
1867
|
+
전제:
|
|
1868
|
+
- 비한국어 chart 출력용 후처리
|
|
1869
|
+
- 헤더에 금액 단위 힌트가 있거나, 첫 컬럼이 연도이고 나머지 컬럼이 숫자 중심인 경우 적용
|
|
1870
|
+
"""
|
|
1871
|
+
if not content or not isinstance(content, str):
|
|
1872
|
+
return content
|
|
1873
|
+
|
|
1874
|
+
normalized = normalize_markdown_table_content(normalize_krw_expressions(content))
|
|
1875
|
+
lines = normalized.splitlines()
|
|
1876
|
+
table_lines = [line for line in lines if line.strip().startswith("|")]
|
|
1877
|
+
if len(table_lines) < 3:
|
|
1878
|
+
return normalized
|
|
1879
|
+
|
|
1880
|
+
rows = []
|
|
1881
|
+
for line in table_lines:
|
|
1882
|
+
cells = split_markdown_table_row(line)
|
|
1883
|
+
rows.append(cells)
|
|
1884
|
+
|
|
1885
|
+
header = rows[0]
|
|
1886
|
+
body_rows = rows[2:]
|
|
1887
|
+
if not header or not body_rows:
|
|
1888
|
+
return normalized
|
|
1889
|
+
|
|
1890
|
+
default_multiplier = 10**8
|
|
1891
|
+
column_multipliers: dict[int, int] = {}
|
|
1892
|
+
convert_columns: set[int] = set()
|
|
1893
|
+
for idx, header_cell in enumerate(header):
|
|
1894
|
+
if _AMOUNT_HEADER_PATTERN.search(header_cell):
|
|
1895
|
+
convert_columns.add(idx)
|
|
1896
|
+
# 배수를 컬럼별로 그 컬럼 헤더 단위에서 도출한다. 단일 변수를 덮어쓰면
|
|
1897
|
+
# 마지막 헤더 단위가 모든 컬럼을 지배해 혼합 단위 표가 오염된다 (이슈 #175).
|
|
1898
|
+
if re.search(r"(trillion|조|兆)", header_cell, re.IGNORECASE):
|
|
1899
|
+
column_multipliers[idx] = 10**12
|
|
1900
|
+
elif re.search(r"(billion|억|億)", header_cell, re.IGNORECASE):
|
|
1901
|
+
column_multipliers[idx] = 10**8
|
|
1902
|
+
elif re.search(r"(million|백만)", header_cell, re.IGNORECASE):
|
|
1903
|
+
column_multipliers[idx] = 10**6
|
|
1904
|
+
else:
|
|
1905
|
+
column_multipliers[idx] = default_multiplier
|
|
1906
|
+
|
|
1907
|
+
if not convert_columns and header and _YEAR_HEADER_PATTERN.search(header[0]):
|
|
1908
|
+
numeric_columns = set(range(1, len(header)))
|
|
1909
|
+
for row in body_rows:
|
|
1910
|
+
for idx in list(numeric_columns):
|
|
1911
|
+
if idx >= len(row):
|
|
1912
|
+
numeric_columns.discard(idx)
|
|
1913
|
+
continue
|
|
1914
|
+
cell = row[idx]
|
|
1915
|
+
if cell in ("", "-"):
|
|
1916
|
+
continue
|
|
1917
|
+
if cell.startswith("KRW "):
|
|
1918
|
+
continue
|
|
1919
|
+
if not _PLAIN_NUMBER_PATTERN.fullmatch(cell):
|
|
1920
|
+
numeric_columns.discard(idx)
|
|
1921
|
+
convert_columns = numeric_columns
|
|
1922
|
+
|
|
1923
|
+
if not convert_columns:
|
|
1924
|
+
return normalized
|
|
1925
|
+
|
|
1926
|
+
for row in body_rows:
|
|
1927
|
+
for idx in convert_columns:
|
|
1928
|
+
if idx >= len(row):
|
|
1929
|
+
continue
|
|
1930
|
+
cell = normalize_krw_expressions(row[idx]).strip()
|
|
1931
|
+
if not cell or cell == "-":
|
|
1932
|
+
row[idx] = cell
|
|
1933
|
+
continue
|
|
1934
|
+
if cell.startswith("KRW ") or _PERCENT_PATTERN.fullmatch(cell):
|
|
1935
|
+
row[idx] = cell
|
|
1936
|
+
continue
|
|
1937
|
+
if _PLAIN_NUMBER_PATTERN.fullmatch(cell):
|
|
1938
|
+
multiplier = column_multipliers.get(idx, default_multiplier)
|
|
1939
|
+
amount = int(Decimal(cell.replace(",", "")) * multiplier)
|
|
1940
|
+
row[idx] = f"KRW {amount:,}"
|
|
1941
|
+
else:
|
|
1942
|
+
row[idx] = cell
|
|
1943
|
+
|
|
1944
|
+
rebuilt_lines = []
|
|
1945
|
+
for line_idx, cells in enumerate(rows):
|
|
1946
|
+
if line_idx == 1:
|
|
1947
|
+
rebuilt_lines.append("| " + " | ".join(["---"] * len(header)) + " |")
|
|
1948
|
+
else:
|
|
1949
|
+
rebuilt_lines.append("| " + " | ".join(cells) + " |")
|
|
1950
|
+
|
|
1951
|
+
rebuilt_iter = iter(rebuilt_lines)
|
|
1952
|
+
output_lines = []
|
|
1953
|
+
for line in lines:
|
|
1954
|
+
if line.strip().startswith("|"):
|
|
1955
|
+
output_lines.append(next(rebuilt_iter))
|
|
1956
|
+
else:
|
|
1957
|
+
output_lines.append(line)
|
|
1958
|
+
return "\n".join(output_lines)
|
|
1959
|
+
|
|
1960
|
+
|
|
1961
|
+
def merge_translated_table_with_source_values(
|
|
1962
|
+
translated_content: str,
|
|
1963
|
+
source_content: str,
|
|
1964
|
+
) -> str:
|
|
1965
|
+
"""
|
|
1966
|
+
번역된 chart table의 라벨은 유지하고, 값 셀은 원본 정규화 값으로 덮어씁니다.
|
|
1967
|
+
|
|
1968
|
+
목적:
|
|
1969
|
+
- 비한국어 번역 시에도 KRW full numeric / 퍼센트 / 숫자 정밀도를 유지
|
|
1970
|
+
"""
|
|
1971
|
+
translated = normalize_markdown_table_content(translated_content)
|
|
1972
|
+
source = normalize_chart_table_currency_values(source_content)
|
|
1973
|
+
|
|
1974
|
+
translated_lines = [
|
|
1975
|
+
line for line in translated.splitlines() if line.strip().startswith("|")
|
|
1976
|
+
]
|
|
1977
|
+
source_lines = [
|
|
1978
|
+
line for line in source.splitlines() if line.strip().startswith("|")
|
|
1979
|
+
]
|
|
1980
|
+
|
|
1981
|
+
if len(translated_lines) < 3 or len(translated_lines) != len(source_lines):
|
|
1982
|
+
return translated
|
|
1983
|
+
|
|
1984
|
+
def _split(line: str) -> list[str]:
|
|
1985
|
+
return split_markdown_table_row(line)
|
|
1986
|
+
|
|
1987
|
+
translated_rows = [_split(line) for line in translated_lines]
|
|
1988
|
+
source_rows = [_split(line) for line in source_lines]
|
|
1989
|
+
|
|
1990
|
+
if not translated_rows or len(translated_rows[0]) != len(source_rows[0]):
|
|
1991
|
+
return translated
|
|
1992
|
+
|
|
1993
|
+
merged_rows: list[list[str]] = []
|
|
1994
|
+
for row_idx, (translated_row, source_row) in enumerate(
|
|
1995
|
+
zip(translated_rows, source_rows)
|
|
1996
|
+
):
|
|
1997
|
+
if row_idx == 1:
|
|
1998
|
+
merged_rows.append(["---"] * len(translated_rows[0]))
|
|
1999
|
+
continue
|
|
2000
|
+
|
|
2001
|
+
merged_row = []
|
|
2002
|
+
for translated_cell, source_cell in zip(translated_row, source_row):
|
|
2003
|
+
if (
|
|
2004
|
+
_KRW_VALUE_PATTERN.fullmatch(source_cell)
|
|
2005
|
+
or _PERCENT_PATTERN.fullmatch(source_cell)
|
|
2006
|
+
or _PLAIN_NUMBER_PATTERN.fullmatch(source_cell)
|
|
2007
|
+
):
|
|
2008
|
+
merged_row.append(source_cell)
|
|
2009
|
+
else:
|
|
2010
|
+
merged_row.append(translated_cell)
|
|
2011
|
+
merged_rows.append(merged_row)
|
|
2012
|
+
|
|
2013
|
+
merged_lines = ["| " + " | ".join(row) + " |" for row in merged_rows]
|
|
2014
|
+
merged_iter = iter(merged_lines)
|
|
2015
|
+
output_lines = []
|
|
2016
|
+
for line in translated.splitlines():
|
|
2017
|
+
if line.strip().startswith("|"):
|
|
2018
|
+
output_lines.append(next(merged_iter))
|
|
2019
|
+
else:
|
|
2020
|
+
output_lines.append(line)
|
|
2021
|
+
return "\n".join(output_lines)
|
|
2022
|
+
|
|
2023
|
+
|
|
2024
|
+
def is_valid_markdown_table(content: str) -> bool:
|
|
2025
|
+
"""
|
|
2026
|
+
마크다운 테이블이 유효한지 검증합니다.
|
|
2027
|
+
|
|
2028
|
+
검증 항목:
|
|
2029
|
+
1. 너무 많은 연속된 빈 셀 (| | | | 패턴)
|
|
2030
|
+
2. 테이블 구조의 무결성
|
|
2031
|
+
|
|
2032
|
+
Args:
|
|
2033
|
+
content: VLM에서 반환된 마크다운 테이블 문자열
|
|
2034
|
+
|
|
2035
|
+
Returns:
|
|
2036
|
+
bool: 유효하면 True, 깨진 테이블이면 False
|
|
2037
|
+
"""
|
|
2038
|
+
# 빈 내용이나 None은 통과 (chart/flow가 아닌 경우)
|
|
2039
|
+
if not content or not isinstance(content, str):
|
|
2040
|
+
return True
|
|
2041
|
+
|
|
2042
|
+
content = normalize_markdown_table_content(content)
|
|
2043
|
+
|
|
2044
|
+
# 빈 내용은 통과
|
|
2045
|
+
if not content.strip():
|
|
2046
|
+
return True
|
|
2047
|
+
|
|
2048
|
+
# 마크다운 테이블이 아닌 경우 통과 (일반 텍스트 등)
|
|
2049
|
+
if not content.strip().startswith("|"):
|
|
2050
|
+
return True
|
|
2051
|
+
|
|
2052
|
+
lines = content.strip().split("\n")
|
|
2053
|
+
table_lines = [line for line in lines if line.strip().startswith("|")]
|
|
2054
|
+
|
|
2055
|
+
if not table_lines:
|
|
2056
|
+
return True
|
|
2057
|
+
|
|
2058
|
+
# 1. 연속된 빈 셀 패턴 검사
|
|
2059
|
+
for line in table_lines:
|
|
2060
|
+
# 구분자 라인은 건너뜀 (|---|---|)
|
|
2061
|
+
if _SEPARATOR_PATTERN.search(line):
|
|
2062
|
+
continue
|
|
2063
|
+
|
|
2064
|
+
stripped_line = line.strip()
|
|
2065
|
+
|
|
2066
|
+
# 연속된 빈 셀 패턴 (| | | |)을 직접 검사
|
|
2067
|
+
# 빈 셀이 5개 이상 연속되면 깨진 테이블로 간주
|
|
2068
|
+
# 정상 테이블은 "| A | B |" 형태라 빈 셀이 2개 연속은 있을 수 있음
|
|
2069
|
+
if re.search(r"(\|\s+){5,}", stripped_line):
|
|
2070
|
+
# 실제로 빈 셀인지 확인 (공백만 있는지)
|
|
2071
|
+
consecutive_empty = re.findall(r"\|\s+(?=\|)", stripped_line)
|
|
2072
|
+
if len(consecutive_empty) >= 5:
|
|
2073
|
+
logger.warning(
|
|
2074
|
+
f"Detected corrupted table with {len(consecutive_empty)} consecutive empty cells: {line[:100]}..."
|
|
2075
|
+
)
|
|
2076
|
+
return False
|
|
2077
|
+
|
|
2078
|
+
return True
|
|
2079
|
+
|
|
2080
|
+
|
|
2081
|
+
# ---------------------------------------------------------------------------
|
|
2082
|
+
# Raw the model chart-token parser (chart pipeline fix)
|
|
2083
|
+
#
|
|
2084
|
+
# The the model base model, when prompted with "\nImage Analysis:" for a chart
|
|
2085
|
+
# region, emits its native classified format WITHOUT a JSON envelope, e.g.:
|
|
2086
|
+
#
|
|
2087
|
+
# <|class_start|>chart<|class_end|>
|
|
2088
|
+
# <|sub_class_start|>Bar Chart<|sub_class_end|>
|
|
2089
|
+
# <|caption_start|> ... <|caption_end|>
|
|
2090
|
+
# <|content_start|>| Country | Value |\n|---|---|\n| Netherlands | 215 |<|content_end|>
|
|
2091
|
+
#
|
|
2092
|
+
# These responses are neither valid JSON nor start with "|", so
|
|
2093
|
+
# parse_classified_picture_output() returns None and the plain-"|" fallback in
|
|
2094
|
+
# apply_picture_result() also misses them, leaving the raw token-wrapped block
|
|
2095
|
+
# to leak into the assembled markdown (sometimes HTML-escaped inside <p>..</p>,
|
|
2096
|
+
# or jammed into an image alt-text "![...]") where ChartDataPointRule's pandas
|
|
2097
|
+
# table parser cannot read it. This helper extracts (image_type, table) so the
|
|
2098
|
+
# chart table reaches the markdown as a clean, parseable table.
|
|
2099
|
+
# ---------------------------------------------------------------------------
|
|
2100
|
+
|
|
2101
|
+
import html as _html
|
|
2102
|
+
|
|
2103
|
+
_NATIVE_CLASS_PATTERN = re.compile(
|
|
2104
|
+
r"<\|class_start\|>\s*(?P<cls>[^<|]*?)\s*<\|class_end\|>", re.S
|
|
2105
|
+
)
|
|
2106
|
+
_NATIVE_CONTENT_PATTERN = re.compile(
|
|
2107
|
+
r"<\|content_start\|>(?P<body>.*?)<\|content_end\|>", re.S
|
|
2108
|
+
)
|
|
2109
|
+
_NATIVE_TOKEN_PATTERN = re.compile(r"<\|[^|>]*\|>")
|
|
2110
|
+
|
|
2111
|
+
|
|
2112
|
+
def parse_native_chart_tokens(content):
|
|
2113
|
+
"""Parse a raw the model classified picture/chart response.
|
|
2114
|
+
|
|
2115
|
+
Returns (image_type, table_text) when the response uses the the model
|
|
2116
|
+
<|...|> token format, else None (so callers fall through to their existing
|
|
2117
|
+
JSON / plain-markdown handling). table_text is the markdown table extracted
|
|
2118
|
+
from the <|content_start|>..<|content_end|> span with all special tokens
|
|
2119
|
+
stripped; image_type is the <|class_start|>..<|class_end|> class
|
|
2120
|
+
("chart"/"flow"/"picture"), defaulting to "picture".
|
|
2121
|
+
"""
|
|
2122
|
+
if not content or not isinstance(content, str):
|
|
2123
|
+
return None
|
|
2124
|
+
# Unescape HTML entities and drop wrapping image/paragraph syntax so the
|
|
2125
|
+
# token markers are visible (e.g. "![<|class_start|>..." or "<p><|..").
|
|
2126
|
+
text = _html.unescape(content)
|
|
2127
|
+
if "<|content_start|>" not in text and "<|class_start|>" not in text:
|
|
2128
|
+
return None
|
|
2129
|
+
|
|
2130
|
+
cls_match = _NATIVE_CLASS_PATTERN.search(text)
|
|
2131
|
+
raw_cls = cls_match.group("cls").strip().lower() if cls_match else ""
|
|
2132
|
+
if raw_cls.startswith("chart"):
|
|
2133
|
+
image_type = "chart"
|
|
2134
|
+
elif raw_cls.startswith("flow"):
|
|
2135
|
+
image_type = "flow"
|
|
2136
|
+
else:
|
|
2137
|
+
image_type = "picture"
|
|
2138
|
+
|
|
2139
|
+
body_match = _NATIVE_CONTENT_PATTERN.search(text)
|
|
2140
|
+
if body_match:
|
|
2141
|
+
table = body_match.group("body")
|
|
2142
|
+
else:
|
|
2143
|
+
# No explicit content span: strip tokens and keep whatever remains.
|
|
2144
|
+
table = text
|
|
2145
|
+
table = _NATIVE_TOKEN_PATTERN.sub("", table)
|
|
2146
|
+
# Drop residual image/paragraph wrapper artifacts.
|
|
2147
|
+
table = table.replace("<p>", "").replace("</p>", "")
|
|
2148
|
+
table = re.sub(r"^!\[", "", table.strip()).rstrip("]").strip()
|
|
2149
|
+
return image_type, table
|
|
2150
|
+
|
|
2151
|
+
# ==========================================================================
|
|
2152
|
+
# [vendored] table_processor(functions)
|
|
2153
|
+
# ==========================================================================
|
|
2154
|
+
"""
|
|
2155
|
+
TableElementPostProcessor
|
|
2156
|
+
|
|
2157
|
+
테이블 요소의 OTSL 태그를 HTML로 변환합니다.
|
|
2158
|
+
"""
|
|
2159
|
+
|
|
2160
|
+
import html
|
|
2161
|
+
import json
|
|
2162
|
+
import re
|
|
2163
|
+
from typing import Match
|
|
2164
|
+
|
|
2165
|
+
# [vendor-strip] from loguru import logger
|
|
2166
|
+
|
|
2167
|
+
# [vendor-strip] from ..schemas.element_schema import DocumentElement, ElementCategory
|
|
2168
|
+
# [vendor-strip] from ..utils.otsl_converter import convert_otsl_to_html_v2
|
|
2169
|
+
# [vendor-strip] from ..utils.truncate_repeat import truncate_repetitive_content
|
|
2170
|
+
# [vendor-strip] from .base import ElementPostProcessor
|
|
2171
|
+
|
|
2172
|
+
|
|
2173
|
+
def looks_like_otsl(content: str) -> bool:
|
|
2174
|
+
"""Return True when table content starts with wrapped or bare OTSL tokens."""
|
|
2175
|
+
return bool(
|
|
2176
|
+
content.lstrip().startswith(
|
|
2177
|
+
("<otsl>", "<fcel>", "<ecel>", "<lcel>", "<ucel>", "<xcel>", "<nl>")
|
|
2178
|
+
)
|
|
2179
|
+
)
|
|
2180
|
+
|
|
2181
|
+
|
|
2182
|
+
def looks_like_html_table(content: str) -> bool:
|
|
2183
|
+
"""Return True when content appears to contain an HTML table."""
|
|
2184
|
+
stripped = content.strip()
|
|
2185
|
+
if stripped.startswith("<table"):
|
|
2186
|
+
return True
|
|
2187
|
+
|
|
2188
|
+
# Relax detection for serialized/escaped table strings such as:
|
|
2189
|
+
# "\"<table ...>\"" or "<table rowspan=\\\"2\\\">".
|
|
2190
|
+
lowered = stripped.lower()
|
|
2191
|
+
return "<table" in lowered and any(
|
|
2192
|
+
marker in lowered for marker in ("rowspan", "colspan", "<tr", "<td")
|
|
2193
|
+
)
|
|
2194
|
+
|
|
2195
|
+
|
|
2196
|
+
def normalize_span_attributes(content: str) -> str:
|
|
2197
|
+
"""rowspan/colspan 속성값의 따옴표를 제거해 출력 형식을 통일한다."""
|
|
2198
|
+
return re.sub(
|
|
2199
|
+
r'\b(rowspan|colspan)\s*=\s*(?:"|\')?(\d+)(?:"|\')?',
|
|
2200
|
+
r"\1=\2",
|
|
2201
|
+
content,
|
|
2202
|
+
flags=re.IGNORECASE,
|
|
2203
|
+
)
|
|
2204
|
+
|
|
2205
|
+
|
|
2206
|
+
def normalize_html_table_content(content: str) -> str:
|
|
2207
|
+
"""Normalize escaped quotes and wrappers in HTML table content.
|
|
2208
|
+
|
|
2209
|
+
NOTE: provider가 직접 반환한 HTML(escaped quote, JSON 문자열 래핑, code fence 등)
|
|
2210
|
+
정규화용이다. ``html.unescape``를 포함하므로, 이미 ``html.escape`` 된 출력
|
|
2211
|
+
(예: ``convert_otsl_to_html_v2`` 결과)에는 적용하면 안 된다. 그 경로에서는
|
|
2212
|
+
``normalize_span_attributes``만 사용한다 (이슈 #172).
|
|
2213
|
+
"""
|
|
2214
|
+
normalized = content.strip()
|
|
2215
|
+
|
|
2216
|
+
# Some providers return HTML table content as a JSON string literal.
|
|
2217
|
+
# Unwrap it repeatedly while it still looks like a serialized string.
|
|
2218
|
+
for _ in range(3):
|
|
2219
|
+
if len(normalized) < 2 or normalized[0] != '"' or normalized[-1] != '"':
|
|
2220
|
+
break
|
|
2221
|
+
try:
|
|
2222
|
+
parsed = json.loads(normalized)
|
|
2223
|
+
except json.JSONDecodeError:
|
|
2224
|
+
break
|
|
2225
|
+
if not isinstance(parsed, str):
|
|
2226
|
+
break
|
|
2227
|
+
normalized = parsed.strip()
|
|
2228
|
+
|
|
2229
|
+
normalized = html.unescape(normalized)
|
|
2230
|
+
normalized = normalized.replace("\\u0022", '"').replace("\\n", "")
|
|
2231
|
+
normalized = re.sub(r'\\+(")', '"', normalized)
|
|
2232
|
+
normalized = re.sub(r"\\+(/)", r"\1", normalized)
|
|
2233
|
+
normalized = re.sub(
|
|
2234
|
+
r"^\s*```(?:html)?\s*(.*?)\s*```\s*$",
|
|
2235
|
+
r"\1",
|
|
2236
|
+
normalized,
|
|
2237
|
+
flags=re.IGNORECASE | re.DOTALL,
|
|
2238
|
+
)
|
|
2239
|
+
|
|
2240
|
+
stripped = normalized.strip()
|
|
2241
|
+
if len(stripped) >= 2 and stripped[0] == stripped[-1] == '"':
|
|
2242
|
+
inner = stripped[1:-1].strip()
|
|
2243
|
+
if inner.startswith("<table"):
|
|
2244
|
+
normalized = inner
|
|
2245
|
+
|
|
2246
|
+
return normalize_span_attributes(normalized)
|
|
2247
|
+
|
|
2248
|
+
|
|
2249
|
+
def remove_dots_from_html_cells(html_content: str) -> str:
|
|
2250
|
+
"""
|
|
2251
|
+
HTML 테이블의 <td> 셀에서 과도한 점 패턴을 제거합니다.
|
|
2252
|
+
|
|
2253
|
+
truncate_repeat.py의 로직을 참고하여 점 5개 이상 연속된 패턴을 제거합니다.
|
|
2254
|
+
ASCII dot(.) 또는 Unicode ellipsis(…, U+2026)를 처리합니다.
|
|
2255
|
+
|
|
2256
|
+
Args:
|
|
2257
|
+
html_content: HTML 테이블 문자열
|
|
2258
|
+
|
|
2259
|
+
Returns:
|
|
2260
|
+
str: 점 패턴이 제거된 HTML 테이블
|
|
2261
|
+
"""
|
|
2262
|
+
if not html_content or not isinstance(html_content, str):
|
|
2263
|
+
return html_content
|
|
2264
|
+
|
|
2265
|
+
def clean_dots_in_td(match: Match[str]) -> str:
|
|
2266
|
+
attrs = match.group(1) # 태그 속성
|
|
2267
|
+
td_content = match.group(2) # 셀 내용
|
|
2268
|
+
original = td_content
|
|
2269
|
+
|
|
2270
|
+
# <td> 태그 내부의 텍스트에서 점 패턴 제거
|
|
2271
|
+
# ASCII dot(.) 또는 Unicode ellipsis(…, U+2026)가 5개 이상 연속되면 제거
|
|
2272
|
+
td_content = re.sub(r"(\.|\u2026|\u00B7){5,}", "", td_content)
|
|
2273
|
+
|
|
2274
|
+
# 중간생략문자(···) 같은 패턴도 제거 (U+00B7은 middle dot)
|
|
2275
|
+
td_content = re.sub(r"(\u00B7){5,}", "", td_content)
|
|
2276
|
+
|
|
2277
|
+
if td_content != original:
|
|
2278
|
+
logger.debug("Removed dot patterns from TD cell")
|
|
2279
|
+
|
|
2280
|
+
return f"<td{attrs}>{td_content}</td>"
|
|
2281
|
+
|
|
2282
|
+
# <td>...</td> 패턴을 찾아서 처리 (태그 속성도 고려)
|
|
2283
|
+
result = re.sub(
|
|
2284
|
+
r"<td([^>]*)>(.*?)</td>", clean_dots_in_td, html_content, flags=re.DOTALL
|
|
2285
|
+
)
|
|
2286
|
+
|
|
2287
|
+
return result
|
|
2288
|
+
|
|
2289
|
+
|
|
2290
|
+
|
|
2291
|
+
# ==========================================================================
|
|
2292
|
+
# [vendored] chart_processor(functions)
|
|
2293
|
+
# ==========================================================================
|
|
2294
|
+
"""
|
|
2295
|
+
DataChartElementPostProcessor
|
|
2296
|
+
|
|
2297
|
+
차트 요소를 후처리합니다 (현재는 bypass).
|
|
2298
|
+
"""
|
|
2299
|
+
|
|
2300
|
+
import html
|
|
2301
|
+
import re
|
|
2302
|
+
|
|
2303
|
+
import markdown
|
|
2304
|
+
# [vendor-strip] from loguru import logger
|
|
2305
|
+
|
|
2306
|
+
# [vendor-strip] from ..schemas.element_schema import DocumentElement, ElementCategory
|
|
2307
|
+
# [vendor-strip] from ..utils.picture_response_validator import normalize_markdown_table_content
|
|
2308
|
+
# [vendor-strip] from .base import ElementPostProcessor
|
|
2309
|
+
|
|
2310
|
+
_SEPARATOR_ROW_PATTERN = re.compile(r"\|\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)+\|")
|
|
2311
|
+
|
|
2312
|
+
|
|
2313
|
+
def _extract_pipe_row(segment: str, expected_pipe_count: int) -> tuple[str, str]:
|
|
2314
|
+
"""Extract a single markdown row from a flattened pipe-table segment."""
|
|
2315
|
+
stripped = segment.lstrip()
|
|
2316
|
+
if not stripped.startswith("|"):
|
|
2317
|
+
return "", segment
|
|
2318
|
+
|
|
2319
|
+
pipe_count = 0
|
|
2320
|
+
for index, char in enumerate(stripped):
|
|
2321
|
+
if char == "|":
|
|
2322
|
+
pipe_count += 1
|
|
2323
|
+
if pipe_count == expected_pipe_count:
|
|
2324
|
+
row = stripped[: index + 1].strip()
|
|
2325
|
+
remaining = stripped[index + 1 :].lstrip()
|
|
2326
|
+
return row, remaining
|
|
2327
|
+
|
|
2328
|
+
return stripped.strip(), ""
|
|
2329
|
+
|
|
2330
|
+
|
|
2331
|
+
def normalize_inline_markdown_table(content: str) -> str:
|
|
2332
|
+
"""Split a single-line pipe table into markdown rows before HTML conversion."""
|
|
2333
|
+
normalized = normalize_markdown_table_content(content)
|
|
2334
|
+
if "\n" in normalized or not normalized.strip().startswith("|"):
|
|
2335
|
+
return normalized
|
|
2336
|
+
|
|
2337
|
+
separator_match = _SEPARATOR_ROW_PATTERN.search(normalized)
|
|
2338
|
+
if not separator_match:
|
|
2339
|
+
return normalized
|
|
2340
|
+
|
|
2341
|
+
separator_row = separator_match.group(0).strip()
|
|
2342
|
+
expected_pipe_count = separator_row.count("|")
|
|
2343
|
+
if expected_pipe_count < 2:
|
|
2344
|
+
return normalized
|
|
2345
|
+
|
|
2346
|
+
header_segment = normalized[: separator_match.start()].strip()
|
|
2347
|
+
data_segment = normalized[separator_match.end() :].strip()
|
|
2348
|
+
|
|
2349
|
+
header_row, leftover_header = _extract_pipe_row(header_segment, expected_pipe_count)
|
|
2350
|
+
if not header_row or leftover_header.strip():
|
|
2351
|
+
return normalized
|
|
2352
|
+
|
|
2353
|
+
rows = [header_row, separator_row]
|
|
2354
|
+
remaining = data_segment
|
|
2355
|
+
while remaining:
|
|
2356
|
+
row, remaining = _extract_pipe_row(remaining, expected_pipe_count)
|
|
2357
|
+
if not row:
|
|
2358
|
+
break
|
|
2359
|
+
rows.append(row)
|
|
2360
|
+
|
|
2361
|
+
return "\n".join(rows) if len(rows) >= 2 else normalized
|
|
2362
|
+
|
|
2363
|
+
|
|
2364
|
+
|
|
2365
|
+
# ==========================================================================
|
|
2366
|
+
# [vendored] kdl_postprocess
|
|
2367
|
+
# ==========================================================================
|
|
2368
|
+
"""Pure-rule markdown postprocessing for the ``kdl_frontier`` mode.
|
|
2369
|
+
|
|
2370
|
+
These are GT-agnostic, structure-preserving text-surface rewrites applied to the
|
|
2371
|
+
fully-assembled document markdown right before the parse response is returned. They
|
|
2372
|
+
are the production port of the kdl-frontier-parser prototype's ``_apply_stack`` rules
|
|
2373
|
+
(validated on ParseBench: header-marking +0.0156 5D, title+quote +0.0132 5D).
|
|
2374
|
+
|
|
2375
|
+
Three rules, applied in this exact order (matching the prototype):
|
|
2376
|
+
1. header_mark - mark a table's auto-detected multi-level header region as ``<th>``.
|
|
2377
|
+
2. quote_fold - fold curly quotes/apostrophes to ASCII (SemFormat literal-match fix).
|
|
2378
|
+
3. title_promote- de-blockquote + promote standalone title-ish lines to ``# ``.
|
|
2379
|
+
|
|
2380
|
+
All three are idempotent and never reorder / merge / split elements, so document
|
|
2381
|
+
STRUCTURE (and TEDS-Structure) is unchanged. Stdlib-only (no model, no I/O).
|
|
2382
|
+
"""
|
|
2383
|
+
|
|
2384
|
+
# [vendor-strip] from __future__ import annotations
|
|
2385
|
+
|
|
2386
|
+
import re
|
|
2387
|
+
from collections import Counter
|
|
2388
|
+
|
|
2389
|
+
# --------------------------------------------------------------------------------------
|
|
2390
|
+
# Rule 1: multi-level table-header marking (port of header_transform.transform_markdown)
|
|
2391
|
+
# --------------------------------------------------------------------------------------
|
|
2392
|
+
_TABLE_RE = re.compile(r"<table\b[^>]*>.*?</table>", re.I | re.S)
|
|
2393
|
+
_TR_RE = re.compile(r"(<tr\b[^>]*>.*?</tr>)", re.I | re.S)
|
|
2394
|
+
_COLSPAN_RE = re.compile(r"colspan\s*=\s*[\"']?([2-9]|\d\d)", re.I)
|
|
2395
|
+
|
|
2396
|
+
|
|
2397
|
+
def _row_has_colspan(tr: str) -> bool:
|
|
2398
|
+
return bool(_COLSPAN_RE.search(tr))
|
|
2399
|
+
|
|
2400
|
+
|
|
2401
|
+
def _auto_header_n(table_html: str) -> int:
|
|
2402
|
+
"""Number of leading rows that form the header: contiguous colspan>1 rows + 1 leaf row.
|
|
2403
|
+
|
|
2404
|
+
Single-level tables (no colspan>1 in row 0) -> 0 (first-row marking was net-negative).
|
|
2405
|
+
"""
|
|
2406
|
+
trs = _TR_RE.findall(table_html)
|
|
2407
|
+
if not trs or not _row_has_colspan(trs[0]):
|
|
2408
|
+
return 0
|
|
2409
|
+
n = 0
|
|
2410
|
+
for tr in trs:
|
|
2411
|
+
if _row_has_colspan(tr):
|
|
2412
|
+
n += 1
|
|
2413
|
+
else:
|
|
2414
|
+
break
|
|
2415
|
+
return min(n + 1, len(trs))
|
|
2416
|
+
|
|
2417
|
+
|
|
2418
|
+
def _mark_n(table_html: str, n: int) -> str:
|
|
2419
|
+
out = table_html
|
|
2420
|
+
for tr in _TR_RE.findall(table_html)[:n]:
|
|
2421
|
+
m = re.sub(r"<td(\s[^>]*)?>", r"<th\1>", tr, flags=re.I)
|
|
2422
|
+
m = re.sub(r"</td>", "</th>", m, flags=re.I)
|
|
2423
|
+
out = out.replace(tr, m, 1)
|
|
2424
|
+
return out
|
|
2425
|
+
|
|
2426
|
+
|
|
2427
|
+
def header_mark(md: str) -> str:
|
|
2428
|
+
"""Mark auto-detected multi-level header rows of every <table> block as <th>."""
|
|
2429
|
+
if not md:
|
|
2430
|
+
return md
|
|
2431
|
+
|
|
2432
|
+
def _repl(match: "re.Match[str]") -> str:
|
|
2433
|
+
table_html = match.group(0)
|
|
2434
|
+
n = _auto_header_n(table_html)
|
|
2435
|
+
if n >= 1:
|
|
2436
|
+
marked = _mark_n(table_html, n)
|
|
2437
|
+
if marked != table_html:
|
|
2438
|
+
return marked
|
|
2439
|
+
return table_html
|
|
2440
|
+
|
|
2441
|
+
return _TABLE_RE.sub(_repl, md)
|
|
2442
|
+
|
|
2443
|
+
|
|
2444
|
+
# --------------------------------------------------------------------------------------
|
|
2445
|
+
# Rule 2: curly-quote fold (port of wf_rule_4_transform.fold_md)
|
|
2446
|
+
# --------------------------------------------------------------------------------------
|
|
2447
|
+
_QUOTE_FOLD = {
|
|
2448
|
+
"‘": "'", "’": "'", "‚": "'", "‛": "'", # single quotes
|
|
2449
|
+
"“": '"', "”": '"', "„": '"', "‟": '"', # double quotes
|
|
2450
|
+
}
|
|
2451
|
+
_QUOTE_TABLE = str.maketrans(_QUOTE_FOLD)
|
|
2452
|
+
|
|
2453
|
+
|
|
2454
|
+
def quote_fold(md: str) -> str:
|
|
2455
|
+
"""Fold curly quotes/apostrophes to ASCII (regression-free; dash/space fold excluded)."""
|
|
2456
|
+
if not md:
|
|
2457
|
+
return md
|
|
2458
|
+
return md.translate(_QUOTE_TABLE)
|
|
2459
|
+
|
|
2460
|
+
|
|
2461
|
+
# --------------------------------------------------------------------------------------
|
|
2462
|
+
# Rule 3: title promotion + de-blockquote (port of wf_rule_0_transform.transform_markdown)
|
|
2463
|
+
# --------------------------------------------------------------------------------------
|
|
2464
|
+
_HEADING_RE = re.compile(r"^\s*#{1,6}\s+")
|
|
2465
|
+
_LIST_RE = re.compile(r"^\s*[-*+]\s+")
|
|
2466
|
+
_NUMLIST_RE = re.compile(r"^\s*\d+\.\s+")
|
|
2467
|
+
_TABLEROW_RE = re.compile(r"^\s*\|")
|
|
2468
|
+
_LABELVALUE_RE = re.compile(r"^.{1,40}:\s")
|
|
2469
|
+
_BLOCKQUOTE_RE = re.compile(r"^\s*>\s+(.+)$")
|
|
2470
|
+
_FENCE_RE = re.compile(r"^\s*(```|~~~)")
|
|
2471
|
+
_TERMINAL_PUNCT = tuple(".!?:;,")
|
|
2472
|
+
_LETTER_RE = re.compile(r"[A-Za-z]")
|
|
2473
|
+
_UPPER_RE = re.compile(r"[A-Z]")
|
|
2474
|
+
# Multi-page docs inject "---\n\n**Page N**" separators (response_builder._build_full_markdown);
|
|
2475
|
+
# never promote those to headings.
|
|
2476
|
+
_PAGE_MARKER_RE = re.compile(r"^\*\*Page\s+\d+\*\*$")
|
|
2477
|
+
|
|
2478
|
+
# Shipped variant params (verbatim from the prototype). "aggressive" is the chosen one
|
|
2479
|
+
# (conservative + defensible vs ultra2; 5D delta vs ultra2 is ~ -0.0008).
|
|
2480
|
+
_TITLE_VARIANTS = {
|
|
2481
|
+
"aggressive": (12, 0.60, False),
|
|
2482
|
+
"strict_caps_bq": (8, 0.85, True),
|
|
2483
|
+
"deblockquote_only": (12, 0.60, False),
|
|
2484
|
+
"ultra": (22, 0.25, False),
|
|
2485
|
+
"ultra2": (30, 0.0, False),
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
|
|
2489
|
+
def _is_titleish(text: str, max_words: int, caps_ratio: float, require_all_caps: bool) -> bool:
|
|
2490
|
+
s = text.strip()
|
|
2491
|
+
if not s:
|
|
2492
|
+
return False
|
|
2493
|
+
if _HEADING_RE.match(s) or _LIST_RE.match(s) or _NUMLIST_RE.match(s) or _TABLEROW_RE.match(s):
|
|
2494
|
+
return False
|
|
2495
|
+
if len(s.split()) > max_words:
|
|
2496
|
+
return False
|
|
2497
|
+
if s.endswith(_TERMINAL_PUNCT):
|
|
2498
|
+
return False
|
|
2499
|
+
if _LABELVALUE_RE.match(s):
|
|
2500
|
+
return False
|
|
2501
|
+
letters = _LETTER_RE.findall(s)
|
|
2502
|
+
if not letters:
|
|
2503
|
+
return False
|
|
2504
|
+
caps_frac = len(_UPPER_RE.findall(s)) / len(letters)
|
|
2505
|
+
if require_all_caps:
|
|
2506
|
+
return caps_frac >= caps_ratio
|
|
2507
|
+
first_alpha = next((c for c in s if c.isalpha()), "")
|
|
2508
|
+
return first_alpha.isupper() or caps_frac > caps_ratio
|
|
2509
|
+
|
|
2510
|
+
|
|
2511
|
+
def title_promote(md: str, variant: str = "aggressive") -> str:
|
|
2512
|
+
"""De-blockquote and promote standalone title-ish lines to ``# `` headings."""
|
|
2513
|
+
if not md:
|
|
2514
|
+
return md
|
|
2515
|
+
max_words, caps_ratio, require_all_caps = _TITLE_VARIANTS[variant]
|
|
2516
|
+
do_promote = variant != "deblockquote_only"
|
|
2517
|
+
|
|
2518
|
+
lines = md.split("\n")
|
|
2519
|
+
n = len(lines)
|
|
2520
|
+
out = list(lines)
|
|
2521
|
+
in_fence = False
|
|
2522
|
+
for i, raw in enumerate(lines):
|
|
2523
|
+
if _FENCE_RE.match(raw):
|
|
2524
|
+
in_fence = not in_fence
|
|
2525
|
+
continue
|
|
2526
|
+
if in_fence:
|
|
2527
|
+
continue
|
|
2528
|
+
above = lines[i - 1] if i > 0 else ""
|
|
2529
|
+
below = lines[i + 1] if i + 1 < n else ""
|
|
2530
|
+
standalone = (i == 0 or above.strip() == "") and (i + 1 >= n or below.strip() == "")
|
|
2531
|
+
if not standalone:
|
|
2532
|
+
continue
|
|
2533
|
+
if _PAGE_MARKER_RE.match(raw.strip()):
|
|
2534
|
+
continue
|
|
2535
|
+
bq = _BLOCKQUOTE_RE.match(raw)
|
|
2536
|
+
if bq:
|
|
2537
|
+
inner = bq.group(1)
|
|
2538
|
+
if _is_titleish(inner, max_words, caps_ratio, require_all_caps):
|
|
2539
|
+
out[i] = ("# " + inner.strip()) if do_promote else inner.strip()
|
|
2540
|
+
continue
|
|
2541
|
+
if do_promote and _is_titleish(raw, max_words, caps_ratio, require_all_caps):
|
|
2542
|
+
out[i] = "# " + raw.strip()
|
|
2543
|
+
return "\n".join(out)
|
|
2544
|
+
|
|
2545
|
+
|
|
2546
|
+
# --------------------------------------------------------------------------------------
|
|
2547
|
+
# Public entry point
|
|
2548
|
+
# --------------------------------------------------------------------------------------
|
|
2549
|
+
# Guard: skip rewriting pathologically large/repetitive markdown (avoids catastrophic
|
|
2550
|
+
# regex cost on runaway model output). Generation already uses no_repeat_ngram_size, so
|
|
2551
|
+
# this should rarely trigger; when it does we return the markdown UNCHANGED (never drop
|
|
2552
|
+
# content -> structure stays intact).
|
|
2553
|
+
_MAX_MD_LEN = 2_000_000
|
|
2554
|
+
|
|
2555
|
+
|
|
2556
|
+
def _looks_runaway(md: str) -> bool:
|
|
2557
|
+
if len(md) > _MAX_MD_LEN:
|
|
2558
|
+
return True
|
|
2559
|
+
lines = [ln.strip() for ln in md.splitlines() if ln.strip()]
|
|
2560
|
+
if len(lines) > 200:
|
|
2561
|
+
top = Counter(lines).most_common(1)
|
|
2562
|
+
if top and top[0][1] > 1000:
|
|
2563
|
+
return True
|
|
2564
|
+
return False
|
|
2565
|
+
|
|
2566
|
+
|
|
2567
|
+
def postprocess_markdown(md: str, *, title_variant: str = "aggressive") -> str:
|
|
2568
|
+
"""Apply the kdl-frontier pure-rule stack to assembled document markdown.
|
|
2569
|
+
|
|
2570
|
+
Order: header_mark -> quote_fold -> title_promote(variant). Each rule is wrapped so a
|
|
2571
|
+
failure in one never discards the document; on a runaway document all rules are skipped.
|
|
2572
|
+
Returns the rewritten markdown (or the input unchanged on guard/failure).
|
|
2573
|
+
"""
|
|
2574
|
+
if not md or _looks_runaway(md):
|
|
2575
|
+
return md
|
|
2576
|
+
try:
|
|
2577
|
+
md = header_mark(md)
|
|
2578
|
+
except Exception: # noqa: BLE001 - never fail the response on a postprocess rule
|
|
2579
|
+
pass
|
|
2580
|
+
try:
|
|
2581
|
+
md = quote_fold(md)
|
|
2582
|
+
md = title_promote(md, variant=title_variant)
|
|
2583
|
+
except Exception: # noqa: BLE001
|
|
2584
|
+
pass
|
|
2585
|
+
return md
|
|
2586
|
+
|
|
2587
|
+
# ==========================================================================
|
|
2588
|
+
# [core] KDL-Frontier-Parser-nano orchestration
|
|
2589
|
+
# ==========================================================================
|
|
2590
|
+
# Faithful port of the closed-source orchestrator's inference flow for the
|
|
2591
|
+
# submitted run. Stage prompts/params and every deterministic transform above
|
|
2592
|
+
# are vendored verbatim; this section wires them together:
|
|
2593
|
+
# render -> layout (1036x1036) -> crop/bucket -> per-category recognition
|
|
2594
|
+
# -> element post-processing -> markdown assembly -> rule post-processing.
|
|
2595
|
+
|
|
2596
|
+
_NANO_PROMPTS = {
|
|
2597
|
+
# byte-exact stage prompts (leading newline included; formula has no
|
|
2598
|
+
# trailing newline) — these are the templates the run was measured with.
|
|
2599
|
+
"layout": "\nLayout Detection:\n",
|
|
2600
|
+
"text": "\nText Recognition:\n",
|
|
2601
|
+
"table": "\nTable Recognition:\n",
|
|
2602
|
+
"picture": "\nImage Analysis:\n",
|
|
2603
|
+
"formula": "\nFormula Recognition:",
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
_NANO_EXTRA_PAYLOAD = {
|
|
2607
|
+
# merged TOP-LEVEL into the chat/completions body (vLLM accepts these).
|
|
2608
|
+
"layout": {
|
|
2609
|
+
"skip_special_tokens": False,
|
|
2610
|
+
"top_p": 0.01,
|
|
2611
|
+
"top_k": 1,
|
|
2612
|
+
"presence_penalty": 0.0,
|
|
2613
|
+
"frequency_penalty": 0.0,
|
|
2614
|
+
"repetition_penalty": 1.0,
|
|
2615
|
+
"extra_args": {"no_repeat_ngram_size": 100},
|
|
2616
|
+
},
|
|
2617
|
+
"text": {
|
|
2618
|
+
"top_p": 0.01,
|
|
2619
|
+
"top_k": 1,
|
|
2620
|
+
"repetition_penalty": 1.0,
|
|
2621
|
+
"extra_args": {"no_repeat_ngram_size": 100},
|
|
2622
|
+
},
|
|
2623
|
+
"table": {
|
|
2624
|
+
"skip_special_tokens": False,
|
|
2625
|
+
"top_p": 0.01,
|
|
2626
|
+
"top_k": 1,
|
|
2627
|
+
"presence_penalty": 1.0,
|
|
2628
|
+
"frequency_penalty": 0.005,
|
|
2629
|
+
"repetition_penalty": 1.0,
|
|
2630
|
+
"extra_args": {"no_repeat_ngram_size": 100},
|
|
2631
|
+
},
|
|
2632
|
+
"picture": {
|
|
2633
|
+
"top_p": 0.01,
|
|
2634
|
+
"top_k": 1,
|
|
2635
|
+
"repetition_penalty": 1.0,
|
|
2636
|
+
"extra_args": {"no_repeat_ngram_size": 100},
|
|
2637
|
+
},
|
|
2638
|
+
"formula": None, # formula stage sends temperature+max_tokens only
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
_NANO_MAX_TOKENS_ENV = {
|
|
2642
|
+
# submitted-run budgets (layout/table must fit --max-model-len 8192,
|
|
2643
|
+
# otherwise long pages return HTTP 400 -> empty layout)
|
|
2644
|
+
"layout": ("KDL_NANO_LAYOUT_MAX_TOKENS", 6000),
|
|
2645
|
+
"text": ("KDL_NANO_TEXT_MAX_TOKENS", 2048),
|
|
2646
|
+
"table": ("KDL_NANO_TABLE_MAX_TOKENS", 5500),
|
|
2647
|
+
"picture": ("KDL_NANO_PICTURE_MAX_TOKENS", 4096),
|
|
2648
|
+
"formula": ("KDL_NANO_FORMULA_MAX_TOKENS", 128),
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
# table crops are encoded lossless PNG (JPEG q95 collapses multi-line cells);
|
|
2652
|
+
# every other stage uses JPEG quality=95.
|
|
2653
|
+
_NANO_LOSSLESS_STAGES = {"table"}
|
|
2654
|
+
|
|
2655
|
+
_TEXT_BUCKET_CATEGORIES = {
|
|
2656
|
+
# TextElementPostProcessor.TEXT_CATEGORIES (n-gram repetition cleanup)
|
|
2657
|
+
"Title", "Section-header", "Text", "Page-header",
|
|
2658
|
+
"Page-footer", "List-item", "Caption", "Footnote",
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
_BLOCKQUOTE_CATEGORIES = {"Caption", "Footnote", "Page-header", "Page-footer"}
|
|
2662
|
+
|
|
2663
|
+
|
|
2664
|
+
def _nano_image_to_data_uri(image: Image.Image, *, lossless: bool = False) -> str:
|
|
2665
|
+
"""Port of message_builder.image_to_bytes + base64 data-URI encoding."""
|
|
2666
|
+
buffered = io.BytesIO()
|
|
2667
|
+
if (
|
|
2668
|
+
lossless
|
|
2669
|
+
or image.mode in ("RGBA", "LA")
|
|
2670
|
+
or (image.mode == "P" and "transparency" in image.info)
|
|
2671
|
+
):
|
|
2672
|
+
mime_type = "image/png"
|
|
2673
|
+
image.save(buffered, format="PNG")
|
|
2674
|
+
else:
|
|
2675
|
+
if image.mode not in ("L", "RGB", "CMYK"):
|
|
2676
|
+
image = image.convert("RGB")
|
|
2677
|
+
mime_type = "image/jpeg"
|
|
2678
|
+
image.save(buffered, format="JPEG", quality=95)
|
|
2679
|
+
b64 = base64.b64encode(buffered.getvalue()).decode("ascii")
|
|
2680
|
+
return f"data:{mime_type};base64,{b64}"
|
|
2681
|
+
|
|
2682
|
+
|
|
2683
|
+
def _nano_payload(stage: str, model: str, image: Image.Image) -> dict:
|
|
2684
|
+
payload = {
|
|
2685
|
+
"model": model,
|
|
2686
|
+
"messages": [
|
|
2687
|
+
{
|
|
2688
|
+
"role": "user",
|
|
2689
|
+
"content": [
|
|
2690
|
+
{
|
|
2691
|
+
"type": "image_url",
|
|
2692
|
+
"image_url": {
|
|
2693
|
+
"url": _nano_image_to_data_uri(
|
|
2694
|
+
image, lossless=stage in _NANO_LOSSLESS_STAGES
|
|
2695
|
+
)
|
|
2696
|
+
},
|
|
2697
|
+
},
|
|
2698
|
+
{"type": "text", "text": _NANO_PROMPTS[stage]},
|
|
2699
|
+
],
|
|
2700
|
+
}
|
|
2701
|
+
],
|
|
2702
|
+
"temperature": 0.0,
|
|
2703
|
+
"max_tokens": int(
|
|
2704
|
+
os.getenv(_NANO_MAX_TOKENS_ENV[stage][0], str(_NANO_MAX_TOKENS_ENV[stage][1]))
|
|
2705
|
+
),
|
|
2706
|
+
}
|
|
2707
|
+
extra = _NANO_EXTRA_PAYLOAD[stage]
|
|
2708
|
+
if extra is not None:
|
|
2709
|
+
payload.update(extra)
|
|
2710
|
+
return payload
|
|
2711
|
+
|
|
2712
|
+
|
|
2713
|
+
async def _nano_chat(
|
|
2714
|
+
client: httpx.AsyncClient,
|
|
2715
|
+
url: str,
|
|
2716
|
+
payload: dict,
|
|
2717
|
+
semaphore: asyncio.Semaphore,
|
|
2718
|
+
) -> str | None:
|
|
2719
|
+
"""POST a chat/completions request. Returns content, or None on failure
|
|
2720
|
+
(the orchestrator keeps failed elements with content='')."""
|
|
2721
|
+
last_exc: Exception | None = None
|
|
2722
|
+
async with semaphore:
|
|
2723
|
+
for attempt in range(3): # tenacity stop_after_attempt(3) equivalent
|
|
2724
|
+
try:
|
|
2725
|
+
resp = await client.post(
|
|
2726
|
+
url,
|
|
2727
|
+
json={**payload, "chat_template_kwargs": {"enable_thinking": False}},
|
|
2728
|
+
)
|
|
2729
|
+
if resp.status_code >= 500:
|
|
2730
|
+
raise httpx.HTTPStatusError(
|
|
2731
|
+
f"{resp.status_code}", request=resp.request, response=resp
|
|
2732
|
+
)
|
|
2733
|
+
if resp.status_code >= 400:
|
|
2734
|
+
logger.warning("4xx from endpoint (not retried): %s", resp.text[:200])
|
|
2735
|
+
return None
|
|
2736
|
+
data = resp.json()
|
|
2737
|
+
return data["choices"][0]["message"]["content"]
|
|
2738
|
+
except (httpx.HTTPError, asyncio.TimeoutError, KeyError, ValueError) as e:
|
|
2739
|
+
last_exc = e
|
|
2740
|
+
await asyncio.sleep(min(10.0, 2.0 * (2 ** attempt)))
|
|
2741
|
+
logger.warning("stage request failed after retries: %s", last_exc)
|
|
2742
|
+
return None
|
|
2743
|
+
|
|
2744
|
+
|
|
2745
|
+
def _nano_group_by_bucket(
|
|
2746
|
+
content: List[Dict[str, Any]], original_image: Image.Image
|
|
2747
|
+
) -> Dict[str, List[Dict[str, Any]]]:
|
|
2748
|
+
"""Verbatim port of result_parser.group_content_by_category."""
|
|
2749
|
+
result: Dict[str, List[Dict[str, Any]]] = {
|
|
2750
|
+
"text": [], "table": [], "picture": [], "formula": [],
|
|
2751
|
+
}
|
|
2752
|
+
im_w, im_h = original_image.size
|
|
2753
|
+
for item in content:
|
|
2754
|
+
cat = item.get("category", "Text")
|
|
2755
|
+
mapped_cat = layout_recognition_bucket(cat)
|
|
2756
|
+
bbox = item.get("bbox", [])
|
|
2757
|
+
if not bbox or len(bbox) != 4:
|
|
2758
|
+
continue
|
|
2759
|
+
try:
|
|
2760
|
+
x1, y1, x2, y2 = bbox
|
|
2761
|
+
left = int(round(x1 * im_w))
|
|
2762
|
+
upper = int(round(y1 * im_h))
|
|
2763
|
+
right = int(round(x2 * im_w))
|
|
2764
|
+
lower = int(round(y2 * im_h))
|
|
2765
|
+
left = max(0, min(left, im_w))
|
|
2766
|
+
upper = max(0, min(upper, im_h))
|
|
2767
|
+
right = max(left + 1, min(right, im_w))
|
|
2768
|
+
lower = max(upper + 1, min(lower, im_h))
|
|
2769
|
+
pixel_width = right - left
|
|
2770
|
+
pixel_height = lower - upper
|
|
2771
|
+
if pixel_width < 5 or pixel_height < 5:
|
|
2772
|
+
continue
|
|
2773
|
+
cropped_img = original_image.crop((left, upper, right, lower))
|
|
2774
|
+
preprocessed_img = preprocess_for_vlm(cropped_img)
|
|
2775
|
+
if is_monochromatic(preprocessed_img):
|
|
2776
|
+
continue
|
|
2777
|
+
except Exception:
|
|
2778
|
+
continue
|
|
2779
|
+
element_info = {
|
|
2780
|
+
"bbox": bbox,
|
|
2781
|
+
"category": cat,
|
|
2782
|
+
"layout_order": item.get("layout_order", 0),
|
|
2783
|
+
"page_number": item.get("page_number", 1),
|
|
2784
|
+
"preprocessed_image": preprocessed_img,
|
|
2785
|
+
}
|
|
2786
|
+
if "angle" in item:
|
|
2787
|
+
element_info["angle"] = item.get("angle")
|
|
2788
|
+
if mapped_cat == "picture":
|
|
2789
|
+
element_info["cropped_image"] = cropped_img
|
|
2790
|
+
result[mapped_cat].append(element_info)
|
|
2791
|
+
return result
|
|
2792
|
+
|
|
2793
|
+
|
|
2794
|
+
def _nano_is_single_clean_otsl(content: Any) -> bool:
|
|
2795
|
+
return (
|
|
2796
|
+
isinstance(content, str)
|
|
2797
|
+
and content.count("<otsl>") == 1
|
|
2798
|
+
and ("<fcel>" in content or "<ecel>" in content)
|
|
2799
|
+
)
|
|
2800
|
+
|
|
2801
|
+
|
|
2802
|
+
def _nano_apply_picture_result(el: Dict[str, Any], content: str | None) -> None:
|
|
2803
|
+
"""Port of picture_recognition.apply_picture_result for the single-model
|
|
2804
|
+
run (enable_chart=True, caption_language='en').
|
|
2805
|
+
|
|
2806
|
+
The classified-JSON branch of the original is unreachable here: this
|
|
2807
|
+
preset has no response_format, so the model answers '\\nImage Analysis:'
|
|
2808
|
+
either with native chart tokens or plain text — never schema JSON. Hangul
|
|
2809
|
+
caption translation (a recognition-time convenience for Korean documents)
|
|
2810
|
+
is likewise inert on this benchmark and not ported.
|
|
2811
|
+
"""
|
|
2812
|
+
if content is None:
|
|
2813
|
+
el["content"] = ""
|
|
2814
|
+
return
|
|
2815
|
+
content_str = content.strip()
|
|
2816
|
+
native_parsed = parse_native_chart_tokens(content_str)
|
|
2817
|
+
if native_parsed is not None:
|
|
2818
|
+
image_type, table = native_parsed
|
|
2819
|
+
if table: # language_code 'en' != 'ko'
|
|
2820
|
+
table = normalize_krw_expressions(table)
|
|
2821
|
+
if image_type == "chart":
|
|
2822
|
+
el["category"] = "Chart"
|
|
2823
|
+
elif image_type == "flow":
|
|
2824
|
+
el["category"] = "Flowchart"
|
|
2825
|
+
normalized = normalize_markdown_table_content(table)
|
|
2826
|
+
if is_valid_markdown_table(normalized) and normalized.strip().startswith("|"):
|
|
2827
|
+
el["content"] = normalized
|
|
2828
|
+
else:
|
|
2829
|
+
el["content"] = table
|
|
2830
|
+
return
|
|
2831
|
+
if content_str.startswith("|") and is_valid_markdown_table(content_str):
|
|
2832
|
+
el["content"] = normalize_markdown_table_content(content_str)
|
|
2833
|
+
el["category"] = "Chart"
|
|
2834
|
+
else:
|
|
2835
|
+
el["content"] = content_str
|
|
2836
|
+
|
|
2837
|
+
|
|
2838
|
+
def _nano_postprocess_element(el: Dict[str, Any]) -> None:
|
|
2839
|
+
"""Port of postprocessing.PostProcessor (first matching processor wins;
|
|
2840
|
+
failures keep the original content)."""
|
|
2841
|
+
content = el.get("content") or ""
|
|
2842
|
+
category = el.get("category", "Text")
|
|
2843
|
+
if not content.strip():
|
|
2844
|
+
return
|
|
2845
|
+
try:
|
|
2846
|
+
if category == "Table":
|
|
2847
|
+
if looks_like_html_table(content):
|
|
2848
|
+
el["content"] = normalize_html_table_content(content)
|
|
2849
|
+
elif looks_like_otsl(content):
|
|
2850
|
+
cleaned = truncate_repetitive_content(content, preserve_line_breaks=True)
|
|
2851
|
+
html_content = convert_otsl_to_html_v2(cleaned)
|
|
2852
|
+
html_content = normalize_span_attributes(html_content)
|
|
2853
|
+
el["content"] = remove_dots_from_html_cells(html_content)
|
|
2854
|
+
elif category in _TEXT_BUCKET_CATEGORIES:
|
|
2855
|
+
el["content"] = truncate_repetitive_content(content)
|
|
2856
|
+
elif category in ("Picture", "Flowchart"):
|
|
2857
|
+
el["content"] = f"![{truncate_repetitive_content(content)}]"
|
|
2858
|
+
elif category == "Chart":
|
|
2859
|
+
cleaned = normalize_inline_markdown_table(content)
|
|
2860
|
+
el["content"] = _markdown_lib.markdown(
|
|
2861
|
+
html.escape(cleaned), extensions=["tables"]
|
|
2862
|
+
)
|
|
2863
|
+
elif category == "Formula":
|
|
2864
|
+
cleaned = truncate_repetitive_content(content)
|
|
2865
|
+
if ("\\(" in cleaned and "\\)" in cleaned) or (
|
|
2866
|
+
"\\[" in cleaned and "\\]" in cleaned
|
|
2867
|
+
):
|
|
2868
|
+
cleaned = cleaned.replace("$", "")
|
|
2869
|
+
cleaned = (
|
|
2870
|
+
cleaned.replace("\\(", " $ ")
|
|
2871
|
+
.replace("\\)", " $ ")
|
|
2872
|
+
.replace("\\[", " $$ ")
|
|
2873
|
+
.replace("\\]", " $$ ")
|
|
2874
|
+
)
|
|
2875
|
+
el["content"] = cleaned
|
|
2876
|
+
except Exception as e: # keep original content on post-processing failure
|
|
2877
|
+
logger.warning("element postprocess failed (%s): %s", category, e)
|
|
2878
|
+
|
|
2879
|
+
|
|
2880
|
+
# --- markdown formatting (port of formatters.markdown_formatter over dicts) ---
|
|
2881
|
+
|
|
2882
|
+
# verbatim from deepparser_v2/formatters/markdown_formatter.py
|
|
2883
|
+
_HTML_STRIKE_OPEN_RE = re.compile(r"<\s*(?:s|strike|del)(?:\s[^>]*)?>", re.IGNORECASE)
|
|
2884
|
+
_HTML_STRIKE_CLOSE_RE = re.compile(r"<\s*/\s*(?:s|strike|del)\s*>", re.IGNORECASE)
|
|
2885
|
+
_LEADING_MD_HEADING_RE = re.compile(r"^[ \t]*#{1,6}[ \t]+")
|
|
2886
|
+
|
|
2887
|
+
|
|
2888
|
+
def _strip_leading_heading_marker(content: str) -> str:
|
|
2889
|
+
if not content:
|
|
2890
|
+
return content
|
|
2891
|
+
return _LEADING_MD_HEADING_RE.sub("", content, count=1)
|
|
2892
|
+
|
|
2893
|
+
|
|
2894
|
+
def _preserve_inline_markup(content: str) -> str:
|
|
2895
|
+
if not content:
|
|
2896
|
+
return content
|
|
2897
|
+
content = _HTML_STRIKE_OPEN_RE.sub("~~", content)
|
|
2898
|
+
content = _HTML_STRIKE_CLOSE_RE.sub("~~", content)
|
|
2899
|
+
return content
|
|
2900
|
+
|
|
2901
|
+
def _nano_format_blockquote(content: str) -> str:
|
|
2902
|
+
stripped = content.strip()
|
|
2903
|
+
if not stripped:
|
|
2904
|
+
return ""
|
|
2905
|
+
lines = stripped.splitlines()
|
|
2906
|
+
quoted = [f"> {lines[0]}"]
|
|
2907
|
+
quoted.extend(f"> {line}" if line else ">" for line in lines[1:])
|
|
2908
|
+
return "\n".join(quoted)
|
|
2909
|
+
|
|
2910
|
+
|
|
2911
|
+
def _nano_format_formula(content: str) -> str:
|
|
2912
|
+
stripped = content.strip()
|
|
2913
|
+
if not stripped:
|
|
2914
|
+
return ""
|
|
2915
|
+
if stripped.startswith("```"):
|
|
2916
|
+
return stripped
|
|
2917
|
+
return f"```latex\n{stripped}\n```"
|
|
2918
|
+
|
|
2919
|
+
|
|
2920
|
+
def _nano_image_markdown(el: Dict[str, Any]) -> str:
|
|
2921
|
+
image_src = el.get("picture_path") or ""
|
|
2922
|
+
if not image_src:
|
|
2923
|
+
return ""
|
|
2924
|
+
alt = {"Chart": "chart", "Flowchart": "flowchart"}.get(el["category"], "picture")
|
|
2925
|
+
return f""
|
|
2926
|
+
|
|
2927
|
+
|
|
2928
|
+
def _nano_format_element(el: Dict[str, Any]) -> str:
|
|
2929
|
+
category = el.get("category", "Text")
|
|
2930
|
+
content = el.get("content") or ""
|
|
2931
|
+
if category in ("Title", "Section-header"):
|
|
2932
|
+
prefix = "#" if category == "Title" else "##"
|
|
2933
|
+
return f"{prefix} {_preserve_inline_markup(_strip_leading_heading_marker(content))}"
|
|
2934
|
+
if category == "Table":
|
|
2935
|
+
return content
|
|
2936
|
+
if category == "Chart":
|
|
2937
|
+
image_markdown = _nano_image_markdown(el)
|
|
2938
|
+
blocks = [b for b in (image_markdown, content) if b]
|
|
2939
|
+
return "\n\n".join(blocks)
|
|
2940
|
+
if category in ("Picture", "Flowchart"):
|
|
2941
|
+
image_markdown = _nano_image_markdown(el)
|
|
2942
|
+
if not image_markdown:
|
|
2943
|
+
return content
|
|
2944
|
+
stripped = content.strip()
|
|
2945
|
+
if stripped.startswith("|") or stripped.startswith("<table"):
|
|
2946
|
+
return f"{image_markdown}\n\n{stripped}"
|
|
2947
|
+
if content:
|
|
2948
|
+
return f"{image_markdown}\n\n{content}"
|
|
2949
|
+
return image_markdown
|
|
2950
|
+
if category in _BLOCKQUOTE_CATEGORIES:
|
|
2951
|
+
return _nano_format_blockquote(content)
|
|
2952
|
+
if category == "Formula":
|
|
2953
|
+
return _nano_format_formula(content)
|
|
2954
|
+
if category == "List-item":
|
|
2955
|
+
c = _preserve_inline_markup(_strip_leading_heading_marker(content.strip()))
|
|
2956
|
+
return f"- {c}" if c else ""
|
|
2957
|
+
return _preserve_inline_markup(_strip_leading_heading_marker(content.strip()))
|
|
2958
|
+
|
|
2959
|
+
|
|
2960
|
+
def _nano_assemble_markdown(
|
|
2961
|
+
elements: List[Dict[str, Any]],
|
|
2962
|
+
) -> Tuple[str, List[Dict[str, Any]]]:
|
|
2963
|
+
"""Port of response_builder._build_markdown_context/_build_full_markdown:
|
|
2964
|
+
sort by (page, layout_order), group contiguous same-page List-items into
|
|
2965
|
+
one block, drop empty blocks, page separators '---\\n\\n**Page N**' in the
|
|
2966
|
+
full document string only."""
|
|
2967
|
+
valid = [
|
|
2968
|
+
e for e in sorted(elements, key=lambda e: (e.get("page_number", 1),
|
|
2969
|
+
e.get("layout_order", 0)))
|
|
2970
|
+
if isinstance(e.get("page_number"), int)
|
|
2971
|
+
and not isinstance(e.get("page_number"), bool)
|
|
2972
|
+
and e.get("page_number", 0) >= 1
|
|
2973
|
+
]
|
|
2974
|
+
blocks: List[Tuple[int, str]] = [] # (page_number, content)
|
|
2975
|
+
index = 0
|
|
2976
|
+
while index < len(valid):
|
|
2977
|
+
el = valid[index]
|
|
2978
|
+
page = el["page_number"]
|
|
2979
|
+
if el.get("category") == "List-item":
|
|
2980
|
+
items = []
|
|
2981
|
+
while (
|
|
2982
|
+
index < len(valid)
|
|
2983
|
+
and valid[index].get("category") == "List-item"
|
|
2984
|
+
and valid[index]["page_number"] == page
|
|
2985
|
+
):
|
|
2986
|
+
formatted = _nano_format_element(valid[index])
|
|
2987
|
+
if formatted:
|
|
2988
|
+
items.append(formatted)
|
|
2989
|
+
index += 1
|
|
2990
|
+
content = "\n".join(items).strip()
|
|
2991
|
+
else:
|
|
2992
|
+
content = _nano_format_element(el).strip()
|
|
2993
|
+
index += 1
|
|
2994
|
+
if content:
|
|
2995
|
+
blocks.append((page, content))
|
|
2996
|
+
|
|
2997
|
+
md_parts: List[str] = []
|
|
2998
|
+
current_page: int | None = None
|
|
2999
|
+
for page, content in blocks:
|
|
3000
|
+
if current_page is not None and page != current_page:
|
|
3001
|
+
md_parts.append(f"---\n\n**Page {page}**")
|
|
3002
|
+
md_parts.append(content)
|
|
3003
|
+
current_page = page
|
|
3004
|
+
|
|
3005
|
+
pages_md: Dict[int, List[str]] = {}
|
|
3006
|
+
for page, content in blocks:
|
|
3007
|
+
pages_md.setdefault(page, []).append(content)
|
|
3008
|
+
markdown_pages = [
|
|
3009
|
+
{"page_number": page, "content": "\n\n".join(parts)}
|
|
3010
|
+
for page, parts in sorted(pages_md.items())
|
|
3011
|
+
]
|
|
3012
|
+
return "\n\n".join(md_parts), markdown_pages
|
|
3013
|
+
|
|
3014
|
+
|
|
3015
|
+
class _NanoEngine:
|
|
3016
|
+
"""Per-document pipeline against one OpenAI-compatible vLLM endpoint."""
|
|
3017
|
+
|
|
3018
|
+
def __init__(self, endpoint_url: str, model: str, max_concurrent: int,
|
|
3019
|
+
timeout_s: float, api_key: str | None = None):
|
|
3020
|
+
base = endpoint_url.rstrip("/")
|
|
3021
|
+
self._url = (
|
|
3022
|
+
base + "/chat/completions"
|
|
3023
|
+
if base.endswith("/v1")
|
|
3024
|
+
else base + "/v1/chat/completions"
|
|
3025
|
+
)
|
|
3026
|
+
self._model = model
|
|
3027
|
+
self._max_concurrent = max_concurrent
|
|
3028
|
+
self._timeout_s = timeout_s
|
|
3029
|
+
self._headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
3030
|
+
|
|
3031
|
+
async def parse_pages(self, page_images: List[Image.Image]) -> dict:
|
|
3032
|
+
semaphore = asyncio.Semaphore(self._max_concurrent)
|
|
3033
|
+
elements: List[Dict[str, Any]] = []
|
|
3034
|
+
async with httpx.AsyncClient(timeout=self._timeout_s, headers=self._headers) as client:
|
|
3035
|
+
for page_no, image in enumerate(page_images, start=1):
|
|
3036
|
+
image = normalize_image_mode(image, "RGB")
|
|
3037
|
+
page_elements = await self._parse_page(client, semaphore, image, page_no)
|
|
3038
|
+
elements.extend(page_elements)
|
|
3039
|
+
|
|
3040
|
+
for el in elements:
|
|
3041
|
+
_nano_postprocess_element(el)
|
|
3042
|
+
|
|
3043
|
+
full_md, markdown_pages = _nano_assemble_markdown(elements)
|
|
3044
|
+
# rule-based post-processing (the kdl_frontier mode gate is always on
|
|
3045
|
+
# for this provider): header_mark -> quote_fold -> title_promote
|
|
3046
|
+
full_md = postprocess_markdown(full_md)
|
|
3047
|
+
for page in markdown_pages:
|
|
3048
|
+
page["content"] = postprocess_markdown(page["content"])
|
|
3049
|
+
|
|
3050
|
+
pages_payload: Dict[int, List[Dict[str, Any]]] = {}
|
|
3051
|
+
for el in elements:
|
|
3052
|
+
pages_payload.setdefault(el["page_number"], []).append(
|
|
3053
|
+
{
|
|
3054
|
+
"category": el.get("category", "Text"),
|
|
3055
|
+
"bbox": el.get("bbox"),
|
|
3056
|
+
"content": el.get("content") or "",
|
|
3057
|
+
"layout_order": el.get("layout_order", 0),
|
|
3058
|
+
}
|
|
3059
|
+
)
|
|
3060
|
+
return {
|
|
3061
|
+
"markdown": full_md,
|
|
3062
|
+
"markdown_pages": markdown_pages,
|
|
3063
|
+
"pages": [
|
|
3064
|
+
{"page_number": n, "elements": els}
|
|
3065
|
+
for n, els in sorted(pages_payload.items())
|
|
3066
|
+
],
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
async def _parse_page(
|
|
3070
|
+
self,
|
|
3071
|
+
client: httpx.AsyncClient,
|
|
3072
|
+
semaphore: asyncio.Semaphore,
|
|
3073
|
+
image: Image.Image,
|
|
3074
|
+
page_no: int,
|
|
3075
|
+
) -> List[Dict[str, Any]]:
|
|
3076
|
+
w, h = image.size
|
|
3077
|
+
if min(w, h) < 32:
|
|
3078
|
+
return []
|
|
3079
|
+
try:
|
|
3080
|
+
if analyze_page_content(image).is_blank:
|
|
3081
|
+
return []
|
|
3082
|
+
except Exception:
|
|
3083
|
+
pass
|
|
3084
|
+
|
|
3085
|
+
layout_image = prepare_native_layout_image(image)
|
|
3086
|
+
layout_content = await _nano_chat(
|
|
3087
|
+
client, self._url, _nano_payload("layout", self._model, layout_image),
|
|
3088
|
+
semaphore,
|
|
3089
|
+
)
|
|
3090
|
+
if not layout_content or not layout_content.strip():
|
|
3091
|
+
return []
|
|
3092
|
+
if not is_native_layout_response(layout_content):
|
|
3093
|
+
logger.warning("page %d: layout response has no <|box_start|> tokens", page_no)
|
|
3094
|
+
return []
|
|
3095
|
+
items = parse_native_layout_tokens(layout_content)
|
|
3096
|
+
for item in items:
|
|
3097
|
+
item["page_number"] = page_no
|
|
3098
|
+
buckets = _nano_group_by_bucket(items, image)
|
|
3099
|
+
|
|
3100
|
+
# native full-page table route (single-table pages preserve multi-line
|
|
3101
|
+
# cells only with full-page context; adopted only for single clean OTSL)
|
|
3102
|
+
fullpage_table = (
|
|
3103
|
+
preprocess_for_vlm(image) if len(buckets["table"]) == 1 else None
|
|
3104
|
+
)
|
|
3105
|
+
|
|
3106
|
+
tasks = []
|
|
3107
|
+
|
|
3108
|
+
async def recognize(stage: str, el: Dict[str, Any]) -> None:
|
|
3109
|
+
pre = el.get("preprocessed_image")
|
|
3110
|
+
if pre is None:
|
|
3111
|
+
el["content"] = ""
|
|
3112
|
+
return
|
|
3113
|
+
if stage == "picture" and (pre.width < 25 or pre.height < 25):
|
|
3114
|
+
el["content"] = ""
|
|
3115
|
+
return
|
|
3116
|
+
content = await _nano_chat(
|
|
3117
|
+
client, self._url, _nano_payload(stage, self._model, pre), semaphore
|
|
3118
|
+
)
|
|
3119
|
+
if stage == "picture":
|
|
3120
|
+
_nano_apply_picture_result(el, content)
|
|
3121
|
+
else:
|
|
3122
|
+
el["content"] = content if content is not None else ""
|
|
3123
|
+
|
|
3124
|
+
async def recognize_table_fullpage(el: Dict[str, Any]) -> None:
|
|
3125
|
+
content = await _nano_chat(
|
|
3126
|
+
client, self._url,
|
|
3127
|
+
_nano_payload("table", self._model, fullpage_table), semaphore,
|
|
3128
|
+
)
|
|
3129
|
+
if content is not None and _nano_is_single_clean_otsl(content):
|
|
3130
|
+
el["content"] = content
|
|
3131
|
+
return
|
|
3132
|
+
await recognize("table", el)
|
|
3133
|
+
|
|
3134
|
+
for el in buckets["text"]:
|
|
3135
|
+
tasks.append(recognize("text", el))
|
|
3136
|
+
for i, el in enumerate(buckets["table"]):
|
|
3137
|
+
if fullpage_table is not None and i == 0:
|
|
3138
|
+
tasks.append(recognize_table_fullpage(el))
|
|
3139
|
+
else:
|
|
3140
|
+
tasks.append(recognize("table", el))
|
|
3141
|
+
for el in buckets["picture"]:
|
|
3142
|
+
tasks.append(recognize("picture", el))
|
|
3143
|
+
for el in buckets["formula"]:
|
|
3144
|
+
tasks.append(recognize("formula", el))
|
|
3145
|
+
await asyncio.gather(*tasks)
|
|
3146
|
+
|
|
3147
|
+
page_elements: List[Dict[str, Any]] = []
|
|
3148
|
+
picture_idx = 0
|
|
3149
|
+
for bucket_name in ("text", "table", "picture", "formula"):
|
|
3150
|
+
for el in buckets[bucket_name]:
|
|
3151
|
+
el.pop("preprocessed_image", None)
|
|
3152
|
+
cropped = el.pop("cropped_image", None)
|
|
3153
|
+
if bucket_name == "picture":
|
|
3154
|
+
# Textual artifact-path emulation: the original pipeline
|
|
3155
|
+
# saves each crop and references it from the markdown
|
|
3156
|
+
# (>=25px gate). The reference string is reproduced for
|
|
3157
|
+
# output parity, but no file is written — ParseBench
|
|
3158
|
+
# metrics consume the markdown text only and never
|
|
3159
|
+
# dereference image paths.
|
|
3160
|
+
if cropped is not None and cropped.width >= 25 and cropped.height >= 25:
|
|
3161
|
+
el["picture_path"] = (
|
|
3162
|
+
"artifacts/cropped_pictures/"
|
|
3163
|
+
f"page_{page_no:03d}_picture_{picture_idx:03d}.png"
|
|
3164
|
+
)
|
|
3165
|
+
picture_idx += 1
|
|
3166
|
+
page_elements.append(el)
|
|
3167
|
+
return page_elements
|
|
3168
|
+
|
|
3169
|
+
|
|
3170
|
+
@register_provider("kdl_frontier_nano")
|
|
3171
|
+
class KdlFrontierNanoProvider(Provider):
|
|
3172
|
+
"""Standalone provider for KDLAI/KDL-Frontier-Parser-nano (one vLLM
|
|
3173
|
+
endpoint + deterministic orchestration; no other learned components)."""
|
|
3174
|
+
|
|
3175
|
+
def __init__(self, provider_name: str, base_config: dict[str, Any] | None = None):
|
|
3176
|
+
super().__init__(provider_name, base_config)
|
|
3177
|
+
self._endpoint_url = (
|
|
3178
|
+
self.base_config.get("endpoint_url")
|
|
3179
|
+
or os.getenv("KDL_NANO_ENDPOINT_URL")
|
|
3180
|
+
or ""
|
|
3181
|
+
).rstrip("/")
|
|
3182
|
+
if not self._endpoint_url:
|
|
3183
|
+
raise ProviderConfigError(
|
|
3184
|
+
"KDL_NANO_ENDPOINT_URL is required (vLLM OpenAI-compatible base "
|
|
3185
|
+
"URL ending in /v1, serving KDLAI/KDL-Frontier-Parser-nano)."
|
|
3186
|
+
)
|
|
3187
|
+
self._model = (
|
|
3188
|
+
self.base_config.get("model")
|
|
3189
|
+
or os.getenv("KDL_NANO_MODEL")
|
|
3190
|
+
or "kdl-frontier-parser-nano"
|
|
3191
|
+
)
|
|
3192
|
+
self._dpi = int(self.base_config.get("dpi", os.getenv("KDL_NANO_DPI", "144")))
|
|
3193
|
+
self._timeout = float(self.base_config.get("timeout", 900))
|
|
3194
|
+
self._max_pages = int(
|
|
3195
|
+
self.base_config.get("max_pages", os.getenv("KDL_NANO_MAX_PAGES", "400"))
|
|
3196
|
+
)
|
|
3197
|
+
self._max_concurrent = int(os.getenv("KDL_NANO_MAX_CONCURRENT", "8"))
|
|
3198
|
+
api_key_env = self.base_config.get("api_key_env", "VLLM_API_KEY")
|
|
3199
|
+
self._api_key = self.base_config.get("api_key") or os.getenv(api_key_env, "")
|
|
3200
|
+
|
|
3201
|
+
def _load_page_images(self, source_path: Path) -> List[Image.Image]:
|
|
3202
|
+
if source_path.suffix.lower() in (".png", ".jpg", ".jpeg", ".jfif"):
|
|
3203
|
+
return [Image.open(source_path)]
|
|
3204
|
+
import fitz # PyMuPDF
|
|
3205
|
+
|
|
3206
|
+
zoom = self._dpi / 72.0
|
|
3207
|
+
mat = fitz.Matrix(zoom, zoom)
|
|
3208
|
+
images: List[Image.Image] = []
|
|
3209
|
+
with fitz.open(str(source_path)) as doc:
|
|
3210
|
+
if doc.page_count > self._max_pages:
|
|
3211
|
+
raise ProviderPermanentError(
|
|
3212
|
+
f"Document has {doc.page_count} pages > max_pages={self._max_pages}."
|
|
3213
|
+
)
|
|
3214
|
+
for page in doc:
|
|
3215
|
+
pix = page.get_pixmap(matrix=mat, alpha=False)
|
|
3216
|
+
images.append(Image.open(io.BytesIO(pix.tobytes("png"))))
|
|
3217
|
+
return images
|
|
3218
|
+
|
|
3219
|
+
def run_inference(
|
|
3220
|
+
self, pipeline: PipelineSpec, request: InferenceRequest
|
|
3221
|
+
) -> RawInferenceResult:
|
|
3222
|
+
source_path = Path(request.source_file_path)
|
|
3223
|
+
if not source_path.exists():
|
|
3224
|
+
raise ProviderPermanentError(f"Source file not found: {source_path}")
|
|
3225
|
+
started_at = datetime.now()
|
|
3226
|
+
try:
|
|
3227
|
+
page_images = self._load_page_images(source_path)
|
|
3228
|
+
except ProviderPermanentError:
|
|
3229
|
+
raise
|
|
3230
|
+
except Exception as e:
|
|
3231
|
+
raise ProviderPermanentError(f"Failed to load document: {e}") from e
|
|
3232
|
+
if not page_images:
|
|
3233
|
+
raise ProviderPermanentError("Document rendered to zero pages.")
|
|
3234
|
+
|
|
3235
|
+
engine = _NanoEngine(
|
|
3236
|
+
self._endpoint_url, self._model, self._max_concurrent, self._timeout,
|
|
3237
|
+
api_key=self._api_key,
|
|
3238
|
+
)
|
|
3239
|
+
try:
|
|
3240
|
+
raw_output = self.run_async_from_sync(engine.parse_pages(page_images))
|
|
3241
|
+
except Exception as e:
|
|
3242
|
+
raise ProviderTransientError(f"Pipeline failed: {e}") from e
|
|
3243
|
+
completed_at = datetime.now()
|
|
3244
|
+
return RawInferenceResult(
|
|
3245
|
+
request=request,
|
|
3246
|
+
pipeline=pipeline,
|
|
3247
|
+
pipeline_name=pipeline.pipeline_name,
|
|
3248
|
+
product_type=request.product_type,
|
|
3249
|
+
raw_output=raw_output,
|
|
3250
|
+
started_at=started_at,
|
|
3251
|
+
completed_at=completed_at,
|
|
3252
|
+
latency_in_ms=int((completed_at - started_at).total_seconds() * 1000),
|
|
3253
|
+
)
|
|
3254
|
+
|
|
3255
|
+
@staticmethod
|
|
3256
|
+
def _bbox_to_segment(bbox: Any, label: str | None) -> LayoutSegmentIR | None:
|
|
3257
|
+
try:
|
|
3258
|
+
if isinstance(bbox, dict):
|
|
3259
|
+
x1, y1, x2, y2 = bbox.get("x1"), bbox.get("y1"), bbox.get("x2"), bbox.get("y2")
|
|
3260
|
+
elif isinstance(bbox, (list, tuple)) and len(bbox) == 4:
|
|
3261
|
+
x1, y1, x2, y2 = bbox
|
|
3262
|
+
else:
|
|
3263
|
+
return None
|
|
3264
|
+
if None in (x1, y1, x2, y2):
|
|
3265
|
+
return None
|
|
3266
|
+
return LayoutSegmentIR(
|
|
3267
|
+
x=float(x1), y=float(y1),
|
|
3268
|
+
w=float(x2) - float(x1), h=float(y2) - float(y1), label=label,
|
|
3269
|
+
)
|
|
3270
|
+
except Exception:
|
|
3271
|
+
return None
|
|
3272
|
+
|
|
3273
|
+
def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
|
|
3274
|
+
if raw_result.product_type != ProductType.PARSE:
|
|
3275
|
+
raise ProviderPermanentError("KdlFrontierNanoProvider only supports PARSE.")
|
|
3276
|
+
raw = raw_result.raw_output
|
|
3277
|
+
|
|
3278
|
+
md_pages = sorted(
|
|
3279
|
+
raw.get("markdown_pages") or [],
|
|
3280
|
+
key=lambda d: d.get("page_number") or 0,
|
|
3281
|
+
)
|
|
3282
|
+
pages = [
|
|
3283
|
+
PageIR(page_index=idx, markdown=str(mp.get("content", "")))
|
|
3284
|
+
for idx, mp in enumerate(md_pages)
|
|
3285
|
+
]
|
|
3286
|
+
full_markdown = raw.get("markdown") or "\n\n<!-- page-break -->\n\n".join(
|
|
3287
|
+
p.markdown for p in pages
|
|
3288
|
+
)
|
|
3289
|
+
|
|
3290
|
+
layout_pages: list[ParseLayoutPageIR] = []
|
|
3291
|
+
for p in raw.get("pages") or []:
|
|
3292
|
+
items: list[LayoutItemIR] = []
|
|
3293
|
+
for e in p.get("elements") or []:
|
|
3294
|
+
seg = self._bbox_to_segment(e.get("bbox"), str(e.get("category", "")) or None)
|
|
3295
|
+
items.append(
|
|
3296
|
+
LayoutItemIR(
|
|
3297
|
+
type=str(e.get("category", "text")),
|
|
3298
|
+
md=str(e.get("content", "")),
|
|
3299
|
+
bbox=seg,
|
|
3300
|
+
layout_segments=[seg] if seg else [],
|
|
3301
|
+
)
|
|
3302
|
+
)
|
|
3303
|
+
try:
|
|
3304
|
+
layout_pages.append(
|
|
3305
|
+
ParseLayoutPageIR(page_number=int(p.get("page_number") or 1), items=items)
|
|
3306
|
+
)
|
|
3307
|
+
except Exception:
|
|
3308
|
+
pass
|
|
3309
|
+
|
|
3310
|
+
output = ParseOutput(
|
|
3311
|
+
task_type="parse",
|
|
3312
|
+
example_id=raw_result.request.example_id,
|
|
3313
|
+
pipeline_name=raw_result.pipeline_name,
|
|
3314
|
+
pages=pages,
|
|
3315
|
+
layout_pages=layout_pages,
|
|
3316
|
+
markdown=full_markdown,
|
|
3317
|
+
)
|
|
3318
|
+
return InferenceResult(
|
|
3319
|
+
request=raw_result.request,
|
|
3320
|
+
pipeline_name=raw_result.pipeline_name,
|
|
3321
|
+
product_type=raw_result.product_type,
|
|
3322
|
+
raw_output=raw_result.raw_output,
|
|
3323
|
+
output=output,
|
|
3324
|
+
started_at=raw_result.started_at,
|
|
3325
|
+
completed_at=raw_result.completed_at,
|
|
3326
|
+
latency_in_ms=raw_result.latency_in_ms,
|
|
3327
|
+
)
|