fix(markdown): preserve hard line breaks - #4019
Conversation
|
✅ DCO Check Passed Thanks @aaarif796, all your commits are properly signed off. 🎉 |
I, aaarif796 <aaarif796@gmail.com>, hereby add my Signed-off-by to this commit: a4847f5 Signed-off-by: aaarif796 <aaarif796@gmail.com>
Merge Protections🟢 Merge protection satisfied — ready to merge. Show 1 satisfied protection🟢 Enforce conventional commitMake sure that we follow https://www.conventionalcommits.org/en/v1.0.0/
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
Thanks @aaarif796 for suggesting this PR. This is part of a broader issue as described in #4011 |
Thanks @ceberam for the detailed clarification |
Description
This PR fixes the Markdown parsing side of the hard line break round-trip
described in #4011.
A hard line break in GitHub Flavored Markdown (GFM) can be represented using
two trailing spaces before a newline:
The expected Docling representation is a single
TextItemcontaining anembedded newline:
Previously, the Markdown backend could interpret the content after the hard
line break as a separate
TextItem, resulting in a loss of the distinctionbetween an intra-block hard line break and a paragraph/document-item
boundary.
This PR preserves the hard line break as
\ninside the appropriateTextItem.Related Issue
Resolves #4011
Problem
The Markdown round-trip consists of two directions:
For this round-trip to be lossless, an embedded newline in a
TextItemmusthave the same semantics in both directions.
For example, a
TextItemcontaining:"Author 1\nAffiliation 1"represents one text block with an intra-block line break.
It is different from two separate text items:
The Markdown representation of an embedded hard line break is:
where the two spaces before the newline are significant.
Investigation
I traced the problem through the Markdown parser and Docling document model
to understand where the hard line break was being lost.
1. Docling text-item representation
I first inspected
DoclingDocument.add_text()and theTextItemstructure.Every call to
add_text()creates a separateTextItem.Therefore:
produces two separate document items.
Whereas:
keeps the newline inside one text item.
This confirmed that the parser must preserve a hard line break inside the same
TextItem.2. Marko AST investigation
I then inspected how Marko parses Markdown line breaks.
For:
Marko produces:
with:
For a normal soft Markdown line break, Marko produces the same node type with:
This provides a semantic distinction between hard and soft line breaks without
having to manually inspect the original Markdown source.
The implementation therefore uses:
3. Inline formatting investigation
I also tested:
Marko represents formatted content as separate inline nodes.
Therefore, reconstructing the complete paragraph as plain text would risk losing
formatting information.
The implementation keeps the existing inline-node processing and introduces
only small state to represent a pending hard line break.
Root Cause
The Markdown backend already processed:
but did not preserve the semantic difference between:
and:
As a result, an explicit hard line break was not carried forward when the
following text was processed.
The parser could consequently create a new
TextIteminstead of keeping thenewline within the existing text item.
Implementation
A pending hard-line-break state was added to the Markdown backend:
When Marko reports an explicit hard line break:
The newline is not immediately added as a separate document item.
Instead, the backend waits for the following text content.
When the next text node is processed, the pending state is consumed. If the
previous text item belongs to the same parent, the following text is appended
using
\n:Otherwise, normal
add_text()behavior is retained.The pending state is then reset:
This preserves the hard line break without introducing an unnecessary
paragraph-level
TextItem.Example
Before
Input:
The parser could produce:
which loses the distinction between a hard line break and separate document
items.
After
The parser produces:
This matches the expected Docling representation.
Formatted Example
Input:
The parser continues to preserve the formatting of
Johnwhile alsopreserving the hard line break.
The implementation operates on Marko's inline AST rather than flattening the
paragraph into plain text.
Tables
Table handling is kept separate.
The existing table-specific behavior remains unchanged:
Hard-line-break handling is therefore not applied as normal paragraph text
while processing table content.
Soft Line Breaks
Normal soft line breaks are intentionally not treated as hard line breaks.
Marko distinguishes them using:
Therefore:
continues through the existing behavior, while:
is treated as an explicit hard line break.
This avoids changing existing semantics for ordinary Markdown line wrapping.
Code and Formula Content
The implementation is limited to normal inline text processing.
Existing handling for code blocks, code spans, formulas, and other specialized
document elements remains unchanged.
Tests
Regression tests were added to ensure the behavior remains stable.
Plain hard line break
This verifies that the two Markdown lines remain inside a single
TextItem.Hard line break with formatting
This verifies that hard-line-break handling does not break existing inline
formatting behavior.
Verification
Targeted hard-line-break test
Result:
Formatted hard-line-break test
Result:
Full Markdown backend test suite
Result:
The warning is from the existing
test_convert_leading_dash_sequencestest and is unrelated to this change.Code Quality Checks
The repository checks were run with:
The following checks passed:
Two repository hooks invoke
python3directly:On the Windows development environment,
python3is not available as acommand.
The underlying scripts were therefore verified directly using the project's
Python environment:
and:
Both completed successfully.
No repository hook configuration was changed for this platform-specific issue.
Files Changed
docling/backend/md_backend.pyUpdated Markdown AST processing to recognize explicit hard line breaks and
preserve them as embedded newlines in the appropriate
TextItem.tests/test_backend_markdown.pyAdded regression coverage for:
Scope
This PR focuses specifically on the Markdown parsing side of the round-trip
described in #4011.
Markdown serialization is handled separately by
docling-core.No changes were made to the Markdown serializer in this repository.
Acceptance Criteria
LineBreak(soft=False)are recognized.\n.TextItem.TextItems are avoided.typasses.tach-module-coveragecheck passes.max-linescheck passes.Checklist