From 8544d9431beb2f09cd7e9165a6bc5a1a22c40aaf Mon Sep 17 00:00:00 2001 From: Lubaba Khalid <129605617+LubabaKhalid@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:06:24 +0500 Subject: [PATCH] fix(md): keep a heading or list item without text from taking a later block's text Signed-off-by: Lubaba Khalid <129605617+LubabaKhalid@users.noreply.github.com> --- docling/backend/md_backend.py | 44 +++++++++++++++++++++++++ tests/test_backend_markdown.py | 60 +++++++++++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/docling/backend/md_backend.py b/docling/backend/md_backend.py index ac6c94e9f8..99cbcaf23a 100644 --- a/docling/backend/md_backend.py +++ b/docling/backend/md_backend.py @@ -719,6 +719,9 @@ def _iterate_elements( # noqa: C901 if element in visited: return + # The payloads below this size were pushed by enclosing elements. + creation_stack_size = len(creation_stack) + # A line break only joins runs of the same paragraph. When no text run # follows the break inside it (for example, the paragraph ends in inline # HTML or a code span, or the next lines are table rows), the pending @@ -819,6 +822,23 @@ def _iterate_elements( # noqa: C901 elif isinstance(element, marko.inline.Image): _log.debug(" - Image with alt: %s, url: %s", element.title, element.dest) + # An image without alt text emits no RawText. If it is the content of + # a list item or heading, create that item first, with empty text, so + # that the picture goes under the list item, as in the HTML backend. + if creation_stack and not element.children: + parent_item = self._flush_creation_stack( + doc=doc, + creation_stack=creation_stack, + snippet_text="", + parent_item=parent_item, + list_ordered_flag_by_ref=list_ordered_flag_by_ref, + list_start_by_ref=list_start_by_ref, + list_item_counter_by_ref=list_item_counter_by_ref, + list_last_item_by_ref=list_last_item_by_ref, + formatting=formatting, + hyperlink=hyperlink, + ) + fig_caption: TextItem | None = None if element.title is not None and element.title != "": title = unescape(element.title) @@ -1054,6 +1074,30 @@ def _iterate_elements( # noqa: C901 hyperlink=hyperlink, ) + # The content of a list item or heading had no text run, e.g. + # only inline HTML. Create the item with empty text now, or the + # next text run of the document, in a later block, would take it. + if len(creation_stack) > creation_stack_size and isinstance( + element, + marko.block.ListItem + | marko.block.Heading + | marko.block.SetextHeading, + ): + pending = creation_stack[creation_stack_size:] + del creation_stack[creation_stack_size:] + self._flush_creation_stack( + doc=doc, + creation_stack=pending, + snippet_text="", + parent_item=parent_item, + list_ordered_flag_by_ref=list_ordered_flag_by_ref, + list_start_by_ref=list_start_by_ref, + list_item_counter_by_ref=list_item_counter_by_ref, + list_last_item_by_ref=list_last_item_by_ref, + formatting=formatting, + hyperlink=hyperlink, + ) + def _get_image_loader(self) -> ImageResourceLoader: """Lazily build the shared image-resource loader. diff --git a/tests/test_backend_markdown.py b/tests/test_backend_markdown.py index f889e6f4da..95bbaecc55 100644 --- a/tests/test_backend_markdown.py +++ b/tests/test_backend_markdown.py @@ -6,7 +6,13 @@ from pathlib import Path import pytest -from docling_core.types.doc import CodeItem, CodeLanguageLabel, PictureItem +from docling_core.types.doc import ( + CodeItem, + CodeLanguageLabel, + DocItemLabel, + ListItem, + PictureItem, +) from PIL import Image from docling.backend.md_backend import MarkdownDocumentBackend @@ -136,6 +142,58 @@ def test_convert_list_item_codespan_only(): assert "- raw\\_ops.Abs" in pred_md +def test_list_item_with_only_an_image_keeps_its_place(): + """An image without alt text emits no text run. Its list item is still + created, with the picture under it, so the next item does not take its place. + """ + doc = ( + get_converter() + .convert_string( + "1. ![](a.png)\n2. Second step\n3. Third step\n", format=InputFormat.MD + ) + .document + ) + + items = [item for item in doc.texts if isinstance(item, ListItem)] + assert [(item.marker, item.text) for item in items] == [ + ("1.", ""), + ("2.", "Second step"), + ("3.", "Third step"), + ] + assert len({item.parent.cref for item in items}) == 1 + assert doc.pictures[0].parent == items[0].get_ref() + + +def test_sub_list_of_an_image_only_item_is_not_duplicated(): + doc = ( + get_converter() + .convert_string("- ![](a.png)\n - nested\n- next\n", format=InputFormat.MD) + .document + ) + + items = {item.text: item for item in doc.texts if isinstance(item, ListItem)} + assert [item.text for item in doc.texts] == ["", "nested", "next"] + assert items["nested"].parent.resolve(doc).parent == items[""].get_ref() + + +@pytest.mark.parametrize( + "heading", + ["# ![](logo.png)", '# ', "![](logo.png)\n==="], + ids=["image", "inline-html", "setext-image"], +) +def test_heading_without_text_does_not_take_the_next_paragraph(heading: str): + doc = ( + get_converter() + .convert_string(f"{heading}\n\nIntro paragraph.\n", format=InputFormat.MD) + .document + ) + + assert [(item.label, item.text) for item in doc.texts] == [ + (DocItemLabel.TITLE, ""), + (DocItemLabel.TEXT, "Intro paragraph."), + ] + + def _convert_markdown( markdown: str, options: MarkdownBackendOptions ) -> DoclingDocument: