From 2181a49c7f3905622b93e8a072bc13bf25bf77e1 Mon Sep 17 00:00:00 2001 From: Pierre Mesure Date: Mon, 19 Oct 2026 22:56:22 +0200 Subject: [PATCH 1/8] fix: preserve multiline DeepSeek and Unlimited OCR blocks Collect text and captions through the next annotation, preserving paragraphs while skipping markup already owned by a table. Fixes #4905. Signed-off-by: Pierre Mesure Signed-off-by: Christoph Auer --- docling/utils/deepseekocr_utils.py | 21 ++++------- tests/test_ocr_multiline_blocks.py | 60 ++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 14 deletions(-) create mode 100644 tests/test_ocr_multiline_blocks.py diff --git a/docling/utils/deepseekocr_utils.py b/docling/utils/deepseekocr_utils.py index 76a39aaca5..01c94bcdb2 100644 --- a/docling/utils/deepseekocr_utils.py +++ b/docling/utils/deepseekocr_utils.py @@ -158,23 +158,16 @@ def _collect_annotation_content( break ii += 1 else: - # Original logic for other labels + # Text and captions can span lines and paragraphs within one annotation. while i < len(lines): - content_line = lines[i].strip() - if content_line: - if re.match(annotation_pattern, content_line): - break - visited_lines.add(i) + if re.match(annotation_pattern, lines[i].strip()): + break + if i not in visited_lines: content_lines.append(lines[i].rstrip()) - i += 1 - if label_str not in ["figure", "image"]: - break - else: - i += 1 - if content_lines: - break + visited_lines.add(i) + i += 1 - return "\n".join(content_lines), i + return "\n".join(content_lines).strip("\n"), i def _process_annotation_item( diff --git a/tests/test_ocr_multiline_blocks.py b/tests/test_ocr_multiline_blocks.py new file mode 100644 index 0000000000..2cfb589c16 --- /dev/null +++ b/tests/test_ocr_multiline_blocks.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +import pytest +from docling_core.types.doc import DocItemLabel, Size + +from docling.utils.deepseekocr_utils import ( + parse_deepseekocr_markdown, + parse_unlimited_ocr_markdown, +) + + +@pytest.mark.parametrize("parser", ["deepseek", "unlimited"]) +def test_text_block_keeps_continuation_lines_and_following_table(parser: str) -> None: + def marker(label: str, top: int) -> str: + if parser == "deepseek": + return f"{label}[[100,{top},900,{top + 100}]]\n" + return f"<|det|>{label} [100,{top},900,{top + 100}]<|/det|>" + + source = ( + marker("text", 100) + + "First line\nSecond line\n\nThird paragraph\n" + + marker("table", 400) + + "
Total
\n" + + marker("table_caption", 600) + + "Caption first line\nCaption second line\n" + + marker("text", 800) + + "Final block" + ) + parse = ( + parse_deepseekocr_markdown + if parser == "deepseek" + else parse_unlimited_ocr_markdown + ) + doc = parse(source, Size(width=600, height=800), page_no=2) + text = next(item for item in doc.texts if item.label == DocItemLabel.TEXT) + assert text.text == "First line\nSecond line\n\nThird paragraph" + assert text.prov[0].page_no == 2 + assert len(doc.tables) == 1 + assert doc.tables[0].data.grid[0][0].text == "Total" + caption = doc.tables[0].captions[0].resolve(doc) + assert caption.text == "Caption first line\nCaption second line" + assert doc.texts[-1].text == "Final block" + assert "Second line" in doc.export_to_markdown() + + +def test_caption_does_not_claim_table_markup_already_collected() -> None: + source = ( + "table[[100,100,900,300]]\n" + "table_caption[[100,400,900,500]]\nCaption first\nCaption second\n\n" + "
Total
\n\n" + "text[[100,600,900,700]]\nFinal block" + ) + doc = parse_deepseekocr_markdown(source, Size(width=600, height=800), page_no=1) + assert doc.tables[0].data.grid[0][0].text == "Total" + assert ( + doc.tables[0].captions[0].resolve(doc).text == "Caption first\nCaption second" + ) + assert all("" not in item.text for item in doc.texts) + assert doc.texts[-1].text == "Final block" From f4860d607f71bb1053edba89a5c97fabfc1a3218 Mon Sep 17 00:00:00 2001 From: Pierre Mesure Date: Mon, 19 Oct 2026 22:56:21 +0200 Subject: [PATCH 2/8] fix: preserve VLM section heading depth in exports Correct the shared HTML mapper and Nemotron native heading conversion. Add export and provenance regressions. Fixes #4902. Signed-off-by: Pierre Mesure Signed-off-by: Christoph Auer --- docling/utils/chandra_utils.py | 3 +- docling/utils/nemotron_parse_utils.py | 6 ++- tests/test_chandra_vlm.py | 2 +- tests/test_nemotron_parse_v2.py | 2 +- tests/test_vlm_heading_exports.py | 56 +++++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 tests/test_vlm_heading_exports.py diff --git a/docling/utils/chandra_utils.py b/docling/utils/chandra_utils.py index d841630c88..d08d59253d 100644 --- a/docling/utils/chandra_utils.py +++ b/docling/utils/chandra_utils.py @@ -620,7 +620,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 0cb8725204..b54f79a1b5 100644 --- a/tests/test_chandra_vlm.py +++ b/tests/test_chandra_vlm.py @@ -405,7 +405,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" From 925df096c982b1b51e5e6c212e6919af6b5220d2 Mon Sep 17 00:00:00 2001 From: Pierre Mesure Date: Mon, 19 Oct 2026 22:56:21 +0200 Subject: [PATCH 3/8] fix: normalize Nemotron formula delimiters before export Use a shared LaTeX delimiter normalizer and keep the raw response in orig. Verify all delimiter styles and multiline equations. Fixes #4903. Signed-off-by: Pierre Mesure Signed-off-by: Christoph Auer --- docling/utils/nemotron_parse_utils.py | 3 ++- docling/utils/vlm_utils.py | 13 ++++++++++ tach.toml | 2 +- tests/test_nemotron_formula_exports.py | 34 ++++++++++++++++++++++++++ tests/test_nemotron_parse_v2.py | 2 +- 5 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 tests/test_nemotron_formula_exports.py diff --git a/docling/utils/nemotron_parse_utils.py b/docling/utils/nemotron_parse_utils.py index 933f8a1c18..187e2e98b8 100644 --- a/docling/utils/nemotron_parse_utils.py +++ b/docling/utils/nemotron_parse_utils.py @@ -31,6 +31,7 @@ from docling.backend.md_backend import MarkdownDocumentBackend from docling.datamodel.base_models import InputFormat from docling.datamodel.document import InputDocument +from docling.utils.vlm_utils import strip_math_delimiters _log = logging.getLogger(__name__) @@ -325,7 +326,7 @@ def parse_nemotron_parse_v2( else: label = _TEXT_LABELS.get(region.label, DocItemLabel.TEXT) text = ( - raw_text + strip_math_delimiters(raw_text) if label == DocItemLabel.FORMULA else _markdown_to_text(raw_text) ) diff --git a/docling/utils/vlm_utils.py b/docling/utils/vlm_utils.py index d3ed9c5d11..1c8f0ed42e 100644 --- a/docling/utils/vlm_utils.py +++ b/docling/utils/vlm_utils.py @@ -71,6 +71,19 @@ def parse_markdown_heading(text: str) -> tuple[str, int | None]: return match.group(2), len(match.group(1)) +def strip_math_delimiters(text: str) -> str: + """Remove one matching outer math pair without interpreting its LaTeX.""" + text = text.strip() + for opening, closing in (("$$", "$$"), (r"\[", r"\]"), (r"\(", r"\)"), ("$", "$")): + if ( + len(text) >= len(opening) + len(closing) + and text.startswith(opening) + and text.endswith(closing) + ): + return text[len(opening) : -len(closing)].strip() + return text + + def strip_stop_strings(texts: list[str], stop_strings: list[str]) -> list[str]: """Strip stop strings from decoded texts. diff --git a/tach.toml b/tach.toml index b8635b2581..8e2d8f2c2a 100644 --- a/tach.toml +++ b/tach.toml @@ -185,7 +185,7 @@ depends_on = [] [[modules]] path = "docling.utils.nemotron_parse_utils" layer = "core" -depends_on = ["docling.backend", "docling.datamodel"] +depends_on = ["docling.backend", "docling.datamodel", "docling.utils"] [[modules]] path = "docling.utils" diff --git a/tests/test_nemotron_formula_exports.py b/tests/test_nemotron_formula_exports.py new file mode 100644 index 0000000000..da994ba0e0 --- /dev/null +++ b/tests/test_nemotron_formula_exports.py @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +import pytest +from docling_core.types.doc import FormulaItem, Size + +from docling.utils.nemotron_parse_utils import parse_nemotron_parse_v2 + + +@pytest.mark.parametrize( + "opening, closing", + [("$", "$"), ("$$", "$$"), (r"\(", r"\)"), (r"\[", r"\]"), ("", "")], +) +def test_formula_export_has_one_delimiter_pair(opening: str, closing: str) -> None: + latex = r"\left\{x_1 * y_2 : x > 0\right\}" + raw = f"{opening}{latex}{closing}" + source = f"{raw}" + size = Size(width=600, height=800) + doc = parse_nemotron_parse_v2(source, size, size, page_no=1) + formula = doc.texts[0] + assert isinstance(formula, FormulaItem) + assert formula.text == latex + assert formula.orig == raw + assert formula.prov + assert doc.export_to_markdown() == f"$${latex}$$" + + +def test_multiline_formula_keeps_latex_and_line_breaks() -> None: + latex = "\\begin{aligned}\nx &= y \\\\\nz &= w\n\\end{aligned}" + source = f"$$\n{latex}\n$$" + size = Size(width=600, height=800) + doc = parse_nemotron_parse_v2(source, size, size, page_no=1) + assert doc.texts[0].text == latex + assert doc.export_to_markdown() == f"$${latex}$$" diff --git a/tests/test_nemotron_parse_v2.py b/tests/test_nemotron_parse_v2.py index 050f1ac09e..44654724c1 100644 --- a/tests/test_nemotron_parse_v2.py +++ b/tests/test_nemotron_parse_v2.py @@ -183,7 +183,7 @@ def test_parse_nemotron_markdown_and_semantic_classes() -> None: item.text for item in document.texts if item.label == DocItemLabel.LIST_ITEM ] == ["First item", "Second item"] assert any( - item.label == DocItemLabel.FORMULA and item.text == r"\(x^2\)" + item.label == DocItemLabel.FORMULA and item.text == r"x^2" for item in document.texts ) assert len(document.pictures) == 2 From 9341be3e118fae48b710e45d99316e5f96bf1928 Mon Sep 17 00:00:00 2001 From: Christoph Auer Date: Thu, 22 Oct 2026 10:30:02 +0200 Subject: [PATCH 4/8] fix(vlm): share the Nemotron 3.5 formula delimiter normalizer strip_math_delimiters removed one outer pair only when the text started and ended with it. For formulas with several segments it left unbalanced delimiters: "\(x_1=a\), \(x_2=b\)" became "x_1=a\), \(x_2=b", and "$a$ + $b$" became "a$ + $b". The Nemotron 3.5 mapper already handles these cases. Move its normalizer to vlm_utils as strip_math_delimiters, so the Nemotron Parse 2.0 and 3.5 mappers use the same function. It removes every \( and \), keeps \\, and then removes outer \[...\], $$...$$ or $...$ pairs that enclose the whole text. Signed-off-by: Christoph Auer --- docling/utils/nemotron_parse_v3_5_utils.py | 27 +++++----------------- docling/utils/vlm_utils.py | 26 +++++++++++++-------- tests/test_nemotron_formula_exports.py | 9 ++++++++ 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/docling/utils/nemotron_parse_v3_5_utils.py b/docling/utils/nemotron_parse_v3_5_utils.py index 7e34af4c1e..952ee2b4d1 100644 --- a/docling/utils/nemotron_parse_v3_5_utils.py +++ b/docling/utils/nemotron_parse_v3_5_utils.py @@ -49,7 +49,11 @@ _markdown_inline_html, _unescape_markdown_literals, ) -from docling.utils.vlm_utils import link_adjacent_captions, parse_markdown_heading +from docling.utils.vlm_utils import ( + link_adjacent_captions, + parse_markdown_heading, + strip_math_delimiters, +) _log = logging.getLogger(__name__) @@ -83,11 +87,6 @@ } _CHART_OPEN = re.compile(r'') _CHART_TABLE_KEY = re.compile(r"table\[(\w+)\]") -# `\(` and `\)` are not math symbols, so in a formula they are delimiters; `\\` stays. -_INLINE_MATH_DELIMITER = re.compile(r"\\\\|\\[()]") -_OUTER_MATH = re.compile( - r"\$\$((?:(?!\$\$).)*)\$\$|\\\[((?:(?!\\[\[\]]).)*)\\\]|\$([^$]*)\$", re.DOTALL -) @dataclass(frozen=True) @@ -150,20 +149,6 @@ def _page_bbox(bbox: tuple[float, float, float, float], size: Size) -> BoundingB ) -def _formula_latex(text: str) -> str: - """Formula LaTeX without math delimiters; the serializer adds its own. - - The model wraps each segment in ``\\(...\\)``, often with text between them - (``(6) \\(x_1=a\\), \\(x_2=b\\)``), and leaves single ones in truncated output. - """ - text = _INLINE_MATH_DELIMITER.sub( - lambda match: match.group() if match.group() == "\\\\" else "", text - ).strip() - while (outer := _OUTER_MATH.fullmatch(text)) is not None: - text = next(group for group in outer.groups() if group is not None).strip() - return text - - def _add_rich_text( doc: DoclingDocument, text: str, @@ -560,7 +545,7 @@ def parse_nemotron_parse_v3_5( ): heading.text = _unescape_markdown_literals(clean_text) elif block.label == "Formula": - doc.add_formula(text=_formula_latex(text), orig=text, prov=prov) + doc.add_formula(text=strip_math_delimiters(text), orig=text, prov=prov) elif block.label == "Code": doc.add_code( text=text, diff --git a/docling/utils/vlm_utils.py b/docling/utils/vlm_utils.py index 1c8f0ed42e..5a2a88056e 100644 --- a/docling/utils/vlm_utils.py +++ b/docling/utils/vlm_utils.py @@ -71,16 +71,24 @@ def parse_markdown_heading(text: str) -> tuple[str, int | None]: return match.group(2), len(match.group(1)) +# `\(` and `\)` are not math symbols, so in a formula they are delimiters; `\\` stays. +_INLINE_MATH_DELIMITER = re.compile(r"\\\\|\\[()]") +_OUTER_MATH = re.compile( + r"\$\$((?:(?!\$\$).)*)\$\$|\\\[((?:(?!\\[\[\]]).)*)\\\]|\$([^$]*)\$", re.DOTALL +) + + def strip_math_delimiters(text: str) -> str: - """Remove one matching outer math pair without interpreting its LaTeX.""" - text = text.strip() - for opening, closing in (("$$", "$$"), (r"\[", r"\]"), (r"\(", r"\)"), ("$", "$")): - if ( - len(text) >= len(opening) + len(closing) - and text.startswith(opening) - and text.endswith(closing) - ): - return text[len(opening) : -len(closing)].strip() + """Formula LaTeX without math delimiters; the serializer adds its own. + + VLMs can wrap each segment in ``\\(...\\)``, often with text between them + (``(6) \\(x_1=a\\), \\(x_2=b\\)``), and leave single ones in truncated output. + """ + text = _INLINE_MATH_DELIMITER.sub( + lambda match: match.group() if match.group() == "\\\\" else "", text + ).strip() + while (outer := _OUTER_MATH.fullmatch(text)) is not None: + text = next(group for group in outer.groups() if group is not None).strip() return text diff --git a/tests/test_nemotron_formula_exports.py b/tests/test_nemotron_formula_exports.py index da994ba0e0..63da5c3761 100644 --- a/tests/test_nemotron_formula_exports.py +++ b/tests/test_nemotron_formula_exports.py @@ -32,3 +32,12 @@ def test_multiline_formula_keeps_latex_and_line_breaks() -> None: doc = parse_nemotron_parse_v2(source, size, size, page_no=1) assert doc.texts[0].text == latex assert doc.export_to_markdown() == f"$${latex}$$" + + +def test_formula_segments_lose_every_inline_delimiter() -> None: + raw = r"(6) \(x_1=a\), \(x_2=b\)" + source = f"{raw}" + size = Size(width=600, height=800) + doc = parse_nemotron_parse_v2(source, size, size, page_no=1) + assert doc.texts[0].text == "(6) x_1=a, x_2=b" + assert doc.texts[0].orig == raw From 1f4c787fff3765d30a86b3311656c92b6b90db45 Mon Sep 17 00:00:00 2001 From: Christoph Auer Date: Thu, 22 Oct 2026 11:24:32 +0200 Subject: [PATCH 5/8] fix(vlm): keep every markup heading depth distinct across VLM mappers The layout label alone decides title versus section header. Each mapper now stores the raw markup depth of a section header: "#" x N or gives level N. A section header without markup gets depth 2, like "##". The new normalize_section_header_levels() then runs once per document. If no section header has depth 1, all levels go up one step, so every heading exports with its source depth. Otherwise the levels stay, so two source depths never share one level. Depth 1 does not become a title. Models use depth-1 section headers for chapter and part headings, and often use depth 1 and depth 2 on the same page. The old "max(1, N - 1)" rule merged those two depths into one level. The rule is per document, not per page. The VLM pipeline maps pages with normalize_heading_levels=False and normalizes the joined document once. So one depth gets the same level on all pages. Standalone mapper calls normalize their single page by default. Changes in output versus origin/main: - dots, Nemotron Parse 3.5, DeepSeek-OCR, Unlimited-OCR, PaddleOCR-VL: pages with depth 1 keep "#" and "##" distinct (before: both became level 1). Pages without depth 1 do not change. - Chandra and Nemotron Parse 2.0: pages without depth 1 now export the source depth (before: one "#" too deep). A section header without markup now sorts with "##" (before: level 1 next to "#"). - Shared HTML fragment helper: it now only records raw depth; the calling mapper normalizes. Signed-off-by: Christoph Auer --- docling/pipeline/vlm_pipeline.py | 29 ++- docling/utils/chandra_utils.py | 18 +- docling/utils/deepseekocr_utils.py | 17 +- docling/utils/dots_utils.py | 15 +- docling/utils/nemotron_parse_utils.py | 23 ++- docling/utils/nemotron_parse_v3_5_utils.py | 15 +- docling/utils/paddleocr_vl_utils.py | 17 +- docling/utils/vlm_utils.py | 19 ++ docs/usage/vision_models.md | 11 + tests/test_vlm_heading_exports.py | 227 ++++++++++++++++++--- 10 files changed, 342 insertions(+), 49 deletions(-) diff --git a/docling/pipeline/vlm_pipeline.py b/docling/pipeline/vlm_pipeline.py index 88de8a19aa..af687510ba 100644 --- a/docling/pipeline/vlm_pipeline.py +++ b/docling/pipeline/vlm_pipeline.py @@ -69,9 +69,21 @@ from docling.pipeline.base_pipeline import PaginatedPipeline from docling.utils.deepseekocr_utils import parse_deepseekocr_markdown from docling.utils.profiling import TimeRecorder +from docling.utils.vlm_utils import normalize_section_header_levels _log = logging.getLogger(__name__) _DOCLANG_OPEN_RE = re.compile(r"]*)?>") +# Their mappers keep raw heading depths per page; the joined document is normalized. +_RAW_HEADING_DEPTH_FORMATS = frozenset( + { + ResponseFormat.CHANDRA_HTML, + ResponseFormat.DEEPSEEKOCR_MARKDOWN, + ResponseFormat.DOTS_JSON, + ResponseFormat.NEMOTRON_PARSE_V2, + ResponseFormat.NEMOTRON_PARSE_V3_5, + ResponseFormat.UNLIMITED_OCR_MARKDOWN, + } +) class VlmPipeline(PaginatedPipeline): @@ -353,7 +365,7 @@ def _build_document(self, conv_res: ConversionResult) -> ConversionResult: for page_no in expected_page_nos if page_no in processed_page_nos ] - conv_res.document = self._concatenate_page_documents( + conv_res.document = self._join_page_documents( [ (page_no, page_documents[page_no]) for page_no in expected_page_nos @@ -362,6 +374,15 @@ def _build_document(self, conv_res: ConversionResult) -> ConversionResult: ) return conv_res + def _join_page_documents( + self, page_documents: list[tuple[int, DoclingDocument]] + ) -> DoclingDocument: + document = self._concatenate_page_documents(page_documents) + if self._response_format() in _RAW_HEADING_DEPTH_FORMATS: + # Once per document, so a heading depth has one level on all pages. + normalize_section_header_levels(document) + return document + def _process_page_batch( self, *, @@ -525,6 +546,7 @@ def _finalize_page_document( page_no=page.page_no, filename=conv_res.input.file.name or "file", page_image=page.image, + normalize_heading_levels=False, ) elif response_format == ResponseFormat.UNLIMITED_OCR_MARKDOWN: from docling.utils.deepseekocr_utils import parse_unlimited_ocr_markdown @@ -535,6 +557,7 @@ def _finalize_page_document( page_no=page.page_no, filename=conv_res.input.file.name or "file", page_image=page.image, + normalize_heading_levels=False, ) elif response_format == ResponseFormat.MARKDOWN: document = self._convert_text_page( @@ -554,6 +577,7 @@ def _finalize_page_document( page_no=page.page_no, filename=conv_res.input.file.name or "file", page_image=page.image, + normalize_heading_levels=False, ) except ValueError as exc: conv_res.errors.append( @@ -585,6 +609,7 @@ def _finalize_page_document( page_no=page.page_no, filename=conv_res.input.file.name or "file", page_image=page.image, + normalize_heading_levels=False, ) elif response_format == ResponseFormat.MINERU2: from docling.utils.mineru_utils import parse_mineru2 @@ -703,6 +728,7 @@ def _dots_page_document( filename=conv_res.input.file.name or "file", page_image=page_image, model_image_size=model_image_size, + normalize_heading_levels=False, ) def _nemotron_parse_v2_page_document( @@ -740,6 +766,7 @@ def _nemotron_parse_v2_page_document( page_no=page.page_no, filename=conv_res.input.file.name or "file", page_image=page_image, + normalize_heading_levels=False, ) def _extract_code_block(self, text: str) -> str: diff --git a/docling/utils/chandra_utils.py b/docling/utils/chandra_utils.py index d08d59253d..ece606d134 100644 --- a/docling/utils/chandra_utils.py +++ b/docling/utils/chandra_utils.py @@ -41,6 +41,7 @@ from pydantic import AnyUrl, ValidationError from docling.utils.code_language import detect_code_language +from docling.utils.vlm_utils import normalize_section_header_levels _log = logging.getLogger(__name__) @@ -435,6 +436,7 @@ def _emit_runs( run.text = " ".join(run.text.split()) if not merged: return + before = len(self.doc.texts) layer = ( ContentLayer.FURNITURE if label in {DocItemLabel.PAGE_HEADER, DocItemLabel.PAGE_FOOTER} @@ -501,6 +503,10 @@ def _emit_runs( ) if run_label == DocItemLabel.FIELD_VALUE: item.kind = "fillable" + for item in self.doc.texts[before:]: + if isinstance(item, SectionHeaderItem): + # Without , a section header has raw depth 2, like "##". + item.level = 2 def walk( self, @@ -620,8 +626,8 @@ def _block( if re.fullmatch(r"h[1-6]", node.tag): for item in self.doc.texts[before:]: if isinstance(item, SectionHeaderItem): - # Docling section level 1 exports as H2, below the title. - item.level = max(1, int(node.tag[1]) - 1) + # Raw markup depth, normalized per document later. + item.level = int(node.tag[1]) def _list(self, node: _Element, parent: NodeItem | None) -> None: group = self.doc.add_list_group(parent=parent) @@ -797,6 +803,8 @@ def parse_chandra_html( page_no: int, filename: str = "file", page_image: PILImage.Image | None = None, + *, + normalize_heading_levels: bool = True, ) -> DoclingDocument: """Map Chandra layout blocks and their HTML contents to document primitives. @@ -804,7 +812,9 @@ def parse_chandra_html( block; derived children remain unlocated unless they declare their own bbox. Bare HTML is recovered without provenance; non-HTML responses raise ValueError. Caption/footnote links require explicit nesting. Separate layout blocks are - neither associated nor merged by proximity. + neither associated nor merged by proximity. Set ``normalize_heading_levels`` + to False to keep raw heading depths for a later document-wide + :func:`normalize_section_header_levels`. """ doc = DoclingDocument( name=Path(filename).stem, @@ -856,4 +866,6 @@ def parse_chandra_html( block.attrs.get("data-label") == "Blank-Page" for block in blocks ): raise ValueError("Chandra HTML contains no document content") + if normalize_heading_levels: + normalize_section_header_levels(doc) return doc diff --git a/docling/utils/deepseekocr_utils.py b/docling/utils/deepseekocr_utils.py index 01c94bcdb2..48b0f38b28 100644 --- a/docling/utils/deepseekocr_utils.py +++ b/docling/utils/deepseekocr_utils.py @@ -24,6 +24,8 @@ from lxml import etree from PIL import Image as PILImage +from docling.utils.vlm_utils import normalize_section_header_levels + _log = logging.getLogger(__name__) @@ -207,7 +209,8 @@ def _process_annotation_item( clean_content = content[hash_count:].strip() page_doc.add_title(text=clean_content, prov=prov) elif label_str == "sub_title": - heading_level = 1 + # Raw markup depth, normalized per document later. + heading_level = 2 clean_content = content if content.startswith("#"): hash_count = 0 @@ -216,8 +219,7 @@ def _process_annotation_item( hash_count += 1 else: break - if hash_count > 1: - heading_level = hash_count - 1 + heading_level = hash_count clean_content = content[hash_count:].strip() page_doc.add_heading(text=clean_content, level=heading_level, prov=prov) else: @@ -230,6 +232,8 @@ def parse_deepseekocr_markdown( page_no: int, filename: str = "file", page_image: Optional[PILImage.Image] = None, + *, + normalize_heading_levels: bool = True, ) -> DoclingDocument: """Parse DeepSeek OCR markdown with label[[x1, y1, x2, y2]] format. @@ -252,6 +256,8 @@ def parse_deepseekocr_markdown( page_no: Page number (1-based). filename: Source filename. page_image: Optional PIL image of the page. + normalize_heading_levels: If False, keep raw heading depths for a later + document-wide :func:`normalize_section_header_levels`. Returns: DoclingDocument with parsed content @@ -382,6 +388,8 @@ def parse_deepseekocr_markdown( label_str, content_text, prov, caption_item, page_doc, label_map ) + if normalize_heading_levels: + normalize_section_header_levels(page_doc) return page_doc @@ -412,6 +420,8 @@ def parse_unlimited_ocr_markdown( page_no: int, filename: str = "file", page_image: Optional[PILImage.Image] = None, + *, + normalize_heading_levels: bool = True, ) -> DoclingDocument: """Parse Unlimited-OCR grounding output into a :class:`DoclingDocument`. @@ -424,4 +434,5 @@ def parse_unlimited_ocr_markdown( page_no=page_no, filename=filename, page_image=page_image, + normalize_heading_levels=normalize_heading_levels, ) diff --git a/docling/utils/dots_utils.py b/docling/utils/dots_utils.py index 0002182379..d944eb9ad7 100644 --- a/docling/utils/dots_utils.py +++ b/docling/utils/dots_utils.py @@ -43,7 +43,11 @@ from docling.utils.chandra_utils import _add_html_fragment, _add_html_table from docling.utils.code_language import detect_code_language -from docling.utils.vlm_utils import link_adjacent_captions, parse_markdown_heading +from docling.utils.vlm_utils import ( + link_adjacent_captions, + normalize_section_header_levels, + parse_markdown_heading, +) _log = logging.getLogger(__name__) @@ -213,6 +217,8 @@ def parse_dots_json( filename: str = "file", page_image: PILImage.Image | None = None, model_image_size: Size | None = None, + *, + normalize_heading_levels: bool = True, ) -> DoclingDocument: """Parse dots.ocr / dots.mocr JSON output into a DoclingDocument. @@ -224,6 +230,8 @@ def parse_dots_json( page_image: Optional PIL image of the page. model_image_size: If provided, bbox pixel coords are rescaled from this resolution to *original_page_size*. + normalize_heading_levels: If False, keep raw heading depths for a later + document-wide :func:`normalize_section_header_levels`. Returns: DoclingDocument populated with parsed elements. @@ -332,7 +340,8 @@ def parse_dots_json( item = doc.add_heading( text="", orig=text, - level=max(1, markdown_level - 1) if markdown_level is not None else 1, + # Raw markup depth, normalized per document later. + level=markdown_level if markdown_level is not None else 2, prov=prov, ) if not _add_inline_text( @@ -371,4 +380,6 @@ def parse_dots_json( ) link_adjacent_captions(doc, block_starts, caption_owners) + if normalize_heading_levels: + normalize_section_header_levels(doc) return doc diff --git a/docling/utils/nemotron_parse_utils.py b/docling/utils/nemotron_parse_utils.py index 187e2e98b8..5079b26b92 100644 --- a/docling/utils/nemotron_parse_utils.py +++ b/docling/utils/nemotron_parse_utils.py @@ -31,7 +31,10 @@ from docling.backend.md_backend import MarkdownDocumentBackend from docling.datamodel.base_models import InputFormat from docling.datamodel.document import InputDocument -from docling.utils.vlm_utils import strip_math_delimiters +from docling.utils.vlm_utils import ( + normalize_section_header_levels, + strip_math_delimiters, +) _log = logging.getLogger(__name__) @@ -256,8 +259,14 @@ def parse_nemotron_parse_v2( page_no: int, filename: str = "file", page_image: PILImage.Image | None = None, + *, + normalize_heading_levels: bool = True, ) -> DoclingDocument: - """Parse one page of Nemotron Parse 2.0 native output.""" + """Parse one page of Nemotron Parse 2.0 native output. + + Set ``normalize_heading_levels`` to False to keep raw heading depths for a + later document-wide :func:`normalize_section_header_levels`. + """ origin = DocumentOrigin(filename=filename, mimetype="text/plain", binary_hash=0) document = DoclingDocument(name=filename.rsplit(".", 1)[0], origin=origin) image_dpi = 72 @@ -312,15 +321,11 @@ def parse_nemotron_parse_v2( ) elif region.label == "Section-header": heading_match = _HEADING_PATTERN.match(raw_text) - 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, - level=level, + # Raw markup depth, normalized per document later. + level=len(heading_match.group(1)) if heading_match is not None else 2, prov=provenance, ) else: @@ -337,4 +342,6 @@ def parse_nemotron_parse_v2( prov=provenance, ) + if normalize_heading_levels: + normalize_section_header_levels(document) return document diff --git a/docling/utils/nemotron_parse_v3_5_utils.py b/docling/utils/nemotron_parse_v3_5_utils.py index 952ee2b4d1..832e5e0e33 100644 --- a/docling/utils/nemotron_parse_v3_5_utils.py +++ b/docling/utils/nemotron_parse_v3_5_utils.py @@ -51,6 +51,7 @@ ) from docling.utils.vlm_utils import ( link_adjacent_captions, + normalize_section_header_levels, parse_markdown_heading, strip_math_delimiters, ) @@ -455,8 +456,14 @@ def parse_nemotron_parse_v3_5( page_no: int, filename: str = "file", page_image: PILImage.Image | None = None, + *, + normalize_heading_levels: bool = True, ) -> DoclingDocument: - """Parse one page of Nemotron 3.5 Parse native output.""" + """Parse one page of Nemotron 3.5 Parse native output. + + Set ``normalize_heading_levels`` to False to keep raw heading depths for a + later document-wide :func:`normalize_section_header_levels`. + """ doc = DoclingDocument( name=filename.rsplit(".", 1)[0], origin=DocumentOrigin(filename=filename, mimetype="text/plain", binary_hash=0), @@ -529,14 +536,14 @@ def parse_nemotron_parse_v3_5( ) elif block.label in {"Title", "Section-header"}: clean_text, level = parse_markdown_heading(text) - # Markdown "##" is a level-1 section below the "#" title, as in dots. heading = ( doc.add_title(text="", orig=text, prov=prov) if block.label == "Title" else doc.add_heading( text="", orig=text, - level=max(1, level - 1) if level is not None else 1, + # Raw markup depth, normalized per document later. + level=level if level is not None else 2, prov=prov, ) ) @@ -564,4 +571,6 @@ def parse_nemotron_parse_v3_5( ) link_adjacent_captions(doc, block_starts, caption_owners) + if normalize_heading_levels: + normalize_section_header_levels(doc) return doc diff --git a/docling/utils/paddleocr_vl_utils.py b/docling/utils/paddleocr_vl_utils.py index 6b3d05241e..22d10f5117 100644 --- a/docling/utils/paddleocr_vl_utils.py +++ b/docling/utils/paddleocr_vl_utils.py @@ -39,7 +39,10 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator from docling.utils.chandra_utils import _add_html_table -from docling.utils.vlm_utils import parse_markdown_heading +from docling.utils.vlm_utils import ( + normalize_section_header_levels, + parse_markdown_heading, +) _log = logging.getLogger(__name__) @@ -253,13 +256,12 @@ def _add_block( text, _ = parse_markdown_heading(text) doc.add_title(text=text, prov=prov) elif label == "paragraph_title": - heading_level = 1 + # Raw markup depth, normalized per document later. + heading_level = 2 if formatted_content: text, markdown_level = parse_markdown_heading(text) if markdown_level is not None: - # Paddle reserves Markdown H1 for ``doc_title``. Docling's - # section level 1 is exported as H2, so remove that offset. - heading_level = max(1, markdown_level - 1) + heading_level = markdown_level doc.add_heading(text=text, level=heading_level, prov=prov) elif label == "table": if _add_html_table(doc, text, prov) is None: @@ -294,6 +296,7 @@ def parse_paddleocr_vl_result( *, filename: str | None = None, page_no: int | None = None, + normalize_heading_levels: bool = True, ) -> DoclingDocument: """Convert one saved PaddleOCR-VL page result into a ``DoclingDocument``. @@ -307,6 +310,8 @@ def parse_paddleocr_vl_result( 0-based ``page_index`` is converted to Docling's 1-based numbering. This cannot make a concatenated multi-page payload valid; those results remain unsupported and must be parsed per page. + normalize_heading_levels: If False, keep raw heading depths for a later + document-wide :func:`normalize_section_header_levels`. Returns: A single-page ``DoclingDocument`` with typed items and provenance in @@ -336,4 +341,6 @@ def parse_paddleocr_vl_result( for block in page.parsing_res_list: _add_block(doc, block, page=page, page_no=resolved_page_no) + if normalize_heading_levels: + normalize_section_header_levels(doc) return doc diff --git a/docling/utils/vlm_utils.py b/docling/utils/vlm_utils.py index 5a2a88056e..016674911d 100644 --- a/docling/utils/vlm_utils.py +++ b/docling/utils/vlm_utils.py @@ -12,6 +12,7 @@ DocItemLabel, DoclingDocument, FloatingItem, + SectionHeaderItem, Size, TextItem, ) @@ -71,6 +72,24 @@ def parse_markdown_heading(text: str) -> tuple[str, int | None]: return match.group(2), len(match.group(1)) +def normalize_section_header_levels(doc: DoclingDocument) -> None: + """Turn the raw heading depths of a whole document into section levels. + + VLM mappers store a section header with N Markdown ``#`` or HTML ```` + at level N, and one without markup at level 2 (like ``##``). Docling exports + section level N one step deeper, below the title. If no section header has + level 1, all levels go up one step, so exports keep the source depth. + Otherwise nothing changes, so two source depths never share one level. + The layout label alone decides title versus section header. Call this once + per document, after pages are joined, so a depth maps to one level on all + pages. ``doc.texts`` holds every text item, in every layer and container. + """ + headers = [item for item in doc.texts if isinstance(item, SectionHeaderItem)] + if all(item.level > 1 for item in headers): + for item in headers: + item.level -= 1 + + # `\(` and `\)` are not math symbols, so in a formula they are delimiters; `\\` stays. _INLINE_MATH_DELIMITER = re.compile(r"\\\\|\\[()]") _OUTER_MATH = re.compile( diff --git a/docs/usage/vision_models.md b/docs/usage/vision_models.md index 338058c083..83019ee6cf 100644 --- a/docs/usage/vision_models.md +++ b/docs/usage/vision_models.md @@ -223,3 +223,14 @@ invented coordinates. Invalid bounding boxes also produce a warning while their content is retained. Nonempty prose or JSON responses without HTML transcription produce a page-specific inference error and `PARTIAL_SUCCESS`, rather than an unreported empty result. An explicitly labeled `Blank-Page` may be empty. + +## Heading depths in native layout output + +For Chandra, dots, Nemotron Parse, DeepSeek-OCR, Unlimited-OCR, and PaddleOCR-VL +output, the layout label alone decides title versus section header. Docling +exports a title as `#` and a section header one step below it. If no section +header in the document uses depth 1 (`#` or `

`), each section header exports +with its source depth: `##` stays `##`. Otherwise each one exports one step +deeper, so different source depths never share one level. A section header +without heading markup counts as `##`. The pipeline applies this rule once to the +whole document, so one depth gets the same level on all pages. diff --git a/tests/test_vlm_heading_exports.py b/tests/test_vlm_heading_exports.py index c41b002aeb..b75d5b0420 100644 --- a/tests/test_vlm_heading_exports.py +++ b/tests/test_vlm_heading_exports.py @@ -3,43 +3,222 @@ """Heading depth must survive VLM response parsing and Markdown export.""" +import json +from pathlib import PurePath +from types import SimpleNamespace + import pytest from docling_core.types.doc import ( DocItemLabel, DoclingDocument, SectionHeaderItem, Size, + TitleItem, ) +from docling.datamodel.base_models import Page, PagePredictions, VlmPrediction +from docling.datamodel.pipeline_options_vlm_model import ( + InferenceFramework, + InlineVlmOptions, + ResponseFormat, +) +from docling.pipeline.vlm_pipeline import VlmPipeline from docling.utils.chandra_utils import _add_html_fragment, parse_chandra_html +from docling.utils.deepseekocr_utils import ( + parse_deepseekocr_markdown, + parse_unlimited_ocr_markdown, +) +from docling.utils.dots_utils import parse_dots_json from docling.utils.nemotron_parse_utils import parse_nemotron_parse_v2 +from docling.utils.nemotron_parse_v3_5_utils import parse_nemotron_parse_v3_5 +from docling.utils.paddleocr_vl_utils import parse_paddleocr_vl_result +_SIZE = Size(width=600, height=800) +# A block is (is_title, markup depth or None for no heading markup). +_Block = tuple[bool, int | None] +_FORMATS = { + "chandra": ResponseFormat.CHANDRA_HTML, + "dots": ResponseFormat.DOTS_JSON, + "nemotron_v2": ResponseFormat.NEMOTRON_PARSE_V2, + "nemotron_v3_5": ResponseFormat.NEMOTRON_PARSE_V3_5, + "deepseek": ResponseFormat.DEEPSEEKOCR_MARKDOWN, + "unlimited": ResponseFormat.UNLIMITED_OCR_MARKDOWN, +} +_PARSERS = [*_FORMATS, "paddle"] -@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
" - ) + +def _markdown(index: int, depth: int | None) -> str: + return f"{'#' * depth} H{index}" if depth is not None else f"H{index}" + + +def _response(parser: str, blocks: list[_Block]) -> str: + """Render one page of model output with one block per heading.""" + parts = [] + for index, (is_title, depth) in enumerate(blocks): + y = 100 + 50 * index + text = _markdown(index, depth) + if parser == "chandra": + label = "Title" if is_title else "Section-Header" + body = f"H{index}" if depth is not None else text + parts.append( + f'
' + f"{body}
" + ) + elif parser == "dots": + category = "Title" if is_title else "Section-header" + parts.append( + json.dumps( + {"category": category, "bbox": [60, y, 540, y + 40], "text": text} + ) + ) + elif parser in {"nemotron_v2", "nemotron_v3_5"}: + label = "Title" if is_title else "Section-header" + parts.append( + f"{text}" + f"\n\n" + ) + elif parser == "deepseek": + label = "title" if is_title else "sub_title" + parts.append( + f"<|ref|>{label}<|/ref|><|det|>[[100, {y}, 900, {y + 40}]]<|/det|>\n" + f"{text}\n\n" + ) + elif parser == "unlimited": + label = "title" if is_title else "sub_title" + parts.append(f"<|det|>{label} [100, {y}, 900, {y + 40}]<|/det|>{text}\n") + else: + raise AssertionError(parser) + if parser == "dots": + return f"[{', '.join(parts)}]" + return "".join(parts) + + +def _parse(parser: str, blocks: list[_Block], page_no: int = 1) -> DoclingDocument: + """Map one page with the standalone mapper and its default normalization.""" + if parser == "paddle": + payload = { + "width": _SIZE.width, + "height": _SIZE.height, + "model_settings": {"format_block_content": True}, + "parsing_res_list": [ + { + "block_label": "doc_title" if is_title else "paragraph_title", + "block_content": _markdown(index, depth), + "block_bbox": [60, 100 + 50 * index, 540, 140 + 50 * index], + } + for index, (is_title, depth) in enumerate(blocks) + ], + } + return parse_paddleocr_vl_result(payload, filename="page.pdf", page_no=page_no) + content = _response(parser, blocks) 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" + return parse_chandra_html(content, _SIZE, page_no=page_no) + if parser == "dots": + return parse_dots_json(content, _SIZE, page_no=page_no) + if parser == "nemotron_v2": + return parse_nemotron_parse_v2(content, _SIZE, _SIZE, page_no=page_no) + if parser == "nemotron_v3_5": + return parse_nemotron_parse_v3_5(content, _SIZE, page_no=page_no) + if parser == "deepseek": + return parse_deepseekocr_markdown(content, _SIZE, page_no=page_no) + return parse_unlimited_ocr_markdown(content, _SIZE, page_no=page_no) + + +def _levels(doc: DoclingDocument) -> list[int]: + return [item.level for item in doc.texts if isinstance(item, SectionHeaderItem)] + + +@pytest.mark.parametrize("parser", _PARSERS) +def test_depth_one_section_keeps_every_depth_distinct(parser: str) -> None: + doc = _parse(parser, [(False, 1), (False, 2), (False, 3)]) + + assert all(isinstance(item, SectionHeaderItem) for item in doc.texts) + assert _levels(doc) == [1, 2, 3] + assert doc.export_to_markdown() == "## H0\n\n### H1\n\n#### H2" + + +@pytest.mark.parametrize("parser", _PARSERS) +def test_source_depth_survives_export_without_depth_one(parser: str) -> None: + doc = _parse(parser, [(False, 2), (False, 3), (False, 6)], page_no=2) + + assert _levels(doc) == [1, 2, 5] + assert all(item.prov[0].page_no == 2 for item in doc.texts) + assert doc.export_to_markdown() == "## H0\n\n### H1\n\n###### H2" + + +@pytest.mark.parametrize("parser", _PARSERS) +def test_section_without_markup_is_depth_two(parser: str) -> None: + assert _levels(_parse(parser, [(False, None), (False, 3)])) == [1, 2] + assert _levels(_parse(parser, [(False, 1), (False, None)])) == [1, 2] + + +@pytest.mark.parametrize("parser", _PARSERS) +def test_layout_label_alone_decides_title(parser: str) -> None: + doc = _parse(parser, [(True, 2), (False, 1), (False, 2)]) + + assert isinstance(doc.texts[0], TitleItem) + assert doc.texts[0].text == "H0" + assert _levels(doc) == [1, 2] + assert doc.export_to_markdown() == "# H0\n\n## H1\n\n### H2" + + +def test_html_fragment_records_raw_depth() -> None: + doc = DoclingDocument(name="fragment") + doc.add_page(page_no=2, size=_SIZE) + for depth in (2, 3): + _add_html_fragment(doc, f"Method", label=DocItemLabel.TEXT) + + # The calling mapper normalizes; the shared fragment helper must not. + assert _levels(doc) == [2, 3] + + +@pytest.mark.parametrize("parser", list(_FORMATS)) +def test_pipeline_normalizes_joined_pages_once(parser: str) -> None: + pipeline = VlmPipeline.__new__(VlmPipeline) + pipeline.pipeline_options = SimpleNamespace( + generate_page_images=False, + generate_picture_images=False, + images_scale=1.0, + vlm_options=InlineVlmOptions( + prompt="", + repo_id="test", + response_format=_FORMATS[parser], + inference_framework=InferenceFramework.TRANSFORMERS, + scale=1.0, + ), + ) + pipeline.force_backend_text = False + conv_res = SimpleNamespace( + input=SimpleNamespace(file=PurePath("test.pdf")), errors=[] + ) + + def join(pages: dict[int, list[_Block]]) -> DoclingDocument: + page_documents = [] + for page_no, blocks in pages.items(): + page = Page( + page_no=page_no, + size=_SIZE, + predictions=PagePredictions( + vlm_response=VlmPrediction(text=_response(parser, blocks)) + ), + ) + page_documents.append( + (page_no, pipeline._finalize_page_document(conv_res, page)) + ) + return pipeline._join_page_documents(page_documents) + + # Only page 1 has a depth-1 section, yet "##" has one level on both pages. + doc = join({1: [(False, 1), (False, 2)], 2: [(False, 2)]}) + assert [(item.prov[0].page_no, item.level) for item in doc.texts] == [ + (1, 1), + (1, 2), + (2, 2), + ] + # A second normalization would turn "###" into "##". + doc = join({1: [(False, 3)], 2: [(False, 3)]}) + assert _levels(doc) == [2, 2] + assert doc.export_to_markdown() == "### H0\n\n### H0" + assert not conv_res.errors def test_formatted_html_heading_keeps_one_coordinate_owner() -> None: From 6de9f0c09969137d33d0e6fea266979c754144de Mon Sep 17 00:00:00 2001 From: Christoph Auer Date: Thu, 22 Oct 2026 13:26:44 +0200 Subject: [PATCH 6/8] fix(deps): require docling-core 2.103.1 The VLM heading rule can keep a source depth of 6 at section level 6. docling-core before 2.103.1 exports that level as seven "#" in Markdown, which is not a heading. 2.103.1 caps the Markdown heading at six "#", as the DocLang serializer already does. Signed-off-by: Christoph Auer --- pyproject.toml | 6 +++--- uv.lock | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 25365130fc..692dfb169c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ requires-python = '>=3.10,<4.0' # MINIMAL BASE (8 packages) - ~50MB dependencies = [ 'pydantic>=2.0.0,<3.0.0', - 'docling-core>=2.102.0,<3.0.0', + 'docling-core>=2.103.1,<3.0.0', 'pydantic-settings>=2.3.0,<3.0.0', 'filetype>=1.2.0,<2.0.0', 'requests>=2.32.2,<3.0.0', @@ -282,7 +282,7 @@ models-vlm-inline = [ feat-chunking = [ # >=2.94.1 relaxed its transformers pin; older releases cap transformers<5.9.0 # on macOS, which conflicts with the mlx-vlm floor in models-vlm-inline. - 'docling-core[chunking]>=2.102.0,<3.0.0', + 'docling-core[chunking]>=2.103.1,<3.0.0', ] service-client = [ @@ -334,7 +334,7 @@ typecheck = [ ] pr-fast-checks = [ { include-group = "typecheck" }, - "docling-core[chunking] (>=2.102.0,<3.0.0)", + "docling-core[chunking] (>=2.103.1,<3.0.0)", "pydantic (>=2.0.0,<3.0.0)", "pydantic-settings (>=2.3.0,<3.0.0)", "ruff==0.15.12", diff --git a/uv.lock b/uv.lock index 3ecf2aea0c..d12dc1fac6 100644 --- a/uv.lock +++ b/uv.lock @@ -1943,7 +1943,7 @@ requires-dist = [{ name = "docling-slim", extras = ["service-client"], editable [[package]] name = "docling-core" -version = "2.102.0" +version = "2.103.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "defusedxml" }, @@ -1962,9 +1962,9 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c8/cc/af034afb72ddc4bfcb24f6cace3ffcf800ca4226080497c989a6a4124a4b/docling_core-2.102.0.tar.gz", hash = "sha256:f951eef5ba69391d8fc0b39e54726475d6135b49d4080f886ad98945ea397872", size = 425379, upload-time = "2026-10-18T05:31:45.103Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/74/70107b222626fa5167a4de9dd24148b2ff657667d9ad57d17d3fba3906ef/docling_core-2.103.1.tar.gz", hash = "sha256:52398d2369fe32fccb7c06527d888b4cfa50226b25f55bbdc5d6e6d6863ee4f4", size = 440557, upload-time = "2026-10-20T14:45:14.447Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/ee/5e8ddf1cdccf623aa2f131cddbd5e50fc0647f933481ebe347185a05a259/docling_core-2.102.0-py3-none-any.whl", hash = "sha256:dc2d1d6e6ec5e96c762e7b71df0d017dfd94224a06c92eeccced1625a7e921c2", size = 321488, upload-time = "2026-10-18T05:31:43.292Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6b/047a02de5828d54462f48f7d12da50861cc36505a600460ae08eeeeca2f5/docling_core-2.103.1-py3-none-any.whl", hash = "sha256:2407bb42bb95468192b69b772ab72477e2a636bd33c5c6a401047b190fc81b96", size = 328165, upload-time = "2026-10-20T14:45:12.455Z" }, ] [package.optional-dependencies] @@ -2422,8 +2422,8 @@ requires-dist = [ { name = "defusedxml", marker = "extra == 'format-iwork'", specifier = ">=0.7.1,<0.8.0" }, { name = "defusedxml", marker = "extra == 'format-xml-uspto'", specifier = ">=0.7.1,<0.8.0" }, { name = "defusedxml", marker = "extra == 'models-local'", specifier = ">=0.7.1,<0.8.0" }, - { name = "docling-core", specifier = ">=2.102.0,<3.0.0" }, - { name = "docling-core", extras = ["chunking"], marker = "extra == 'feat-chunking'", specifier = ">=2.102.0,<3.0.0" }, + { name = "docling-core", specifier = ">=2.103.1,<3.0.0" }, + { name = "docling-core", extras = ["chunking"], marker = "extra == 'feat-chunking'", specifier = ">=2.103.1,<3.0.0" }, { name = "docling-ibm-models", marker = "extra == 'models-local'", specifier = ">=4.0.2,<5" }, { name = "docling-parse", marker = "extra == 'format-pdf-docling'", specifier = ">=7.22.2,<8.0.0" }, { name = "docling-slim", extras = ["convert-core"], marker = "extra == 'extract-core'" }, @@ -2562,7 +2562,7 @@ examples = [ ] pr-fast-checks = [ { name = "boto3-stubs", specifier = "~=1.37" }, - { name = "docling-core", extras = ["chunking"], specifier = ">=2.102.0,<3.0.0" }, + { name = "docling-core", extras = ["chunking"], specifier = ">=2.103.1,<3.0.0" }, { name = "pandas-stubs", specifier = "~=2.1" }, { name = "pydantic", specifier = ">=2.0.0,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.3.0,<3.0.0" }, From ccba642e36a0dcb0c3284c70158e12ac2e466ad2 Mon Sep 17 00:00:00 2001 From: Christoph Auer Date: Thu, 22 Oct 2026 13:26:58 +0200 Subject: [PATCH 7/8] docs(vlm): correct the heading rule docs and comments - The docs said that the pipeline applies the heading rule to PaddleOCR-VL. No pipeline calls it: parse_paddleocr_vl_result reads one saved page and normalizes that page. The docs now say so, and that Markdown exports source depths 5 and 6 both as "######" when the document has depth 1, while their section levels stay different. - parse_paddleocr_vl_result no longer has the normalize_heading_levels parameter. No caller passed it, and the function accepts one page only. - Correct the comment on DeepSeek block collection: every non-table block keeps its lines, not only text and captions. - Make the comment on _RAW_HEADING_DEPTH_FORMATS a full statement. Signed-off-by: Christoph Auer --- docling/pipeline/vlm_pipeline.py | 2 +- docling/utils/deepseekocr_utils.py | 2 +- docling/utils/paddleocr_vl_utils.py | 7 ++----- docs/usage/vision_models.md | 12 +++++++++--- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docling/pipeline/vlm_pipeline.py b/docling/pipeline/vlm_pipeline.py index af687510ba..0bf6cc0bba 100644 --- a/docling/pipeline/vlm_pipeline.py +++ b/docling/pipeline/vlm_pipeline.py @@ -73,7 +73,7 @@ _log = logging.getLogger(__name__) _DOCLANG_OPEN_RE = re.compile(r"]*)?>") -# Their mappers keep raw heading depths per page; the joined document is normalized. +# Formats whose mappers keep raw heading depths per page; the joined document is normalized. _RAW_HEADING_DEPTH_FORMATS = frozenset( { ResponseFormat.CHANDRA_HTML, diff --git a/docling/utils/deepseekocr_utils.py b/docling/utils/deepseekocr_utils.py index 48b0f38b28..1634b2b492 100644 --- a/docling/utils/deepseekocr_utils.py +++ b/docling/utils/deepseekocr_utils.py @@ -160,7 +160,7 @@ def _collect_annotation_content( break ii += 1 else: - # Text and captions can span lines and paragraphs within one annotation. + # Every non-table block keeps its lines up to the next annotation. while i < len(lines): if re.match(annotation_pattern, lines[i].strip()): break diff --git a/docling/utils/paddleocr_vl_utils.py b/docling/utils/paddleocr_vl_utils.py index 22d10f5117..8164dd1528 100644 --- a/docling/utils/paddleocr_vl_utils.py +++ b/docling/utils/paddleocr_vl_utils.py @@ -296,7 +296,6 @@ def parse_paddleocr_vl_result( *, filename: str | None = None, page_no: int | None = None, - normalize_heading_levels: bool = True, ) -> DoclingDocument: """Convert one saved PaddleOCR-VL page result into a ``DoclingDocument``. @@ -310,8 +309,6 @@ def parse_paddleocr_vl_result( 0-based ``page_index`` is converted to Docling's 1-based numbering. This cannot make a concatenated multi-page payload valid; those results remain unsupported and must be parsed per page. - normalize_heading_levels: If False, keep raw heading depths for a later - document-wide :func:`normalize_section_header_levels`. Returns: A single-page ``DoclingDocument`` with typed items and provenance in @@ -341,6 +338,6 @@ def parse_paddleocr_vl_result( for block in page.parsing_res_list: _add_block(doc, block, page=page, page_no=resolved_page_no) - if normalize_heading_levels: - normalize_section_header_levels(doc) + # One page is one document here, so its heading levels are normalized now. + normalize_section_header_levels(doc) return doc diff --git a/docs/usage/vision_models.md b/docs/usage/vision_models.md index 83019ee6cf..f021fb9fb6 100644 --- a/docs/usage/vision_models.md +++ b/docs/usage/vision_models.md @@ -231,6 +231,12 @@ output, the layout label alone decides title versus section header. Docling exports a title as `#` and a section header one step below it. If no section header in the document uses depth 1 (`#` or `

`), each section header exports with its source depth: `##` stays `##`. Otherwise each one exports one step -deeper, so different source depths never share one level. A section header -without heading markup counts as `##`. The pipeline applies this rule once to the -whole document, so one depth gets the same level on all pages. +deeper, so different source depths never share one section level. A section +header without heading markup counts as `##`. Markdown has no heading deeper than +`######`, so in a document with depth 1, source depths 5 and 6 both export as +`######`, but their section levels stay different. + +The VLM pipeline applies this rule once to the whole document, so one depth gets +the same level on all pages. Each mapper function applies it to its own page when +you call it directly. `parse_paddleocr_vl_result` reads one saved PaddleOCR-VL +page, so it always applies the rule to that page. From 0adbcc004c065acb2fa0c6f78a2c993cf47e6773 Mon Sep 17 00:00:00 2001 From: Christoph Auer Date: Thu, 22 Oct 2026 13:53:32 +0200 Subject: [PATCH 8/8] fix: adapt to docling-core 2.103.1 image and HTML changes docling-core 2.103.0 reads local image files only after an explicit opt-in. The Docling JSON backend kept local image references when enable_local_fetch is set, but exports then raised a ValueError. The backend now calls DoclingDocument.load_local_images() in that case, which is the docling-core opt-in for trusted documents. A missing or too large local image now fails the conversion, not a later export. docling-core 2.103.1 puts formatting inside

and tags in HTML export (for example

bold

). Regenerate the one DOCX HTML reference file that has formatted paragraphs. Signed-off-by: Christoph Auer --- docling/backend/json/docling_json_backend.py | 5 ++++- .../docx/groundtruth/docx_rich_cells.docx.html | 18 +++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docling/backend/json/docling_json_backend.py b/docling/backend/json/docling_json_backend.py index fd2a495eb5..e60192015d 100644 --- a/docling/backend/json/docling_json_backend.py +++ b/docling/backend/json/docling_json_backend.py @@ -95,7 +95,10 @@ def _get_doc_or_err(self) -> Union[DoclingDocument, Exception]: else: raise RuntimeError(f"Unexpected: {type(self.path_or_stream)=}") doc = DoclingDocument.model_validate_json(json_data=json_data) - if not self.options.enable_local_fetch: + if self.options.enable_local_fetch: + # docling-core reads local image paths only after this opt-in. + doc.load_local_images() + else: self._drop_local_image_refs(doc) return doc except Exception as e: diff --git a/tests/data/docx/groundtruth/docx_rich_cells.docx.html b/tests/data/docx/groundtruth/docx_rich_cells.docx.html index be69ab91e7..e715dd7c90 100644 --- a/tests/data/docx/groundtruth/docx_rich_cells.docx.html +++ b/tests/data/docx/groundtruth/docx_rich_cells.docx.html @@ -197,27 +197,27 @@

Table with rich cells

  • Number two
  • Number three
  • This is simple text with

    -

    bold

    +

    bold

    ,

    -

    strikethrough

    +

    strikethrough

    and

    -

    italic

    +

    italic

    formatting with x

    -

    2

    +

    2

    and H

    -

    2

    +

    2

    O

    This is a paragraph

    This is another paragraph

    Table with nested table

    Before table

    Column AColumn B
    Simple cell upper left

    Simple cell with

    -

    bold

    +

    bold

    and

    -

    italic

    -

    text

    ABC

    Cell 1

    Cell 2

    Cell 3

    +

    italic

    +

    text

    ABC

    Cell 1

    Cell 2

    Cell 3

    Rich cell
    A nested table

    -
    ABC

    Cell 1

    Cell 2

    Cell 3

    +
    ABC

    Cell 1

    Cell 2

    Cell 3

    After table with bold , underline , strikethrough , and italic formatting