Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions docling/backend/html_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <br> tags with a line break.

For text that is one paragraph (heading, caption, list item): consecutive <br>
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:/"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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 <br> tags
continue
clean_text = HTMLDocumentBackend._clean_unicode(text_part)

# Apply extra formatting if provided
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 <br> 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,
Expand Down Expand Up @@ -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 []
Expand Down
46 changes: 46 additions & 0 deletions tests/test_backend_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -2274,6 +2274,52 @@ def test_html_newline_handling():
)


def test_html_line_break_outside_paragraphs():
# A <br> 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"<html><body>"
b"<h1>Title<br>line</h1>"
b"<h2>Heading<br>line</h2>"
b"<ul><li>Item<br>line</li><li><b>Bold</b><br><i>italic</i></li></ul>"
b"<dl><dt>Term<br>line</dt><dd>Definition<br>line</dd></dl>"
b"<table><caption>Caption<br>line</caption>"
b"<tr><td>A</td><td>B</td></tr></table>"
b"<figure><img src='a.png'><figcaption>Figure<br>line</figcaption></figure>"
b"<pre>Code<br>line</pre>"
b"<pre><b>bold</b><br><i>code</i></pre>"
b"<ul><li><input type='checkbox' checked>Task<br>line</li></ul>"
b"</body></html>"
)
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",
"bold",
"code",
"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():
# <li><input type=checkbox>text</li> is the canonical GFM task list. The
# text belongs to the checkbox item; it used to become a separate list
Expand Down
Loading