Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9e48e5d
feat: honor the InlineGroup whitespace contract in the backends
cau-git Jul 26, 2026
8a44fd2
build: source docling-core from the contract branch until it ships
cau-git Jul 26, 2026
bd3092f
DCO Remediation Commit for Christoph Auer <cau@zurich.ibm.com>
cau-git Jul 27, 2026
efbc2c0
fix(odf): keep hyperlink-boundary whitespace under the faithful join
cau-git Aug 18, 2026
93e3049
build: relock docling-core to the rebased contract branch
cau-git Aug 18, 2026
8f82255
Merge branch 'main' into cau/serializer-whitespace-fixes
cau-git Oct 26, 2026
e2fd500
fix(docx): keep interior whitespace runs in checkbox and skip-empty p…
cau-git Oct 23, 2026
a28b628
fix(pptx): keep whitespace between inline OMML formulas
cau-git Oct 23, 2026
d17dd90
fix(html): keep <br> between inline runs and collapse caption whitespace
cau-git Oct 23, 2026
131a614
fix(chandra): keep inline run boundary whitespace
cau-git Oct 23, 2026
46dbadc
fix(md): keep boundary whitespace in rich table-cell runs
cau-git Oct 23, 2026
9f7ff7a
fix(asciidoc): keep inline run boundary whitespace
cau-git Oct 23, 2026
2424725
test: pin the host-text boundary before a leading inline group
cau-git Oct 26, 2026
6100cf8
fix(html): keep the word boundary at a block child of a list item
cau-git Oct 23, 2026
53d8fc8
test: move stale run expectations to the inline whitespace contract
cau-git Oct 23, 2026
4424ce7
fix(jats): keep a footnote label apart from its paragraph
cau-git Oct 23, 2026
66db214
test: rebaseline groundtruth for the inline whitespace contract
cau-git Oct 23, 2026
e021aa9
Merge branch 'main' into cau/serializer-whitespace-fixes
cau-git Oct 26, 2026
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
30 changes: 23 additions & 7 deletions docling/backend/asciidoc_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import logging
import re
from collections.abc import Iterator
from dataclasses import dataclass
from dataclasses import dataclass, replace
from io import BytesIO
from pathlib import Path
from typing import Final, Optional, Union
Expand Down Expand Up @@ -840,16 +840,32 @@ def _flush_text_data(
return []
# Mixed formatting needs one item per run, kept together in an inline
# group like the Markdown backend does.
group = doc.add_inline_group(parent=parent)
# Runs keep their boundary whitespace (docling-core joins them with ""); a
# code run's edge whitespace moves out into a plain " " run of its own.
spaced: list[_InlineRun] = []
for run in runs:
run_text = run.text.strip()
if not run_text:
continue
core = run.text.strip() if run.code else run.text
if run.code and run.text[:1].isspace():
spaced.append(_InlineRun(" "))
if core:
spaced.append(replace(run, text=core))
if run.code and core and run.text[-1:].isspace():
spaced.append(_InlineRun(" "))
# Only the block's outer edges are trimmed.
while spaced and not spaced[0].text.strip():
spaced.pop(0)
while spaced and not spaced[-1].text.strip():
spaced.pop()
if spaced:
spaced[0] = replace(spaced[0], text=spaced[0].text.lstrip())
spaced[-1] = replace(spaced[-1], text=spaced[-1].text.rstrip())
group = doc.add_inline_group(parent=parent)
for run in spaced:
if run.code:
doc.add_code(text=run_text, parent=group)
doc.add_code(text=run.text, parent=group)
else:
doc.add_text(
text=run_text,
text=run.text,
label=DocItemLabel.TEXT,
parent=group,
formatting=(
Expand Down
227 changes: 173 additions & 54 deletions docling/backend/html_backend.py

Large diffs are not rendered by default.

32 changes: 27 additions & 5 deletions docling/backend/md_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,9 @@ def _iterate_cell_inline(

elif isinstance(element, marko.inline.RawText | marko.inline.Literal):
raw = element.children if isinstance(element.children, str) else ""
text = unescape(raw.strip())
# Keep the raw fragment: its edge whitespace is the run boundary. Marko has
# already trimmed the cell edges, and whitespace-only runs are significant.
text = unescape(raw)
if text:
item = doc.add_text(
label=DocItemLabel.TEXT,
Expand Down Expand Up @@ -881,13 +883,19 @@ def _iterate_elements( # noqa: C901
original_text = (
element.children if isinstance(element.children, str) else ""
)
snippet_text = unescape(original_text.strip())
if snippet_text:
# Keep the raw fragment: whitespace at a `RawText` edge is the boundary between
# inline runs (`Foo *emph* bar` -> `"Foo "`, `"emph"`, `" bar"`) and the serializers
# concatenate faithfully. Marko has already trimmed the block edges for us.
snippet_text = unescape(original_text)
stripped_text = unescape(original_text.strip())
# A whitespace-only run is kept between runs, but never becomes a heading/list item.
if snippet_text and (stripped_text or not creation_stack):
if creation_stack:
# Headings and list items become one item's own text, not a run: trim.
parent_item = self._flush_creation_stack(
doc=doc,
creation_stack=creation_stack,
snippet_text=snippet_text,
snippet_text=stripped_text,
parent_item=parent_item,
list_ordered_flag_by_ref=list_ordered_flag_by_ref,
list_start_by_ref=list_start_by_ref,
Expand Down Expand Up @@ -923,7 +931,14 @@ def _iterate_elements( # noqa: C901
doc.texts[-1].text += " " + snippet_text
doc.texts[-1].orig += " " + snippet_text
else:
prefix = "\n" if self._pending_hard_line_break else ""
# The break still separates the runs when they cannot merge.
prefix = (
"\n"
if self._pending_hard_line_break
else " "
if self._pending_soft_line_break
else ""
)
doc.add_text(
label=DocItemLabel.TEXT,
parent=parent_item,
Expand Down Expand Up @@ -956,6 +971,13 @@ def _iterate_elements( # noqa: C901
self._pending_soft_line_break = False
# Represent CodeSpan as the container's text; avoid adding a duplicate CodeItem.
return
if self._pending_hard_line_break or self._pending_soft_line_break:
# The break separator never goes inside the code run: give it a plain run.
doc.add_text(
label=DocItemLabel.TEXT,
parent=parent_item,
text="\n" if self._pending_hard_line_break else " ",
)
doc.add_code(
parent=parent_item,
text=snippet_text,
Expand Down
23 changes: 20 additions & 3 deletions docling/backend/mspowerpoint_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,22 @@ def _paragraph_fragments(element: etree._Element) -> list[tuple[DocItemLabel, st
return fragments


def _trim_fragment_edges(
fragments: list[tuple[DocItemLabel, str]],
) -> list[tuple[DocItemLabel, str]]:
"""Trim only the outer edges of a fragment sequence; interior whitespace is text.

Formulas are already stripped and text fragments never touch each other, so only
a text fragment at either end needs trimming.
"""
fragments = list(fragments)
if fragments and fragments[0][0] != DocItemLabel.FORMULA:
fragments[0] = (fragments[0][0], fragments[0][1].lstrip())
if fragments and fragments[-1][0] != DocItemLabel.FORMULA:
fragments[-1] = (fragments[-1][0], fragments[-1][1].rstrip())
return [fragment for fragment in fragments if fragment[1]]


_SAFE_XML_PARSER: Final = etree.XMLParser(
resolve_entities=False,
load_dtd=False,
Expand Down Expand Up @@ -1079,7 +1095,7 @@ def _handle_text_elements(
inline_group = doc.add_inline_group(
parent=current.last_item, content_layer=content_layer
)
for label, text in fragments:
for label, text in _trim_fragment_edges(fragments):
doc.add_text(
label=label,
parent=inline_group,
Expand Down Expand Up @@ -1120,8 +1136,9 @@ def _handle_text_elements(
if has_text
else fragment_parent
)
for label, text in fragments:
if text.strip():
for label, text in _trim_fragment_edges(fragments):
# Without an inline group the formulas are separate blocks.
if has_text or label == DocItemLabel.FORMULA:
doc.add_text(
label=label,
parent=parent,
Expand Down
107 changes: 71 additions & 36 deletions docling/backend/msword_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,29 @@ def _format_enum_counter(counter: int, num_fmt: str | None) -> str:
return str(counter)


def _trim_run_edges(
runs: list[tuple[str, Formatting | None, AnyUrl | Path | None]],
) -> list[tuple[str, Formatting | None, AnyUrl | Path | None]]:
"""Trim only the outer edges of a run sequence, leaving interior whitespace intact.

Same shape as the ODF backend's ``_normalize_odf_text_runs``: leading and trailing
whitespace belongs to the block, whitespace between runs belongs to the text.
"""
while runs and not runs[0][0].strip():
runs.pop(0)
if runs:
text, formatting, hyperlink = runs[0]
runs[0] = (text.lstrip(), formatting, hyperlink)

while runs and not runs[-1][0].strip():
runs.pop()
if runs:
text, formatting, hyperlink = runs[-1]
runs[-1] = (text.rstrip(), formatting, hyperlink)

return [run for run in runs if run[0]]


def _strict_ns_to_transitional(strict_ns: str) -> str:
"""Map a single Strict OOXML namespace/relationship URI to its Transitional form."""
if strict_ns in _STRICT_OOXML_NS_OVERRIDES:
Expand Down Expand Up @@ -2535,32 +2558,33 @@ def _get_paragraph_elements(self, paragraph: Paragraph):
group_text = ""
previous_format = None

# Iterate over the runs of the paragraph and group them by format
# Group the runs by format, concatenating faithfully: in DOCX the boundary space very
# often lives inside a formatted run, and it is the run's own text (see
# `DoclingDocument.add_inline_group`). Stripping each group here is what forced the
# serializers to invent separators.
for text, format, hyperlink in content:
if (len(text.strip()) and format != previous_format) or (
hyperlink is not None
):
# If the style changes for a non empty text, add the previous group
if len(group_text.strip()) > 0:
paragraph_elements.append(
(group_text.strip(), previous_format, None)
)
if group_text:
paragraph_elements.append((group_text, previous_format, None))
group_text = ""

# If there is a hyperlink, add it immediately
if hyperlink is not None:
paragraph_elements.append((text.strip(), format, hyperlink))
paragraph_elements.append((text, format, hyperlink))
text = ""
else:
previous_format = format

group_text += text

# Format the last group
if len(group_text.strip()) > 0:
paragraph_elements.append((group_text.strip(), previous_format, None))
if group_text:
paragraph_elements.append((group_text, previous_format, None))

return paragraph_elements
return _trim_run_edges(paragraph_elements)

def _has_checkbox(self, element: BaseOxmlElement) -> bool:
"""Check if a paragraph element contains a checkbox.
Expand Down Expand Up @@ -2633,10 +2657,11 @@ def _clean_checkbox_symbols(self, text: str) -> str:
# Common checkbox symbols in docx documents
checkbox_symbols = ["☐", "☑", "☒", "□", "■", "▪", "▫"]

text = text.strip()
# Only the leading edge: the text may be the first of several inline runs.
text = text.lstrip()
for symbol in checkbox_symbols:
if text.startswith(symbol):
text = text[len(symbol) :].strip()
text = text[len(symbol) :].lstrip()
break

return text
Expand Down Expand Up @@ -3289,6 +3314,18 @@ def _add_body_text_items(
skip_empty_text: bool,
) -> list[RefItem]:
"""Add ordinary paragraph text items under the current parent."""
if checkbox_label and paragraph_elements:
text, formatting, hyperlink = paragraph_elements[0]
first = (self._clean_checkbox_symbols(text), formatting, hyperlink)
paragraph_elements = _trim_run_edges([first, *paragraph_elements[1:]]) or [
("", formatting, hyperlink)
]
# Runs are already edge-trimmed: whitespace-only runs left are interior and significant.
if skip_empty_text and not any(
text.strip() for text, _, _ in paragraph_elements
):
return []

level = self._get_level()
parent = self._create_or_reuse_parent(
doc=doc,
Expand All @@ -3298,14 +3335,10 @@ def _add_body_text_items(
elem_ref: list[RefItem] = []

for text, formatting, hyperlink in paragraph_elements:
clean_text = self._clean_checkbox_symbols(text) if checkbox_label else text
if skip_empty_text and (not clean_text or not clean_text.strip()):
continue

text_item = doc.add_text(
label=checkbox_label if checkbox_label else DocItemLabel.TEXT,
parent=parent,
text=clean_text,
text=text,
formatting=formatting,
hyperlink=hyperlink,
content_layer=self.content_layer,
Expand Down Expand Up @@ -3518,6 +3551,7 @@ def _add_inline_equations_to_parent(
equations: List of equation strings with markers (e.g., ["<eq>A=B</eq>", ...]).
elem_ref: Optional list to append created element references to.
"""
runs: list[tuple[DocItemLabel, str]] = []
text_tmp = text
for eq in equations:
if len(text_tmp) == 0:
Expand All @@ -3529,33 +3563,34 @@ def _add_inline_equations_to_parent(
text_tmp = "" if len(split_text_tmp) == 1 else split_text_tmp[1]

if len(pre_eq_text) > 0:
e1 = doc.add_text(
label=DocItemLabel.TEXT,
parent=parent,
text=pre_eq_text,
content_layer=self.content_layer,
)
if elem_ref is not None:
elem_ref.append(e1.get_ref())

e2 = doc.add_text(
label=DocItemLabel.FORMULA,
parent=parent,
text=eq.replace("<eq>", "").replace("</eq>", ""),
content_layer=self.content_layer,
runs.append((DocItemLabel.TEXT, pre_eq_text))
runs.append(
(DocItemLabel.FORMULA, eq.replace("<eq>", "").replace("</eq>", ""))
)
if elem_ref is not None:
elem_ref.append(e2.get_ref())

if len(text_tmp) > 0:
e3 = doc.add_text(
label=DocItemLabel.TEXT,
runs.append((DocItemLabel.TEXT, text_tmp))

# Trim the paragraph's outer edges only. The whitespace around an inline equation is a
# significant run boundary: stripping the fragment after the last equation merged it
# into the formula (`$A=\pi r^{2}$is the area formula`) once the serializer stopped
# inventing a separator.
if runs and runs[0][0] == DocItemLabel.TEXT:
runs[0] = (DocItemLabel.TEXT, runs[0][1].lstrip())
if runs and runs[-1][0] == DocItemLabel.TEXT:
runs[-1] = (DocItemLabel.TEXT, runs[-1][1].rstrip())

for label, run_text in runs:
if label == DocItemLabel.TEXT and not run_text:
continue
item = doc.add_text(
label=label,
parent=parent,
text=text_tmp.strip(),
text=run_text,
content_layer=self.content_layer,
)
if elem_ref is not None:
elem_ref.append(e3.get_ref())
elem_ref.append(item.get_ref())

def _slot_for(self, word_ilevel: int) -> int:
"""Map a Word ``w:ilvl`` value to the internal parents-slot index.
Expand Down
14 changes: 6 additions & 8 deletions docling/backend/opendocument_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -567,15 +567,13 @@ def _normalize_odf_text_runs(runs: list[_OdfTextRun]) -> list[_OdfTextRun]:
merged_runs[-1].text += run.text
continue

text = run.text
if merged_runs and merged_runs[-1].hyperlink != run.hyperlink:
# A hyperlink boundary is a hard edge: strip the whitespace right
# at the boundary so it doesn't double up with the separator the
# markdown/inline serializer already inserts between runs.
merged_runs[-1].text = merged_runs[-1].text.rstrip()
text = text.lstrip()
# A hyperlink boundary is not special under the whitespace contract: the
# boundary space stays in whichever plain run owns it, and the serializer
# places it outside the link markup. No stripping here.
merged_runs.append(
_OdfTextRun(text=text, formatting=run.formatting, hyperlink=run.hyperlink)
_OdfTextRun(
text=run.text, formatting=run.formatting, hyperlink=run.hyperlink
)
)

while merged_runs and merged_runs[0].text.strip() == "":
Expand Down
Loading
Loading