From d72179b4a3028d12a9038ae3efe071dac3e46cef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20D=C3=A6hli=20Aslesen?= Date: Wed, 7 Oct 2026 08:42:25 +0200 Subject: [PATCH 1/2] fix(html): resolve the line break sentinel outside paragraphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `
` is replaced by a sentinel character (U+E000), which `split_by_newline()` and `get_text()` turn into a line break for paragraphs and table cells. Headings, captions, list items, `
`
blocks and the text of checkboxes did not resolve it, so the character
was left in the text and in the exports.

These items now get a line break, too. In a heading, a caption or a
list item, consecutive `
` tags give one line break. In a `
`
block, each `
` is a newline. The text of a checkbox stays on one line. A run that only holds `
` tags gives no item of its own. Signed-off-by: Morten Dæhli Aslesen --- docling/backend/html_backend.py | 36 +++++++++++++++++++++------ tests/test_backend_html.py | 43 +++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/docling/backend/html_backend.py b/docling/backend/html_backend.py index 82c44f2253..fa5779e326 100644 --- a/docling/backend/html_backend.py +++ b/docling/backend/html_backend.py @@ -124,6 +124,16 @@ def _warn_headers_without_origin() -> None: # Using Unicode Private Use Area to avoid conflicts with actual content _BR_SENTINEL = "\ue000" + +def _resolve_line_breaks(text: str) -> str: + """Replace the sentinels of
tags with a line break. + + For text that is one paragraph (heading, caption, list item): consecutive
+ tags give one line break. + """ + return re.sub(rf" *{_BR_SENTINEL}[ {_BR_SENTINEL}]*", "\n", text).strip() + + DEFAULT_IMAGE_WIDTH = 128 DEFAULT_IMAGE_HEIGHT = 128 _MHTML_SYNTHETIC_BASE = "thismessage:/" @@ -384,7 +394,7 @@ def to_single_text_element(self) -> AnnotatedText: current_code = c if c else current_code return AnnotatedText( - text=current_text.strip(), + text=_resolve_line_breaks(current_text), hyperlink=current_h, formatting=current_f, code=current_code, @@ -2871,9 +2881,9 @@ def _add_list_item_with_content( tag, ignore_list=True, find_parent_annotation=True ) min_parts = parts.simplify_text_elements() - item_text = re.sub( - r"\s+|\n+", " ", "".join([el.text for el in min_parts]) - ).strip() + item_text = _resolve_line_breaks( + re.sub(r"\s+|\n+", " ", "".join([el.text for el in min_parts])) + ) if not item_text: return None @@ -2895,7 +2905,12 @@ def _add_list_item_with_content( with self._use_inline_group(min_parts, doc): compacted_parts = self._compact_adjacent_single_char_parts(min_parts) for annotated_text, source_tag_ids in compacted_parts: - text_part = re.sub(r"\s+|\n+", " ", annotated_text.text).strip() + text_part = _resolve_line_breaks( + re.sub(r"\s+|\n+", " ", annotated_text.text) + ) + if not text_part: + # The part only held
tags + continue clean_text = HTMLDocumentBackend._clean_unicode(text_part) # Apply extra formatting if provided @@ -2940,7 +2955,7 @@ def _add_list_item_with_content( else: # Simple content - single text element annotated_text = min_parts[0] - text = re.sub(r"\s+|\n+", " ", annotated_text.text).strip() + text = _resolve_line_breaks(re.sub(r"\s+|\n+", " ", annotated_text.text)) clean_text = HTMLDocumentBackend._clean_unicode(text) prov = self._make_text_prov( text=clean_text, @@ -3460,7 +3475,12 @@ def _handle_block(self, tag: Tag, doc: DoclingDocument) -> list[RefItem]: # noq language_hint = self._code_language_hint(tag) with self._use_inline_group(annotated_texts, doc) as inline_ref: for annotated_text in annotated_texts: - text_orig = annotated_text.text.strip() + # In preformatted text, each
is a newline + text_orig = re.sub( + rf" ?{_BR_SENTINEL} ?", "\n", annotated_text.text + ).strip() + if not text_orig: + continue text_clean = HTMLDocumentBackend._clean_unicode(text_orig) prov = self._make_prov( text=text_clean, @@ -3883,7 +3903,7 @@ def _extract_text_excluding_tag_obj_ids( ) -> str: def _extract(node: PageElement) -> list[str]: if isinstance(node, NavigableString): - return [str(node)] + return [str(node).replace(_BR_SENTINEL, "\n")] if isinstance(node, Tag): if id(node) in excluded_obj_ids: return [] diff --git a/tests/test_backend_html.py b/tests/test_backend_html.py index 40502e5a91..e78835d2b6 100644 --- a/tests/test_backend_html.py +++ b/tests/test_backend_html.py @@ -2274,6 +2274,49 @@ def test_html_newline_handling(): ) +def test_html_line_break_outside_paragraphs(): + # A
outside a paragraph or a table cell, e.g. in a heading or a list + # item, left the internal sentinel (U+E000) in the text. It is now a line + # break, as in a paragraph. The text of a checkbox stays on one line. + src = ( + b"" + b"

Title
line

" + b"

Heading
line

" + b"
  • Item
    line
  • Bold
    italic
" + b"
Term
line
Definition
line
" + b"" + b"
Caption
line
AB
" + b"
Figure
line
" + b"
Code
line
" + b"
  • Task
    line
" + b"" + ) + in_doc = InputDocument( + path_or_stream=BytesIO(src), + format=InputFormat.HTML, + backend=HTMLDocumentBackend, + filename="t.html", + ) + doc = HTMLDocumentBackend(in_doc=in_doc, path_or_stream=BytesIO(src)).convert() + + assert [item.text for item in doc.texts] == [ + "Title\nline", + "Heading\nline", + "Item\nline", + "", # the list item with formatting: its text is in the two runs below + "Bold", + "italic", + "Term\nline", + "Definition\nline", + "Caption\nline", + "Figure\nline", + "Code\nline", + "Task line", + ] + assert not any(_BR_SENTINEL in item.orig for item in doc.texts) + assert "## Heading line" in doc.export_to_markdown() + + def test_gfm_task_list_renders_checkbox_with_text(): #
  • text
  • is the canonical GFM task list. The # text belongs to the checkbox item; it used to become a separate list From 7dff863519213c5481bd0702c4eb208619da2dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20D=C3=A6hli=20Aslesen?= Date: Thu, 8 Oct 2026 13:55:22 +0200 Subject: [PATCH 2/2] test(html): cover a
     run that only holds a 
    MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Morten Dæhli Aslesen --- tests/test_backend_html.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_backend_html.py b/tests/test_backend_html.py index e78835d2b6..4ba99db06c 100644 --- a/tests/test_backend_html.py +++ b/tests/test_backend_html.py @@ -2288,6 +2288,7 @@ def test_html_line_break_outside_paragraphs(): b"AB" b"
    Figure
    line
    " b"
    Code
    line
    " + b"
    bold
    code
    " b"
    • Task
      line
    " b"" ) @@ -2311,6 +2312,8 @@ def test_html_line_break_outside_paragraphs(): "Caption\nline", "Figure\nline", "Code\nline", + "bold", + "code", "Task line", ] assert not any(_BR_SENTINEL in item.orig for item in doc.texts)