Skip to content

fix(vlm): keep multiline DeepSeek blocks, distinct heading depths and formula delimiters - #4964

Merged
cau-git merged 8 commits into
mainfrom
cau/vlm-mapper-fixes
Oct 22, 2026
Merged

cau-git merged 8 commits into
mainfrom
cau/vlm-mapper-fixes

Conversation

@cau-git

@cau-git cau-git commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

This PR combines three overlapping fixes by @PierreMesure for the VLM response mappers. The commits are cherry-picked with their authorship and sign-off kept. Further commits on top change parts of them, as described below. It supersedes #4909, #4906 and #4907. All three were based on a commit before #4897 (Nemotron 3.5 Parse).

  • DeepSeek OCR / Unlimited OCR (fix: preserve multiline DeepSeek and Unlimited OCR blocks #4909): a text or caption block keeps all its lines up to the next annotation. Before, every line after the first nonempty line was dropped.
  • Section heading depth in all VLM mappers (fix: preserve VLM section heading depth in exports #4906, changed): Chandra, dots, Nemotron Parse 2.0 and 3.5, DeepSeek-OCR, Unlimited-OCR and PaddleOCR-VL now use one rule. Two different source depths never get the same section level.
    • The layout label alone decides title versus section header. A section header with # or <h1> stays a section header. Models use depth 1 for chapter and part headings, often next to depth 2 on the same page, so depth 1 is not a title.
    • Each mapper stores the raw markup depth: #×N or <hN> gives level N. A section header without markup counts as ##.
    • vlm_utils.normalize_section_header_levels then runs once for the whole document. If no section header has depth 1, all levels go up one step, so the export keeps the source depth (## stays ##). Otherwise the levels stay as they are, and each heading exports one # deeper than in the source.
    • The rule applies to the document, not to each page. The VLM pipeline maps the pages with normalize_heading_levels=False and normalizes the joined document once, so one depth gets the same level on all pages. A standalone mapper call normalizes its single page (the default). parse_paddleocr_vl_result reads one saved page, has no pipeline caller, and always normalizes that page.
    • Markdown has no heading deeper than ######. In a document with depth 1, source depths 5 and 6 both export as ######, but their section levels stay different.
    • The max(1, N - 1) rule from fix: preserve VLM section heading depth in exports #4906, which main already used for dots, Nemotron 3.5, DeepSeek-OCR and PaddleOCR-VL, gave depth 1 and depth 2 the same level. The commit fix(vlm): keep every markup heading depth distinct across VLM mappers replaces it. docs/usage/vision_models.md describes the rule.
  • docling-core floor 2.103.1: before 2.103.1, the Markdown serializer exported section level 6 as seven #, which is not a heading. 2.103.1 caps it at six, as the DocLang serializer already did. uv.lock changes only docling-core 2.102.0 → 2.103.1.
    • docling-core 2.103.0 reads local image files only after an explicit opt-in. With enable_local_fetch=True, the Docling JSON backend kept local image references, but exports then raised a ValueError. The backend now calls DoclingDocument.load_local_images(), the docling-core opt-in for trusted documents. A missing or too large local image now fails the conversion, not a later export. Without enable_local_fetch, local references are still dropped.
    • docling-core 2.103.1 puts formatting inside <p> and <hN> in HTML export (<p><strong>bold</strong></p> instead of <strong><p>bold</p></strong>). One DOCX HTML reference file is regenerated (docx_rich_cells.docx.html). Regenerating also re-encoded embedded PNG data in five DOCX JSON reference files; those changes were not kept, because the JSON comparison does not depend on them.
  • Nemotron Parse 2.0 formulas (fix: normalize Nemotron formula delimiters before export #4907, changed): formula text is stored without math delimiters. The helper from fix: normalize Nemotron formula delimiters before export #4907 removed one outer pair only, which left unbalanced delimiters in multi-segment formulas. For example, \(x_1=a\), \(x_2=b\) became x_1=a\), \(x_2=b. The last commit moves the Nemotron 3.5 normalizer to vlm_utils.strip_math_delimiters and uses it for both mappers. A regression test covers the multi-segment case.

The LightOnOCR PR #4789 contains copies of the old Chandra heading change and of strip_math_delimiters. It should rebase onto this PR and remove them. It renders headings through _add_html_fragment, which now only records the raw depth. So its mapper must call normalize_section_header_levels, and its response format must be added to _RAW_HEADING_DEPTH_FORMATS in vlm_pipeline.py.

Issue resolved by this Pull Request:
Resolves #4902
Resolves #4903
Resolves #4905

Checklist:

  • Documentation has been updated, if necessary. (docs/usage/vision_models.md: heading depths in native layout output. New public vlm_utils.normalize_section_header_levels and a keyword-only normalize_heading_levels=True on the mapper functions that the VLM pipeline calls.)
  • Examples have been added, if necessary. (Not necessary.)
  • Tests have been added, if necessary.

Review evidence:

  • Stored Chandra, dots.mocr and Nemotron 3.5 Parse responses for OmniDocBench and DocLayNet pages were mapped with main and with this branch, and the outputs were compared page by page. Only heading levels change. All other output is identical to main.

    Model OmniDocBench pages changed DocLayNet pages changed
    Chandra 689 / 1644 2161 / 4996
    dots.mocr 53 / 1647 315 / 4999
    Nemotron 3.5 Parse 8 / 1648 not run
  • On every page, the section levels follow the rule above. Pages with a depth-1 section header keep the raw depths, and pages without one get the raw depth minus 1. On no page do two different source depths get the same level (a section header without markup counts as ##). Headings deeper than the DocLang export limit of 6 were not compared. For Chandra, main and branch headings were paired in output order. For dots and Nemotron 3.5, levels were checked against the Markdown depth in the response.

  • DeepSeek: a small sample on main drops continuation lines of text and caption blocks. This branch keeps them. Table and picture counts do not change. The existing DeepSeek reference outputs pass unchanged.

  • tests/test_vlm_heading_exports.py: 36 tests over all seven mappers and the pipeline join, including two pages where only page 1 has depth 1. On the previous head of this branch, 28 of them fail, and on main 26 fail.

  • Full pytest run locally before the docling-core floor change: 3838 passed, 61 skipped, 15 failed. The same 15 tests fail on main in this environment (LaTeX figures, HTML, OpenDocument, WebP, XBRL, extraction, OCR language mapping, e2e). None of them is related to this change.

  • With docling-core 2.103.1, the 31 test files that import the changed modules: 475 passed, 5 skipped, 1 failed (test_extraction_with_pydantic_class_template, which also fails on main in this environment). CI then found two failures caused by the docling-core change (test_local_image_uri_is_embedded_with_enable_local_fetch, test_e2e_docx_conversions); both are fixed as described above, and tests/test_backend_docling_json.py and tests/test_backend_msword.py pass with docling-core 2.103.1 (97 passed, 1 skipped).

  • prek run --files <changed files> (the hooks of make validate): all passed.

Limits: no DeepSeek OCR, Unlimited OCR or Nemotron Parse 2.0 model responses were available. For these three changes there is unit-test evidence only. No model inference was run.

PierreMesure and others added 4 commits October 22, 2026 10:26
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 <pierre@mesu.re>
Signed-off-by: Christoph Auer <cau@zurich.ibm.com>
Correct the shared HTML mapper and Nemotron native heading conversion. Add export and provenance regressions. Fixes #4902.

Signed-off-by: Pierre Mesure <pierre@mesu.re>
Signed-off-by: Christoph Auer <cau@zurich.ibm.com>
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 <pierre@mesu.re>
Signed-off-by: Christoph Auer <cau@zurich.ibm.com>
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 <cau@zurich.ibm.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ DCO Check Passed

Thanks @cau-git, all your commits are properly signed off. 🎉

@mergify

mergify Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Merge Protections

🟢 All 2 merge protections satisfied — ready to merge.

Show 2 satisfied protections

🟢 Enforce conventional commit

Make sure that we follow https://www.conventionalcommits.org/en/v1.0.0/

  • title ~= ^(fix|feat|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\(.+\))?(!)?:

🟢 Require two reviewer for test updates

When test data is updated, we require two reviewers

  • #approved-reviews-by >= 2

@codecov

codecov Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@PierreMesure

Copy link
Copy Markdown
Contributor

Great! Happy to rebase the PR for LightOnOCR-3 when this is merged.

The layout label alone decides title versus section header. Each mapper
now stores the raw markup depth of a section header: "#" x N or <hN>
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 <cau@zurich.ibm.com>
@cau-git cau-git changed the title fix(vlm): keep multiline DeepSeek blocks, Chandra/Nemotron heading depth and formula delimiters fix(vlm): keep multiline DeepSeek blocks, distinct heading depths and formula delimiters Oct 22, 2026
@cau-git
cau-git marked this pull request as ready for review October 22, 2026 09:59
@github-actions github-actions Bot added ai:ci-needs-care AI triage: check CI-safety findings before you approve CI bug Something isn't working vlm-pipeline labels Oct 22, 2026
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

AI triage (advisory)

Checked commit 0adbcc004c. The analysis read the diff only. It did not run code from this pull request.

Warning

The AI analysis did not finish. Only the deterministic checks are shown.

CI safety: needs maintainer care before you approve CI.

  • pyproject.toml: changes build, dependency, or tool configuration
  • tach.toml: changes CI test selection
  • uv.lock: changes locked dependencies

Possibly related pull requests

PR State Signal Assessment
#4906 open referenced as #4906; changes the same code not assessed
#4789 open referenced as #4789; changes the same code not assessed
#4909 open referenced as #4909; changes the same code not assessed
#4908 open changes the same code not assessed
#4933 open changes the same code not assessed

Reference data changes

Change Files
text 1
  • text tests/data/docx/groundtruth/docx_rich_cells.docx.html: +9 / -9 lines

The ai:* labels are advisory. They do not approve CI and they do not add tests:full. A new push runs this check again.

PeterStaar-IBM
PeterStaar-IBM previously approved these changes Oct 22, 2026

@PeterStaar-IBM PeterStaar-IBM left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm!

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 <cau@zurich.ibm.com>
- 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 <cau@zurich.ibm.com>
@github-actions github-actions Bot added ai:ci-needs-care AI triage: check CI-safety findings before you approve CI and removed ai:ci-needs-care AI triage: check CI-safety findings before you approve CI labels Oct 22, 2026
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 <p> and <hN> tags in HTML
export (for example <p><strong>bold</strong></p>). Regenerate the one
DOCX HTML reference file that has formatted paragraphs.

Signed-off-by: Christoph Auer <cau@zurich.ibm.com>
@github-actions github-actions Bot added ai:ci-needs-care AI triage: check CI-safety findings before you approve CI and removed ai:ci-needs-care AI triage: check CI-safety findings before you approve CI labels Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai:ci-needs-care AI triage: check CI-safety findings before you approve CI bug Something isn't working vlm-pipeline

Projects

None yet

4 participants