feat: honor the InlineGroup whitespace contract in the backends - #3891
Open
cau-git wants to merge 5 commits into
Open
feat: honor the InlineGroup whitespace contract in the backends#3891cau-git wants to merge 5 commits into
cau-git wants to merge 5 commits into
Conversation
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 commitRun 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 directlyFor the latest commit: git commit --amend --signoff
git push --force-with-leaseFor multiple commits: git rebase --signoff origin/main
git push --force-with-leaseMore info: DCO check report |
Contributor
Merge Protections🔴 1 of 2 protections blocking · waiting on 👀 reviews
🔴 Require two reviewer for test updatesWaiting for
This rule is failing.When test data is updated, we require two reviewers
Show 1 satisfied protection🟢 Enforce conventional commitMake sure that we follow https://www.conventionalcommits.org/en/v1.0.0/
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
3 tasks
Open
3 tasks
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
force-pushed
the
cau/serializer-whitespace-fixes
branch
from
August 18, 2026 13:26
33c43dc to
93e3049
Compare
cau-git
marked this pull request as ready for review
August 19, 2026 08:15
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
mainRebased onto latest
main(69 commits). Notable interactions with upstream work that landed in the meantime:fix(odf)commit described below was dropped — upstream fix(odf): preserve text after inline elements #3850 ("preserve text after inline elements") landed the samechild.tailrecovery independently, with a cleanerinner_textleaf fallback and its own test. The section is kept below for context, but the fix now comes from fix(odf): preserve text after inline elements #3850._normalize_odf_text_runs— correct under the old" "join, but a word-merge (Watch[the talk]for context.) under the faithful join. This is exactly the "a new producer must honor the contract" failure mode. The strip is removed; the boundary space now stays in the plain run and the serializer places it outside the link markup. fix(odt): populate TextItem.hyperlink for anchor elements #3949's two tests are updated to the contract-correct whitespace-bearing item texts (their Markdown assertions were already right).md_backendmerged with upstream fix(md): keep the last cell of table rows without a trailing pipe #3817 (pipeless tables): the raw fragment feeds run whitespace, a separatestripped_textdrives table detection, and upstream'sin_pipeless_tablegate is preserved.Still to do before merge:
uv.lockstill pins the pre-rebase docling-core commit. Push the rebased docling-core#693 branch, thenuv lockhere (the[tool.uv.sources]entry tracks the branch).textbox.docxandtest_emf_docx.docxare 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
InlineGroupruns 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 3and(strong) .. Once a backend strips the boundary, the real spacing is unrecoverable downstream, so the fix has to happen at the origin.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_elementsmerges same-format neighbours without a separator,to_single_text_elementconcatenates verbatim, and the emitters no longer strip individual runs.Two consequences worth calling out:
`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`ab` `` 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._is_rich_table_cellconsumes 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_elementsgrouped runs by format usingstrip()as both the emit gate and the emitted value. It now groups without stripping and trims only the paragraph edges._add_inline_equations_to_parentbuilds its ownInlineGroupoutside 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
RawTextfragments 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_segmentsbecomes_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 droppedodfdo is lxml-backed, so the text between two child elements lives in the first child's
tail. The run walker read onlyelement.textand recursed intochildren, so every tail was silently discarded:lost both
", ", the", and ", and the trailing" formatting".text:line-breakdropped 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 owntext_recursiveas 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.sourcesat the docling-core branch so the lock-step pair is installable and CI resolves the same core the goldens were baselined against.uv.lockpins the exact commit.Release checklist:
docling-coreentry from[tool.uv.sources].docling-corefloor independenciesfrom>=2.86.0to 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:
" "join)""join)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; itstest_e2e_docx_conversionsis blocked only by the image-render step on a machine without LibreOffice (see "Still to do").New
tests/test_inline_whitespace_contract.pycovers 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 existingH<sub>2</sub>Oand spacedx<sup>2</sup> + yboundaries are asserted alongside them.Goldens are re-baselined.
textbox.docxis deliberately left untouched: its output varies between identical runs and its test is alreadyxfail(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" "forp/li/th/td— a faked block separator at the character-extraction layer, different concern, other consumers.white-spaceproperty, computeddisplayper element, , bidi. Collapse-at-origin plus cross-run normalization covers the normal-flow content exercised here.Checklist: