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,559 @@
1
+ """florin-parser-nano — ParseBench provider.
2
+
3
+ `florin-inc/florin-parser-nano <https://huggingface.co/florin-inc/florin-parser-nano>`_
4
+ is a LoRA fine-tune of `KDLAI/KDL-Frontier-Parser-nano
5
+ <https://huggingface.co/KDLAI/KDL-Frontier-Parser-nano>`_ (KoreaDeep, 1.2B, Qwen2-VL
6
+ architecture) that teaches the model to emit inline formatting — ``**bold**``,
7
+ ``~~strikethrough~~``, ``<sup>``/``<sub>`` — during text recognition, with the
8
+ production prompt unchanged. Weights are AGPL-3.0, inherited from the base model.
9
+ Full attribution to KoreaDeep for the base model and the pipeline design.
10
+
11
+ This provider inherits every inference stage of the in-repo ``kdl_frontier_nano``
12
+ provider unchanged — page rendering, layout detection, crop/bucket, all four
13
+ recognition passes, retry and error handling — and differs in exactly two ways:
14
+
15
+ 1. **The served weights** are the fine-tune instead of the base model.
16
+ 2. **Markdown emission**: the function that turns the finished element list into
17
+ markdown is replaced by the four emission fixes implemented in this file
18
+ (see "THE FOUR EMISSION CHANGES" below). The ``pages`` payload (each element's
19
+ category, bounding box and recognised text) is left exactly as the inherited
20
+ pipeline produced it, so the Visual Grounding dimension is computed from
21
+ unmodified pipeline output.
22
+
23
+ Serve the weights exactly as the base model is served:
24
+
25
+ vllm serve florin-inc/florin-parser-nano \\
26
+ --served-model-name florin-parser-nano \\
27
+ --max-model-len 8192 --gpu-memory-utilization 0.85 \\
28
+ --max-num-seqs 24 --trust-remote-code \\
29
+ --limit-mm-per-prompt '{"image":1}'
30
+
31
+ Then:
32
+
33
+ FLORIN_NANO_ENDPOINT_URL=http://localhost:8000/v1 \\
34
+ uv run parse-bench run florin_parser_nano --input_dir data ...
35
+
36
+ Config (env):
37
+ FLORIN_NANO_ENDPOINT_URL vLLM base URL ending in /v1 (required)
38
+ FLORIN_NANO_MODEL served model name (default florin-parser-nano)
39
+ All other knobs (per-document request concurrency, per-stage token budgets, DPI)
40
+ are inherited from the ``kdl_frontier_nano`` provider and keep their ``KDL_NANO_*``
41
+ environment variables and defaults, because the fine-tune is served and driven
42
+ identically to the base model.
43
+
44
+ THE FOUR EMISSION CHANGES
45
+ -------------------------
46
+ Each is a defect fix in the emission stage, not a change to recognition output:
47
+
48
+ (a) **section_header layout-label mapping.** ``NATIVE_LAYOUT_CATEGORY_MAP`` in
49
+ ``kdl_frontier_nano.py`` has no ``section_header`` key, so that raw layout label
50
+ falls through to ``Text`` and the formatter's ``## `` branch (the only source of
51
+ ``## `` headings) is unreachable. One key is added.
52
+
53
+ (b) **Heading depth from bounding-box height.** The inherited pipeline emits every
54
+ ``Title`` element as ``# ``, which makes any parent/child heading-hierarchy
55
+ check unsatisfiable by construction. Depth 1..4 is assigned from the rank of
56
+ each Title's bounding-box height within the document (taller box = shallower
57
+ heading). Ranks are computed from geometry the layout stage already produced;
58
+ element dicts are not modified.
59
+
60
+ (c) **Bold run-in labels.** A paragraph- or list-item-leading ``Label:`` run (e.g.
61
+ "**AGENCY:** Department of Transport") is bold in the source documents; the
62
+ inherited emission drops the markup. The leading label run is wrapped in
63
+ ``**...**``, never inside tables, headings, code fences, HTML blocks, or lines
64
+ that already contain ``**``. For a list item the marker stays outside the span
65
+ (``- Note: x`` becomes ``- **Note:** x``), and fence state is tracked across
66
+ lines so fence interiors are never touched. (Corrected 2026-08-21 after
67
+ maintainer review of PR #99: the original implementation swallowed list
68
+ markers into the span and, lacking fence state, bolded lines inside fences.)
69
+
70
+ (d) **Relaxed standalone-heading gate.** The inherited ``_is_titleish`` vetoes any
71
+ candidate line ending in ``.!?:;,`` and any line matching ``^.{1,40}:\\s``,
72
+ which rejects genuine headings such as "Notes:". The label/value veto is
73
+ dropped; the terminal-punctuation veto is kept for every character except the
74
+ colon (``.!?;,``), so "Notes:" promotes but a sentence ending in ``.`` does
75
+ not; every other guard is kept and promoted lines are capped at 20 words.
76
+ (Corrected 2026-08-21 after maintainer review of PR #99: the original
77
+ implementation dropped the terminal-punctuation veto entirely, which promoted
78
+ ordinary short sentences ending in ``.`` to headings.)
79
+
80
+ IMPLEMENTATION NOTE — why two module attributes are rebound during inference
81
+ ----------------------------------------------------------------------------
82
+ ``kdl_frontier_nano.py`` is not modified. Two things the emission changes need are
83
+ looked up as module globals by module-level functions in that file, where no subclass
84
+ hook can reach them: ``NATIVE_LAYOUT_CATEGORY_MAP`` (read by ``_category_for_item``
85
+ during layout parsing) and ``_NanoEngine`` (instantiated by name inside
86
+ ``KdlFrontierNanoProvider.run_inference``). ``_patched_bindings()`` below rebinds
87
+ those two attributes while a florin document is in flight and restores them when the
88
+ last in-flight document finishes (re-entrant, so concurrent documents are safe).
89
+ Consequence: do not run the unpatched ``kdl_frontier_nano`` pipeline concurrently in
90
+ the same process as ``florin_parser_nano``; run them as separate invocations.
91
+
92
+ All emission code in this file is a pure function of the element list — no network,
93
+ no randomness — so the same elements always produce the same markdown.
94
+ """
95
+
96
+ from __future__ import annotations
97
+
98
+ import asyncio
99
+ import contextlib
100
+ import os
101
+ import re
102
+ import threading
103
+ from typing import Any, Dict, Iterator, List, Tuple
104
+
105
+ from PIL import Image
106
+
107
+ from parse_bench.inference.providers.base import ProviderConfigError
108
+ from parse_bench.inference.providers.parse import kdl_frontier_nano as K
109
+ from parse_bench.inference.providers.registry import register_provider
110
+ from parse_bench.schemas.pipeline import PipelineSpec
111
+ from parse_bench.schemas.pipeline_io import InferenceRequest, RawInferenceResult
112
+
113
+ # =============================================================================
114
+ # (a) layout label map fix — one added key. "Section-header" is the pipeline's
115
+ # own canonical category name and the only category rendered as "## ".
116
+ # =============================================================================
117
+ FLORIN_NATIVE_LAYOUT_CATEGORY_MAP: Dict[str, str] = {
118
+ **K.NATIVE_LAYOUT_CATEGORY_MAP,
119
+ "section_header": "Section-header",
120
+ }
121
+
122
+ #: Fixed emission parameters for this provider (the submitted configuration).
123
+ _MAX_HEADING_DEPTH = 4 # change (b): heading depths 1..4
124
+ _TITLE_GATE_WORD_CAP = 20 # change (d): word cap alongside the narrowed veto
125
+ #: change (d): the vendored ``_TERMINAL_PUNCT`` with ``:`` removed — a line ending
126
+ #: in sentence punctuation is body text; a line ending in ``:`` may be a heading.
127
+ _TERMINAL_PUNCT_NO_COLON = tuple(".!?;,")
128
+
129
+
130
+ # =============================================================================
131
+ # (b) heading depth from bounding-box height
132
+ # =============================================================================
133
+ def _heading_levels_by_bbox(
134
+ elements: List[Dict[str, Any]], max_level: int = _MAX_HEADING_DEPTH
135
+ ) -> Dict[int, int]:
136
+ """Assign a heading depth 1..``max_level`` to every ``Title`` element.
137
+
138
+ Taller bounding box = more prominent heading = shallower depth. Depths come
139
+ from the *rank* of the distinct rounded box heights within the document, not
140
+ from absolute sizes, so the result does not depend on page size or render
141
+ DPI. Ties share a depth. Returns ``{index in elements -> depth}`` without
142
+ writing to the element dicts, which keeps the elements (and therefore the
143
+ Visual Grounding payload) bit-identical.
144
+ """
145
+ heights: List[Tuple[int, float]] = []
146
+ for i, el in enumerate(elements):
147
+ if el.get("category") != "Title":
148
+ continue
149
+ bb = el.get("bbox")
150
+ if not bb or len(bb) < 4:
151
+ continue
152
+ heights.append((i, abs(float(bb[3]) - float(bb[1]))))
153
+ if not heights:
154
+ return {}
155
+ distinct = sorted({round(h, 4) for _, h in heights}, reverse=True)
156
+ rank = {h: min(r + 1, max_level) for r, h in enumerate(distinct)}
157
+ return {i: rank[round(h, 4)] for i, h in heights}
158
+
159
+
160
+ # =============================================================================
161
+ # element -> markdown
162
+ # =============================================================================
163
+ def _format_element(el: Dict[str, Any], level: int | None) -> str:
164
+ """Render one element, delegating everything unchanged to the inherited
165
+ formatter. The single differing branch: a ``Title`` element with an assigned
166
+ depth gets that many ``#`` characters instead of always one."""
167
+ if el.get("category") == "Title" and level:
168
+ body = K._preserve_inline_markup(
169
+ K._strip_leading_heading_marker(el.get("content") or "")
170
+ )
171
+ return f"{'#' * int(level)} {body}"
172
+ return K._nano_format_element(el)
173
+
174
+
175
+ def _assemble_markdown(
176
+ elements: List[Dict[str, Any]],
177
+ ) -> Tuple[str, List[Dict[str, Any]]]:
178
+ """Port of ``K._nano_assemble_markdown`` with the per-element formatter
179
+ swapped for :func:`_format_element`, so heading depths from change (b) can be
180
+ applied without editing the vendored file.
181
+
182
+ Behaviour copied verbatim: sort by (page, layout_order); drop elements with
183
+ an invalid page number; group runs of contiguous same-page ``List-item``
184
+ elements into a single block; drop empty blocks; insert
185
+ ``---\\n\\n**Page N**`` separators between pages of the whole-document string
186
+ only.
187
+ """
188
+ levels = _heading_levels_by_bbox(elements)
189
+
190
+ valid = [
191
+ (i, e)
192
+ for i, e in sorted(
193
+ enumerate(elements),
194
+ key=lambda p: (p[1].get("page_number", 1), p[1].get("layout_order", 0)),
195
+ )
196
+ if isinstance(e.get("page_number"), int)
197
+ and not isinstance(e.get("page_number"), bool)
198
+ and e.get("page_number", 0) >= 1
199
+ ]
200
+
201
+ blocks: List[Tuple[int, str]] = []
202
+ index = 0
203
+ while index < len(valid):
204
+ idx, el = valid[index]
205
+ page = el["page_number"]
206
+ if el.get("category") == "List-item":
207
+ items = []
208
+ while (
209
+ index < len(valid)
210
+ and valid[index][1].get("category") == "List-item"
211
+ and valid[index][1]["page_number"] == page
212
+ ):
213
+ j, item = valid[index]
214
+ formatted = _format_element(item, levels.get(j))
215
+ if formatted:
216
+ items.append(formatted)
217
+ index += 1
218
+ content = "\n".join(items).strip()
219
+ else:
220
+ content = _format_element(el, levels.get(idx)).strip()
221
+ index += 1
222
+ if content:
223
+ blocks.append((page, content))
224
+
225
+ md_parts: List[str] = []
226
+ current_page: int | None = None
227
+ for page, content in blocks:
228
+ if current_page is not None and page != current_page:
229
+ md_parts.append(f"---\n\n**Page {page}**")
230
+ md_parts.append(content)
231
+ current_page = page
232
+
233
+ pages_md: Dict[int, List[str]] = {}
234
+ for page, content in blocks:
235
+ pages_md.setdefault(page, []).append(content)
236
+ markdown_pages = [
237
+ {"page_number": page, "content": "\n\n".join(parts)}
238
+ for page, parts in sorted(pages_md.items())
239
+ ]
240
+ return "\n\n".join(md_parts), markdown_pages
241
+
242
+
243
+ # =============================================================================
244
+ # (d) relaxed standalone-heading gate
245
+ # =============================================================================
246
+ def _is_titleish_relaxed(
247
+ text: str, max_words: int, caps_ratio: float, require_all_caps: bool
248
+ ) -> bool:
249
+ """Replacement for ``K._is_titleish`` with one veto removed and one narrowed.
250
+
251
+ Kept, unchanged from the vendored gate: refuse a line that is already a
252
+ heading, a bullet, a numbered item or a table row; refuse a line with no
253
+ ASCII letters; and require either a leading capital letter or a
254
+ capitalisation ratio above ``caps_ratio``.
255
+
256
+ Removed: the "``^.{1,40}:\\s`` looks like a label/value pair" veto, which
257
+ rejects genuine headings.
258
+
259
+ Narrowed (2026-08-21, maintainer review of PR #99): the vendored
260
+ terminal-punctuation veto is restored for every character EXCEPT the colon
261
+ (``_TERMINAL_PUNCT_NO_COLON``). Dropping it entirely had promoted ordinary
262
+ short sentences ending in ``.`` to ``# `` headings; keeping ``:`` out of the
263
+ veto set is the point of this change ("Notes:" is a genuine heading the
264
+ vendored gate wrongly rejects).
265
+
266
+ Replaced: the shipped 12-word cap becomes ``_TITLE_GATE_WORD_CAP`` (20).
267
+ The ``max_words`` argument is accepted and deliberately ignored so the
268
+ signature matches the vendored gate, which ``title_promote`` calls
269
+ positionally.
270
+ """
271
+ s = text.strip()
272
+ if not s:
273
+ return False
274
+ if (
275
+ K._HEADING_RE.match(s)
276
+ or K._LIST_RE.match(s)
277
+ or K._NUMLIST_RE.match(s)
278
+ or K._TABLEROW_RE.match(s)
279
+ ):
280
+ return False
281
+ if len(s.split()) > _TITLE_GATE_WORD_CAP:
282
+ return False
283
+ if s.endswith(_TERMINAL_PUNCT_NO_COLON):
284
+ return False
285
+ letters = K._LETTER_RE.findall(s)
286
+ if not letters:
287
+ return False
288
+ caps_frac = len(K._UPPER_RE.findall(s)) / len(letters)
289
+ if require_all_caps:
290
+ return caps_frac >= caps_ratio
291
+ first_alpha = next((c for c in s if c.isalpha()), "")
292
+ return first_alpha.isupper() or caps_frac > caps_ratio
293
+
294
+
295
+ def _title_promote(md: str, variant: str = "aggressive") -> str:
296
+ """Port of ``K.title_promote`` with the accept/reject gate swapped for
297
+ :func:`_is_titleish_relaxed`.
298
+
299
+ Verbatim behaviour: skip fenced code blocks; consider only lines that are
300
+ "standalone" (blank line, or document edge, both above and below); never
301
+ promote the ``**Page N**`` separator; unwrap a ``> `` blockquote and promote
302
+ its inner text if the gate accepts it.
303
+ """
304
+ if not md:
305
+ return md
306
+ max_words, caps_ratio, require_all_caps = K._TITLE_VARIANTS[variant]
307
+ do_promote = variant != "deblockquote_only"
308
+
309
+ lines = md.split("\n")
310
+ n = len(lines)
311
+ out = list(lines)
312
+ in_fence = False
313
+ for i, raw in enumerate(lines):
314
+ if K._FENCE_RE.match(raw):
315
+ in_fence = not in_fence
316
+ continue
317
+ if in_fence:
318
+ continue
319
+ above = lines[i - 1] if i > 0 else ""
320
+ below = lines[i + 1] if i + 1 < n else ""
321
+ standalone = (i == 0 or above.strip() == "") and (
322
+ i + 1 >= n or below.strip() == ""
323
+ )
324
+ if not standalone:
325
+ continue
326
+ if K._PAGE_MARKER_RE.match(raw.strip()):
327
+ continue
328
+ bq = K._BLOCKQUOTE_RE.match(raw)
329
+ if bq:
330
+ inner = bq.group(1)
331
+ if _is_titleish_relaxed(inner, max_words, caps_ratio, require_all_caps):
332
+ out[i] = ("# " + inner.strip()) if do_promote else inner.strip()
333
+ continue
334
+ if do_promote and _is_titleish_relaxed(
335
+ raw, max_words, caps_ratio, require_all_caps
336
+ ):
337
+ out[i] = "# " + raw.strip()
338
+ return "\n".join(out)
339
+
340
+
341
+ # =============================================================================
342
+ # (c) bold run-in `Label:` prefixes
343
+ # =============================================================================
344
+ # A line whose first token run is a short label ending in a colon, followed by
345
+ # whitespace and then the value. Deliberately narrow: at most 60 characters and
346
+ # at most 6 words in the label, no colon inside it, and the line may not begin
347
+ # with a markdown structural character.
348
+ _LABEL_RE = re.compile(r"^([^\s#>|*<][^:\n]{0,60}?:)(\s)")
349
+ _HAS_BOLD = re.compile(r"\*\*")
350
+ _HTML_TABLE_SPAN = re.compile(r"<table\b.*?</table>", re.S)
351
+ _PAGE_MARKER = re.compile(r"^\*\*Page\s+\d+\*\*$")
352
+ #: Lines never touched: heading, pipe-table row, HTML, code fence, image, page rule.
353
+ _SKIP_PREFIXES = ("#", "|", "<", "`", "!", "---")
354
+ #: A code/LaTeX fence marker line — same pattern as the vendored ``_FENCE_RE``.
355
+ _FENCE_LINE = re.compile(r"^\s*(```|~~~)")
356
+ #: A markdown list-item marker: optional indent of at most 3 spaces (4+ is an
357
+ #: indented code block), then a bullet (``-``, ``+``, ``*``) or a number with
358
+ #: ``.``/``)``, then whitespace.
359
+ _LIST_PREFIX = re.compile(r"^(\s{0,3}(?:[-+*]|\d{1,3}[.)])\s+)")
360
+
361
+
362
+ def _table_line_mask(md: str) -> List[bool]:
363
+ """True for each line that lies inside an HTML ``<table>...</table>`` block."""
364
+ spans = [(m.start(), m.end()) for m in _HTML_TABLE_SPAN.finditer(md)]
365
+ mask: List[bool] = []
366
+ pos = 0
367
+ for line in md.split("\n"):
368
+ mask.append(any(a <= pos < b for a, b in spans))
369
+ pos += len(line) + 1
370
+ return mask
371
+
372
+
373
+ def _skippable(line: str) -> bool:
374
+ s = line.strip()
375
+ if not s:
376
+ return True
377
+ if _PAGE_MARKER.match(s):
378
+ return True
379
+ return s.startswith(_SKIP_PREFIXES)
380
+
381
+
382
+ def _bold_run_in_labels(md: str) -> str:
383
+ """Wrap a leading ``Label:`` run of each paragraph or list item in ``**...**``.
384
+
385
+ Never touches a line inside a table, a heading, a code fence, an HTML block,
386
+ or a line that already contains ``**`` (nesting or splitting an existing bold
387
+ span would break the markdown).
388
+
389
+ Corrected 2026-08-21 after the maintainer's review of PR #99 found two
390
+ defects in the original implementation:
391
+
392
+ 1. **List structure is preserved.** The label pattern used to match against
393
+ the whole line, so ``- Note: x`` became ``**- Note:** x`` — the bullet was
394
+ swallowed into the bold span, breaking the list. The list marker is now
395
+ split off first and the label bolded inside the item: ``- **Note:** x``,
396
+ which is valid markdown and matches the benchmark's bold patterns.
397
+ 2. **Fence interiors are never touched.** The skip test used to look at each
398
+ line in isolation, so it skipped the fence marker itself but bolded
399
+ label-shaped lines INSIDE code/LaTeX fences. Fence state is now tracked
400
+ across lines with the same marker pattern as the vendored ``_FENCE_RE``.
401
+ """
402
+ tbl = _table_line_mask(md)
403
+ out: List[str] = []
404
+ in_fence = False
405
+ for i, line in enumerate(md.split("\n")):
406
+ if _FENCE_LINE.match(line):
407
+ in_fence = not in_fence
408
+ out.append(line)
409
+ continue
410
+ if in_fence or tbl[i] or _HAS_BOLD.search(line):
411
+ out.append(line)
412
+ continue
413
+ prefix = ""
414
+ body = line
415
+ lp = _LIST_PREFIX.match(line)
416
+ if lp:
417
+ prefix = lp.group(1)
418
+ body = line[lp.end() :]
419
+ if _skippable(body):
420
+ out.append(line)
421
+ continue
422
+ m = _LABEL_RE.match(body)
423
+ if m and len(m.group(1).split()) <= 6:
424
+ body = f"**{m.group(1)}**{m.group(2)}{body[m.end():]}"
425
+ out.append(prefix + body)
426
+ return "\n".join(out)
427
+
428
+
429
+ # =============================================================================
430
+ # document-level post-processing
431
+ # =============================================================================
432
+ def _postprocess_markdown(md: str, *, title_variant: str = "aggressive") -> str:
433
+ """Port of ``K.postprocess_markdown``: ``header_mark`` -> ``quote_fold`` ->
434
+ ``title_promote``, each wrapped so one failing rule never discards the
435
+ document, and all rules skipped on a runaway document.
436
+
437
+ Two seams: ``title_promote`` uses the relaxed gate (change (d)), and change
438
+ (c) is appended as a final rule. (c) runs last so it sees the promoted
439
+ headings and leaves them alone.
440
+ """
441
+ if not md or K._looks_runaway(md):
442
+ return md
443
+ try:
444
+ md = K.header_mark(md)
445
+ except Exception: # noqa: BLE001 — never fail a document on a post-processing rule
446
+ pass
447
+ try:
448
+ md = K.quote_fold(md)
449
+ md = _title_promote(md, variant=title_variant)
450
+ except Exception: # noqa: BLE001
451
+ pass
452
+ try:
453
+ md = _bold_run_in_labels(md)
454
+ except Exception: # noqa: BLE001
455
+ pass
456
+ return md
457
+
458
+
459
+ def build_markdown(
460
+ elements: List[Dict[str, Any]],
461
+ ) -> Tuple[str, List[Dict[str, Any]]]:
462
+ """Finished element list -> (whole-document markdown, per-page records)."""
463
+ full_md, markdown_pages = _assemble_markdown(elements)
464
+ full_md = _postprocess_markdown(full_md)
465
+ for page in markdown_pages:
466
+ page["content"] = _postprocess_markdown(page["content"])
467
+ return full_md, markdown_pages
468
+
469
+
470
+ # =============================================================================
471
+ # engine + provider
472
+ # =============================================================================
473
+ class _FlorinNanoEngine(K._NanoEngine):
474
+ """The inherited per-document engine with this module's markdown emission.
475
+
476
+ ``_parse_page`` is overridden only to keep a reference to the element
477
+ dictionaries the inherited engine produces; the inherited implementation
478
+ still does all the work. After ``parse_pages`` returns, those same
479
+ dictionaries have been through the inherited post-processing in place, so
480
+ they are the finished elements — including the ``picture_path`` field, which
481
+ the ``pages`` payload drops and which the image markdown needs.
482
+ """
483
+
484
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
485
+ super().__init__(*args, **kwargs)
486
+ self._captured: List[Dict[str, Any]] = []
487
+
488
+ async def _parse_page( # type: ignore[override]
489
+ self,
490
+ client: Any,
491
+ semaphore: asyncio.Semaphore,
492
+ image: Image.Image,
493
+ page_no: int,
494
+ ) -> List[Dict[str, Any]]:
495
+ elements = await super()._parse_page(client, semaphore, image, page_no)
496
+ self._captured.extend(elements)
497
+ return elements
498
+
499
+ async def parse_pages(self, page_images: List[Image.Image]) -> dict:
500
+ self._captured = []
501
+ raw = await super().parse_pages(page_images)
502
+ full_md, markdown_pages = build_markdown(self._captured)
503
+ raw.update({"markdown": full_md, "markdown_pages": markdown_pages})
504
+ return raw
505
+
506
+
507
+ _BINDING_LOCK = threading.Lock()
508
+ _BINDING_DEPTH = 0
509
+ _SAVED_BINDINGS: tuple | None = None
510
+
511
+
512
+ @contextlib.contextmanager
513
+ def _patched_bindings() -> Iterator[None]:
514
+ """Scoped, re-entrant rebinding of the two module globals described in the
515
+ module docstring. The first entering document installs the bindings; the
516
+ last exiting document restores the originals, including on exception."""
517
+ global _BINDING_DEPTH, _SAVED_BINDINGS
518
+ with _BINDING_LOCK:
519
+ if _BINDING_DEPTH == 0:
520
+ _SAVED_BINDINGS = (K.NATIVE_LAYOUT_CATEGORY_MAP, K._NanoEngine)
521
+ K.NATIVE_LAYOUT_CATEGORY_MAP = FLORIN_NATIVE_LAYOUT_CATEGORY_MAP
522
+ K._NanoEngine = _FlorinNanoEngine # type: ignore[misc]
523
+ _BINDING_DEPTH += 1
524
+ try:
525
+ yield
526
+ finally:
527
+ with _BINDING_LOCK:
528
+ _BINDING_DEPTH -= 1
529
+ if _BINDING_DEPTH == 0 and _SAVED_BINDINGS is not None:
530
+ K.NATIVE_LAYOUT_CATEGORY_MAP, K._NanoEngine = _SAVED_BINDINGS # type: ignore[misc]
531
+ _SAVED_BINDINGS = None
532
+
533
+
534
+ @register_provider("florin_parser_nano")
535
+ class FlorinParserNanoProvider(K.KdlFrontierNanoProvider):
536
+ """florin-inc/florin-parser-nano: the ``kdl_frontier_nano`` provider with the
537
+ fine-tuned weights and this module's markdown emission. Serving requirements
538
+ and every inference stage are inherited unchanged."""
539
+
540
+ def __init__(self, provider_name: str, base_config: dict[str, Any] | None = None):
541
+ cfg = dict(base_config or {})
542
+ cfg["endpoint_url"] = (
543
+ cfg.get("endpoint_url") or os.getenv("FLORIN_NANO_ENDPOINT_URL") or ""
544
+ )
545
+ if not cfg["endpoint_url"]:
546
+ raise ProviderConfigError(
547
+ "FLORIN_NANO_ENDPOINT_URL is required (vLLM OpenAI-compatible base "
548
+ "URL ending in /v1, serving florin-inc/florin-parser-nano)."
549
+ )
550
+ cfg["model"] = (
551
+ cfg.get("model") or os.getenv("FLORIN_NANO_MODEL") or "florin-parser-nano"
552
+ )
553
+ super().__init__(provider_name, cfg)
554
+
555
+ def run_inference(
556
+ self, pipeline: PipelineSpec, request: InferenceRequest
557
+ ) -> RawInferenceResult:
558
+ with _patched_bindings():
559
+ return super().run_inference(pipeline, request)