parse-bench 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. parse_bench/__init__.py +3 -0
  2. parse_bench/analysis/__init__.py +6 -0
  3. parse_bench/analysis/aggregation_report.py +582 -0
  4. parse_bench/analysis/cli.py +472 -0
  5. parse_bench/analysis/comparison.py +382 -0
  6. parse_bench/analysis/comparison_core.py +357 -0
  7. parse_bench/analysis/comparison_report.py +2066 -0
  8. parse_bench/analysis/detailed_report.py +2254 -0
  9. parse_bench/analysis/leaderboard_report.py +852 -0
  10. parse_bench/analysis/metric_definitions.py +771 -0
  11. parse_bench/cli.py +267 -0
  12. parse_bench/data/__init__.py +1 -0
  13. parse_bench/data/cli.py +118 -0
  14. parse_bench/data/download.py +127 -0
  15. parse_bench/evaluation/__init__.py +11 -0
  16. parse_bench/evaluation/cli.py +435 -0
  17. parse_bench/evaluation/evaluators/__init__.py +17 -0
  18. parse_bench/evaluation/evaluators/base.py +34 -0
  19. parse_bench/evaluation/evaluators/extract.py +429 -0
  20. parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
  21. parse_bench/evaluation/evaluators/parse.py +1353 -0
  22. parse_bench/evaluation/evaluators/qa.py +199 -0
  23. parse_bench/evaluation/layout_adapters/__init__.py +21 -0
  24. parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
  25. parse_bench/evaluation/layout_adapters/base.py +105 -0
  26. parse_bench/evaluation/layout_adapters/registry.py +109 -0
  27. parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
  28. parse_bench/evaluation/layout_label_mappers/base.py +66 -0
  29. parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
  30. parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
  31. parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
  32. parse_bench/evaluation/metric_aggregation.py +56 -0
  33. parse_bench/evaluation/metrics/__init__.py +5 -0
  34. parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
  35. parse_bench/evaluation/metrics/attribution/constants.py +12 -0
  36. parse_bench/evaluation/metrics/attribution/core.py +1108 -0
  37. parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
  38. parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
  39. parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
  40. parse_bench/evaluation/metrics/base.py +33 -0
  41. parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
  42. parse_bench/evaluation/metrics/extract/__init__.py +29 -0
  43. parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
  44. parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
  45. parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
  46. parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
  47. parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
  48. parse_bench/evaluation/metrics/extract/test_types.py +11 -0
  49. parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
  50. parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
  51. parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
  52. parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
  53. parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
  54. parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
  55. parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
  56. parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
  57. parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
  58. parse_bench/evaluation/metrics/parse/__init__.py +5 -0
  59. parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
  60. parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
  61. parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
  62. parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
  63. parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
  64. parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
  65. parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
  66. parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
  67. parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
  68. parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
  69. parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
  70. parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
  71. parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
  72. parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
  73. parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
  74. parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
  75. parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
  76. parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
  77. parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
  78. parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
  79. parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
  80. parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
  81. parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
  82. parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
  83. parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
  84. parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
  85. parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
  86. parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
  87. parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
  88. parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
  89. parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
  90. parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
  91. parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
  92. parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
  93. parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
  94. parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
  95. parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
  96. parse_bench/evaluation/metrics/parse/test_types.py +103 -0
  97. parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
  98. parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
  99. parse_bench/evaluation/metrics/parse/utils.py +885 -0
  100. parse_bench/evaluation/metrics/qa/__init__.py +5 -0
  101. parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
  102. parse_bench/evaluation/qa/__init__.py +5 -0
  103. parse_bench/evaluation/qa/llm_service.py +335 -0
  104. parse_bench/evaluation/reports/__init__.py +8 -0
  105. parse_bench/evaluation/reports/csv.py +64 -0
  106. parse_bench/evaluation/reports/html.py +338 -0
  107. parse_bench/evaluation/reports/markdown.py +98 -0
  108. parse_bench/evaluation/reports/rule_csv.py +22 -0
  109. parse_bench/evaluation/runner.py +1864 -0
  110. parse_bench/evaluation/stats.py +104 -0
  111. parse_bench/extensions.py +72 -0
  112. parse_bench/inference/__init__.py +33 -0
  113. parse_bench/inference/chunkr_layout_extraction.py +160 -0
  114. parse_bench/inference/cli.py +484 -0
  115. parse_bench/inference/layout_extraction.py +422 -0
  116. parse_bench/inference/pipelines/__init__.py +59 -0
  117. parse_bench/inference/pipelines/extract.py +39 -0
  118. parse_bench/inference/pipelines/layout.py +142 -0
  119. parse_bench/inference/pipelines/parse.py +2603 -0
  120. parse_bench/inference/pipelines.py +0 -0
  121. parse_bench/inference/providers/__init__.py +28 -0
  122. parse_bench/inference/providers/base.py +196 -0
  123. parse_bench/inference/providers/cancellation.py +137 -0
  124. parse_bench/inference/providers/extract/__init__.py +22 -0
  125. parse_bench/inference/providers/extract/citations.py +549 -0
  126. parse_bench/inference/providers/extract/extend.py +851 -0
  127. parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
  128. parse_bench/inference/providers/layoutdet/__init__.py +25 -0
  129. parse_bench/inference/providers/layoutdet/adapters.py +946 -0
  130. parse_bench/inference/providers/layoutdet/base.py +203 -0
  131. parse_bench/inference/providers/layoutdet/chandra.py +449 -0
  132. parse_bench/inference/providers/layoutdet/docling.py +125 -0
  133. parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
  134. parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
  135. parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
  136. parse_bench/inference/providers/layoutdet/paddle.py +117 -0
  137. parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
  138. parse_bench/inference/providers/layoutdet/surya.py +250 -0
  139. parse_bench/inference/providers/layoutdet/yolo.py +109 -0
  140. parse_bench/inference/providers/parse/__init__.py +64 -0
  141. parse_bench/inference/providers/parse/_docling_common.py +233 -0
  142. parse_bench/inference/providers/parse/_layout_utils.py +611 -0
  143. parse_bench/inference/providers/parse/amazon_nova.py +515 -0
  144. parse_bench/inference/providers/parse/anthropic.py +882 -0
  145. parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
  146. parse_bench/inference/providers/parse/chandra2.py +633 -0
  147. parse_bench/inference/providers/parse/chunkr.py +268 -0
  148. parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
  149. parse_bench/inference/providers/parse/datalab.py +370 -0
  150. parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
  151. parse_bench/inference/providers/parse/docling.py +281 -0
  152. parse_bench/inference/providers/parse/docling_serve.py +289 -0
  153. parse_bench/inference/providers/parse/dots_ocr.py +574 -0
  154. parse_bench/inference/providers/parse/extend_parse.py +710 -0
  155. parse_bench/inference/providers/parse/falconocr.py +436 -0
  156. parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
  157. parse_bench/inference/providers/parse/gemma4.py +472 -0
  158. parse_bench/inference/providers/parse/glm_zai.py +229 -0
  159. parse_bench/inference/providers/parse/google.py +1125 -0
  160. parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
  161. parse_bench/inference/providers/parse/google_docai.py +776 -0
  162. parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
  163. parse_bench/inference/providers/parse/granite_vision.py +515 -0
  164. parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
  165. parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
  166. parse_bench/inference/providers/parse/landingai.py +452 -0
  167. parse_bench/inference/providers/parse/liteparse.py +350 -0
  168. parse_bench/inference/providers/parse/llamaparse.py +677 -0
  169. parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
  170. parse_bench/inference/providers/parse/markitdown.py +138 -0
  171. parse_bench/inference/providers/parse/mineru25.py +405 -0
  172. parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
  173. parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
  174. parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
  175. parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
  176. parse_bench/inference/providers/parse/oi_parser.py +222 -0
  177. parse_bench/inference/providers/parse/openai.py +740 -0
  178. parse_bench/inference/providers/parse/opendataloader.py +152 -0
  179. parse_bench/inference/providers/parse/paddleocr.py +624 -0
  180. parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
  181. parse_bench/inference/providers/parse/pulse.py +785 -0
  182. parse_bench/inference/providers/parse/pymupdf.py +207 -0
  183. parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
  184. parse_bench/inference/providers/parse/pypdf.py +179 -0
  185. parse_bench/inference/providers/parse/qwen.py +678 -0
  186. parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
  187. parse_bench/inference/providers/parse/reducto.py +546 -0
  188. parse_bench/inference/providers/parse/surya2.py +372 -0
  189. parse_bench/inference/providers/parse/tesseract.py +301 -0
  190. parse_bench/inference/providers/parse/textract.py +694 -0
  191. parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
  192. parse_bench/inference/providers/parse/unstructured.py +485 -0
  193. parse_bench/inference/providers/parse/warp_ingest.py +199 -0
  194. parse_bench/inference/providers/registry.py +49 -0
  195. parse_bench/inference/renormalize.py +170 -0
  196. parse_bench/inference/runner.py +2023 -0
  197. parse_bench/layout_label_mapping.py +424 -0
  198. parse_bench/layout_projection.py +179 -0
  199. parse_bench/pipeline/__init__.py +1 -0
  200. parse_bench/pipeline/cli.py +549 -0
  201. parse_bench/schemas/__init__.py +33 -0
  202. parse_bench/schemas/evaluation.py +93 -0
  203. parse_bench/schemas/extract_output.py +36 -0
  204. parse_bench/schemas/layout_detection_output.py +545 -0
  205. parse_bench/schemas/layout_ontology.py +315 -0
  206. parse_bench/schemas/metrics.py +69 -0
  207. parse_bench/schemas/parse_output.py +152 -0
  208. parse_bench/schemas/pipeline.py +22 -0
  209. parse_bench/schemas/pipeline_io.py +106 -0
  210. parse_bench/schemas/product.py +97 -0
  211. parse_bench/test_cases/__init__.py +25 -0
  212. parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
  213. parse_bench/test_cases/extract_field_paths.py +164 -0
  214. parse_bench/test_cases/layout_attribution_generation.py +287 -0
  215. parse_bench/test_cases/loader.py +652 -0
  216. parse_bench/test_cases/parse_rule_schemas.py +1071 -0
  217. parse_bench/test_cases/rule_filters.py +32 -0
  218. parse_bench/test_cases/rule_ids.py +107 -0
  219. parse_bench/test_cases/schema.py +427 -0
  220. parse_bench/utils/__init__.py +15 -0
  221. parse_bench/utils/gemini_layout_utils.py +670 -0
  222. parse_bench/utils/text_aggregation.py +100 -0
  223. parse_bench-1.0.0.dist-info/METADATA +476 -0
  224. parse_bench-1.0.0.dist-info/RECORD +227 -0
  225. parse_bench-1.0.0.dist-info/WHEEL +4 -0
  226. parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
  227. parse_bench-1.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,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>&lt;|..").
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"![{alt}]({image_src})"
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
+ )