diff --git a/docling/utils/chandra_utils.py b/docling/utils/chandra_utils.py index 9381ee7d39..8ca3247ffb 100644 --- a/docling/utils/chandra_utils.py +++ b/docling/utils/chandra_utils.py @@ -589,7 +589,8 @@ def _block( if re.fullmatch(r"h[1-6]", node.tag): for item in self.doc.texts[before:]: if isinstance(item, SectionHeaderItem): - item.level = int(node.tag[1]) + # Docling section level 1 exports as H2, below the title. + item.level = max(1, int(node.tag[1]) - 1) def _list(self, node: _Element, parent: NodeItem | None) -> None: group = self.doc.add_list_group(parent=parent) diff --git a/docling/utils/nemotron_parse_utils.py b/docling/utils/nemotron_parse_utils.py index 134f794201..933f8a1c18 100644 --- a/docling/utils/nemotron_parse_utils.py +++ b/docling/utils/nemotron_parse_utils.py @@ -311,7 +311,11 @@ def parse_nemotron_parse_v2( ) elif region.label == "Section-header": heading_match = _HEADING_PATTERN.match(raw_text) - level = len(heading_match.group(1)) if heading_match is not None else 1 + level = ( + max(1, len(heading_match.group(1)) - 1) + if heading_match is not None + else 1 + ) document.add_heading( text=_markdown_to_text(raw_text), orig=raw_text, diff --git a/tests/test_chandra_vlm.py b/tests/test_chandra_vlm.py index 35ec235eaf..9ba8f0ce90 100644 --- a/tests/test_chandra_vlm.py +++ b/tests/test_chandra_vlm.py @@ -394,7 +394,7 @@ def test_chandra_inline_math_formatting_and_code_whitespace(): str(next(t.hyperlink for t in doc.texts if t.text == "link")) == "https://example.org/" ) - assert doc.texts[-1].level == 3 + assert doc.texts[-1].level == 2 def test_chandra_nested_math_does_not_inherit_table_bbox(): diff --git a/tests/test_nemotron_parse_v2.py b/tests/test_nemotron_parse_v2.py index 3ff1941cbd..050f1ac09e 100644 --- a/tests/test_nemotron_parse_v2.py +++ b/tests/test_nemotron_parse_v2.py @@ -175,7 +175,7 @@ def test_parse_nemotron_markdown_and_semantic_classes() -> None: assert document.texts[0].label == DocItemLabel.TITLE assert document.texts[0].text == "Main title" assert document.texts[1].label == DocItemLabel.SECTION_HEADER - assert document.texts[1].level == 3 + assert document.texts[1].level == 2 assert document.texts[1].text == "1.2 Method" assert document.texts[2].text == "Article history:\nReceived 12 June" assert any(item.label == DocItemLabel.REFERENCE for item in document.texts) diff --git a/tests/test_vlm_heading_exports.py b/tests/test_vlm_heading_exports.py new file mode 100644 index 0000000000..c41b002aeb --- /dev/null +++ b/tests/test_vlm_heading_exports.py @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +"""Heading depth must survive VLM response parsing and Markdown export.""" + +import pytest +from docling_core.types.doc import ( + DocItemLabel, + DoclingDocument, + SectionHeaderItem, + Size, +) + +from docling.utils.chandra_utils import _add_html_fragment, parse_chandra_html +from docling.utils.nemotron_parse_utils import parse_nemotron_parse_v2 + + +@pytest.mark.parametrize("parser", ["chandra", "nemotron", "html_fragment"]) +@pytest.mark.parametrize("depth", [2, 3, 6]) +def test_heading_depth_survives_export(parser: str, depth: int) -> None: + size = Size(width=600, height=800) + html = ( + '
' + f"Method
" + ) + if parser == "chandra": + doc = parse_chandra_html(html, size, page_no=2) + elif parser == "nemotron": + source = ( + f"{'#' * depth} Method" + ) + doc = parse_nemotron_parse_v2(source, size, size, page_no=2) + else: + doc = DoclingDocument(name="fragment") + doc.add_page(page_no=2, size=size) + _add_html_fragment(doc, html, label=DocItemLabel.TEXT) + + item = doc.texts[0] + assert isinstance(item, SectionHeaderItem) + assert item.level == depth - 1 + assert item.prov[0].page_no == 2 + assert doc.export_to_markdown() == f"{'#' * depth} Method" + + +def test_formatted_html_heading_keeps_one_coordinate_owner() -> None: + doc = parse_chandra_html( + '
' + "

Method and results

", + Size(width=600, height=800), + page_no=1, + ) + assert isinstance(doc.texts[0], SectionHeaderItem) + assert doc.texts[0].level == 2 + assert doc.texts[0].prov + assert all(not item.prov for item in doc.texts[1:]) + assert doc.export_to_markdown() == "### **Method** and results"