Skip to content

feat: honor the InlineGroup whitespace contract in the backends - #3891

Open
cau-git wants to merge 5 commits into
mainfrom
cau/serializer-whitespace-fixes
Open

feat: honor the InlineGroup whitespace contract in the backends#3891
cau-git wants to merge 5 commits into
mainfrom
cau/serializer-whitespace-fixes

Conversation

@cau-git

@cau-git cau-git commented Jul 27, 2026

Copy link
Copy Markdown
Member

Important

Paired with docling-project/docling-core#693 and must merge after it. These backends emit inline runs that own their boundary whitespace; only a faithful-join docling-core renders them correctly.

Rebased onto main

Rebased onto latest main (69 commits). Notable interactions with upstream work that landed in the meantime:

Still to do before merge:

  • Re-lock. uv.lock still pins the pre-rebase docling-core commit. Push the rebased docling-core#693 branch, then uv lock here (the [tool.uv.sources] entry tracks the branch).
  • Image goldens. textbox.docx and test_emf_docx.docx are image-bearing; their goldens and the docx e2e image check must be regenerated on a machine with LibreOffice / native image support (this was already a release-time step).

Summary

docling-core no longer joins InlineGroup runs with a hard " " — it concatenates them faithfully. Producers must therefore stop stripping run boundaries, because the serializer is no longer there to re-invent a separator.

Previously every backend leaned on that join, which is why the committed groundtruth contained H 2 O, x 2 + y 3 and (strong) .. Once a backend strips the boundary, the real spacing is unrecoverable downstream, so the fix has to happen at the origin.

today : ... Water is written as H 2 O using sub. ... x 2  + y 3  uses sup. ... CO 2 * .
fixed : ... Water is written as H2O using sub. ... x2 + y3 uses sup. ... CO2*.

today : ... we have a strong emphasis (strong) . Notice that ...
fixed : ... we have a strong emphasis (strong). Notice that ...

What changed

HTML. The loss happened at the extraction origin: " ".join(text.split()) collapses runs of whitespace (correct, HTML spec) and strips the edges (the defect), so everything downstream received already-stripped runs. Extraction now collapses without trimming, and normalization happens once per block across the whole run list — per-node collapsing cannot see that ["a ", " ", " b"] is a single boundary. simplify_text_elements merges same-format neighbours without a separator, to_single_text_element concatenates verbatim, and the emitters no longer strip individual runs.

Two consequences worth calling out:

  • A boundary never lands inside a ` delimiter. The serializers hoist whitespace out of emphasis but not out of code, so the space goes to whichever neighbour is plain; when both sides are code it becomes an unformatted run of its own, since `a b` `` is corrupt either way. The emitters consequently keep whitespace-only runs — normalization has already trimmed the block edges, so a run that survives is significant.
  • The single-character compactor skips candidates carrying edge whitespace, or it would glue words back together in rendered span soup. _is_rich_table_cell consumes the normalized list too: inter-tag whitespace in <td>\n<p>x</p>\n</td> otherwise counts as two extra runs and misclassifies the cell.

MS Word. _get_paragraph_elements grouped runs by format using strip() as both the emit gate and the emitted value. It now groups without stripping and trims only the paragraph edges. _add_inline_equations_to_parent builds its own InlineGroup outside that path and stripped the fragment after the last equation, merging the formula into the next word ($A= \pi r^{2}$is the area formula); it now trims group edges only.

Markdown. Keep the raw Marko RawText fragments rather than stripping them, and emit a space for a soft line break — which is how a soft-wrapped paragraph actually renders, and what the join used to supply.

JATS. _strip_segments becomes _trim_segment_edges: drop empty segments, merge compatible neighbours, trim only the first and last.

ODF needed no contract change — it was already the reference producer — but this work exposed a genuine data-loss bug in it, fixed in its own commit (see below).

fix(odf): text between adjacent spans was being dropped

odfdo is lxml-backed, so the text between two child elements lives in the first child's tail. The run walker read only element.text and recursed into children, so every tail was silently discarded:

with <span>bold</span>, <span>underline</span>, and <span>italic</span> formatting

lost both ", ", the ", and ", and the trailing " formatting". text:line-break dropped the text following the break the same way.

This predates the whitespace contract and is data loss, not an artifact — the old hard " " join happened to re-insert a plausible-looking space at each gap, which is exactly why the committed groundtruth read as correct prose and nobody noticed. Making the join faithful is what surfaced it. Kept as a separate commit since it is independent of the rollout; its test uses odfdo's own text_recursive as an independent expected value rather than restating the walker's logic.

Temporary dependency source — must be removed before release

The last commit points tool.uv.sources at the docling-core branch so the lock-step pair is installable and CI resolves the same core the goldens were baselined against. uv.lock pins the exact commit.

Release checklist:

  1. Merge and release docling-core (minor, 2.x — not 3.0).
  2. Drop the docling-core entry from [tool.uv.sources].
  3. Raise the docling-core floor in dependencies from >=2.86.0 to the release carrying the faithful join, and re-lock.

The lock diff is larger than the single changed entry because moving docling-core from a registry wheel to a git checkout re-narrows environment markers across the graph. The resolved package set is unchanged at 382 and no dependency is added or removed.

Version skew

Both packages are in 2.x and a major bump is not affordable, so the dangerous combination is installable and worth understanding:

docling-core docling backends result
old (" " join) old (space-less runs) correct — status quo
new ("" join) new (whitespace runs) correct — the goal
new old word merge
old new (this PR) double spaces — cosmetic, already observed in the ODF goldens

Hence the merge order: docling-core first, then this.

Testing

All contract-relevant backend suites pass against the rebased docling-core (168 passed, 1 skipped): opendocument, markdown, jats, html, email, and inline-whitespace-contract. MS Word behavioral + contract tests pass; its test_e2e_docx_conversions is blocked only by the image-render step on a machine without LibreOffice (see "Still to do").

New tests/test_inline_whitespace_contract.py covers the cases that need a source document built on the fly: DOCX runs owning their whitespace, DOCX inline-equation boundaries, HTML boundaries around adjacent code spans (spaced, unspaced, and mixed with formatting), and <pre> interior whitespace. The existing H<sub>2</sub>O and spaced x<sup>2</sup> + y boundaries are asserted alongside them.

Goldens are re-baselined. textbox.docx is deliberately left untouched: its output varies between identical runs and its test is already xfail(strict=False), so regenerating it would only bless nondeterministic data.

Out of scope

  • <pre> with inline markup still produces one code item per fragment. This PR only stops it merging words (<pre>See <a>x</a> first</pre>) and preserves interior whitespace; the fragment structure is a separate pre-existing issue.
  • get_text's synthetic trailing " " for p/li/th/td — a faked block separator at the character-extraction layer, different concern, other consumers.
  • Real CSS whitespace processing — the white-space property, computed display per element, &nbsp;, bidi. Collapse-at-origin plus cross-run normalization covers the normal-flow content exercised here.

Checklist:

  • Documentation has been updated, if necessary.
  • Examples have been added, if necessary.
  • Tests have been added, if necessary.

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

DCO Check Failed

Hi @cau-git, your pull request has failed the Developer Certificate of Origin (DCO) check.

This repository supports remediation commits, so you can fix this without rewriting history — but you must follow the required message format.


🛠 Quick Fix: Add a remediation commit

Run this command:

git commit --allow-empty -s -m "DCO Remediation Commit for Christoph Auer <cau@zurich.ibm.com>

I, Christoph Auer <cau@zurich.ibm.com>, hereby add my Signed-off-by to this commit: 9e48e5d27b769c1b7aeed11c00ed2c81d98546c5
I, Christoph Auer <cau@zurich.ibm.com>, hereby add my Signed-off-by to this commit: 8a44fd21e9390cd600c19563d52e3f79b6c030a6"
git push

🔧 Advanced: Sign off each commit directly

For the latest commit:

git commit --amend --signoff
git push --force-with-lease

For multiple commits:

git rebase --signoff origin/main
git push --force-with-lease

More info: DCO check report

@mergify

mergify Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🔴 1 of 2 protections blocking · waiting on 👀 reviews

Protection Waiting on
🔴 Require two reviewer for test updates 👀 reviews
🟢 Enforce conventional commit

🔴 Require two reviewer for test updates

Waiting for

  • #approved-reviews-by >= 2
This rule is failing.

When test data is updated, we require two reviewers

  • #approved-reviews-by >= 2

Show 1 satisfied protection

🟢 Enforce conventional commit

Make sure that we follow https://www.conventionalcommits.org/en/v1.0.0/

  • title ~= ^(fix|feat|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\(.+\))?(!)?:

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.74468% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
docling/backend/html_backend.py 95.91% 2 Missing ⚠️
docling/backend/msword_backend.py 96.29% 1 Missing ⚠️
docling/backend/xml/jats_backend.py 91.66% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@cau-git cau-git changed the title Cau/serializer whitespace fixes feat: honor the InlineGroup whitespace contract in the backends Jul 27, 2026
Inline runs carry their own significant whitespace and the docling-core
serializers now concatenate them faithfully, so producers must stop stripping run
boundaries. Previously each backend relied on the serializer's hard `" "` join,
which invented spaces (`H 2 O`, `x 2 + y 3`, `(strong) .`) and made the real
boundary unrecoverable.

HTML: collapse whitespace without trimming at the extraction origin
(`" ".join(s.split())` did both), then normalize once per block across the whole
run list -- per-node collapsing cannot see that `["a ", " ", " b"]` is one
boundary. `simplify_text_elements` merges same-format neighbours without a
separator, `to_single_text_element` concatenates verbatim, and the emitters no
longer strip individual runs. The single-char compactor skips candidates carrying
edge whitespace, or it would glue words in rendered span soup.
`_is_rich_table_cell` consumes the normalized list too: inter-tag whitespace in
`<td>\n<p>x</p>\n</td>` otherwise counts as two extra runs and misclassifies the
cell.

A boundary never lands inside a `` ` `` delimiter, because the serializers hoist
whitespace out of emphasis but not out of code. It goes on whichever neighbour is
plain; when both sides are code it becomes an unformatted run of its own, since
`` `a`` b` `` is corrupt either way. The emitters therefore keep whitespace-only
runs: normalization has already trimmed the block edges, so one that survives is
significant.

`<pre>` is preformatted, so it is taken verbatim and only its block edges are
trimmed. Stripping each fragment merged `<pre>See <a>x</a> first</pre>` into one
word once the join stopped compensating. The one-code-item-per-fragment shape of
`<pre>` with inline markup is a separate pre-existing issue and is left alone.

MS Word: group runs by format without stripping, then trim only the paragraph
edges. `_add_inline_equations_to_parent` builds its own InlineGroup outside that
path and stripped the fragment after the last equation, merging the formula into
the next word (`$A= \pi r^{2}$is the area formula`); it now trims group edges
only. JATS: `_strip_segments` becomes `_trim_segment_edges`. Markdown: keep the
raw Marko `RawText` fragments and emit a space for a soft line break, which is
how a soft-wrapped paragraph renders and which the join used to supply.

Goldens are re-baselined. `textbox.docx` is left untouched: its output varies
between identical runs, and its test is already `xfail(strict=False)`, so
regenerating it would only bless nondeterministic data.

Ships as a minor, not a major: `feat!:` would make python-semantic-release
(7.34.6) bump to 3.0, and a 3.0 is not affordable here. Needs a docling-core
floor bump to the release that carries the faithful join, once that number exists
-- an older docling-core with these backends produces double spaces (cosmetic),
but a newer docling-core under older backends merges words.
These backends emit inline runs that own their boundary whitespace, which only a
faithful-join docling-core renders correctly. Point `tool.uv.sources` at the
branch carrying that change so the lock-step pair is installable and CI resolves
the same core the goldens were baselined against.

`uv.lock` pins the exact commit (66040cb7). Temporary: drop the source and raise
the `docling-core` floor in `dependencies` once the release carrying the faithful
join exists on PyPI.

The lock diff is larger than the one entry because moving docling-core from a
registry wheel to a git checkout re-narrows environment markers across the graph.
The resolved package set is unchanged at 382, and no dependency is added or
removed.
I, Christoph Auer <cau@zurich.ibm.com>, hereby add my Signed-off-by to this commit: ec6163c
I, Christoph Auer <cau@zurich.ibm.com>, hereby add my Signed-off-by to this commit: 8a7b4fe
I, Christoph Auer <cau@zurich.ibm.com>, hereby add my Signed-off-by to this commit: 31a4a73

Signed-off-by: Christoph Auer <cau@zurich.ibm.com>
Rebase integration. Upstream #3949 (ODF anchor hyperlinks) stripped whitespace
at hyperlink boundaries in _normalize_odf_text_runs, correct under the old ''
join which re-inserted a separator, but a word-merge under the faithful join
(Watch[the talk]for context). The boundary space now stays in the plain run
and the serializer places it outside the link markup, per the contract.

- drop the hyperlink-boundary strip; update #3949's two tests to the
  contract-correct whitespace-bearing item texts (markdown was already right)
- regenerate ODF goldens (double-space / space-before-punct artifacts gone,
  upstream #3850 inter-span text renders tight)
- bump three upstream-added docx goldens to schema 1.11.0

Signed-off-by: Christoph Auer <cau@zurich.ibm.com>
Re-resolve the docling-core git source to the rebased branch tip
(e4e745a, v2.91.0) after both PRs were rebased onto main; the lock
previously pinned the pre-rebase commit 66040cb7.

Signed-off-by: Christoph Auer <cau@zurich.ibm.com>
@cau-git
cau-git force-pushed the cau/serializer-whitespace-fixes branch from 33c43dc to 93e3049 Compare August 18, 2026 13:26
@cau-git
cau-git marked this pull request as ready for review August 19, 2026 08:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant