Repository navigation
fix(vlm): keep multiline DeepSeek blocks, distinct heading depths and formula delimiters - #4964
Conversation
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>
|
✅ DCO Check Passed Thanks @cau-git, all your commits are properly signed off. 🎉 |
Merge Protections🟢 All 2 merge protections satisfied — ready to merge. Show 2 satisfied protections🟢 Enforce conventional commitMake sure that we follow https://www.conventionalcommits.org/en/v1.0.0/
🟢 Require two reviewer for test updatesWhen test data is updated, we require two reviewers
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
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>
AI triage (advisory)Checked commit Warning The AI analysis did not finish. Only the deterministic checks are shown. CI safety: needs maintainer care before you approve CI.
Possibly related pull requests
Reference data changes
The |
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>
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>
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).
#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.#×N or<hN>gives level N. A section header without markup counts as##.vlm_utils.normalize_section_header_levelsthen 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.normalize_heading_levels=Falseand 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_resultreads one saved page, has no pipeline caller, and always normalizes that page.######. In a document with depth 1, source depths 5 and 6 both export as######, but their section levels stay different.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 commitfix(vlm): keep every markup heading depth distinct across VLM mappersreplaces it.docs/usage/vision_models.mddescribes the rule.#, which is not a heading. 2.103.1 caps it at six, as the DocLang serializer already did.uv.lockchanges only docling-core 2.102.0 → 2.103.1.enable_local_fetch=True, the Docling JSON backend kept local image references, but exports then raised aValueError. The backend now callsDoclingDocument.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. Withoutenable_local_fetch, local references are still dropped.<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.\(x_1=a\), \(x_2=b\)becamex_1=a\), \(x_2=b. The last commit moves the Nemotron 3.5 normalizer tovlm_utils.strip_math_delimitersand 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 callnormalize_section_header_levels, and its response format must be added to_RAW_HEADING_DEPTH_FORMATSinvlm_pipeline.py.Issue resolved by this Pull Request:
Resolves #4902
Resolves #4903
Resolves #4905
Checklist:
docs/usage/vision_models.md: heading depths in native layout output. New publicvlm_utils.normalize_section_header_levelsand a keyword-onlynormalize_heading_levels=Trueon the mapper functions that the VLM pipeline calls.)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.
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
pytestrun 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, andtests/test_backend_docling_json.pyandtests/test_backend_msword.pypass with docling-core 2.103.1 (97 passed, 1 skipped).prek run --files <changed files>(the hooks ofmake 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.