docling-project / docling-project/docling-core
Page break emitted after a group whose children start on a new page
- Dominant language
- HTML
- Stars
- 282
- Forks
- 214
- Avg merge
- 2d 15h
- Merged PRs (30d)
- 21
Description
### Bug
When `export_to_markdown(page_break_placeholder=...)` walks a `GroupItem` whose children begin on a
new page, the page break is emitted **after** the whole group instead of before it. Every item in
that group is attributed to the previous page.
The cause is in `_iterate_items()` (`docling_core/transforms/serializer/common.py`, lines 99-136):
1. The group branch only matches `ListGroup | InlineGroup`. A plain `GroupItem` — for example one
with `GroupLabel.KEY_VALUE_AREA`, which the PDF pipeline creates for invoice header blocks — is
not a `DocItem` either, so **neither branch runs** and no page break is emitted at the group.
2. The group is then serialized as a single part, children included.
3. Only afterwards does the outer loop reach the group's children, and the `elif isinstance(item,
DocItem)` branch finally emits the break — by which point the children's text is already in the
previous page's section.
There is a second, related problem in the same block: when the group branch *does* match (a
`ListGroup` or an `InlineGroup`), it yields the page break node but does **not** update
`prev_page_nr` nor increment `page_break_i`. The break for the same boundary is therefore emitted a
second time by the first child, and the duplicate only disappears because both nodes get the same
`self_ref` and `get_parts()` skips already-visited refs.
Impact for us: we rely on the placeholder count and position to map markdown sections to physical
pages (page-number metadata for citations, header/footer detection per page, OCR injection for
scanned pages). On a 287-page proposal document, 22 sections were misattributed and one section
ended up empty: the text of physical page 212 was in section 211, so section 212 had 0 % word
coverage against the page's native text layer.
We are working around it in our own `DocSerializer` subclass by (a) peeking the first page of **any**
`GroupItem` and (b) updating `prev_page_nr` when doing so. With that change our output becomes
byte-for-byte identical to exporting page by page (`export_to_markdown(page_no=n)` joined with the
placeholder) on all 21 multi-page documents of our corpus, while the page-by-page export costs
179.54 s versus 1.06 s on the 287-page document.
### Steps to reproduce
```python
from docling_core.types.doc.base import BoundingBox, CoordOrigin, Size
from docling_core.types.doc.document import DoclingDocument, PageItem, ProvenanceItem
from docling_core.types.doc.labels import DocItemLabel, GroupLabel
BBOX = BoundingBox(l=50, t=700, r=500, b=680, coord_origin=CoordOrigin.BOTTOMLEFT)
doc = DoclingDocument(name="repro")
for page_no in (1, 2):
doc.pages[page_no] = PageItem(page_no=page_no, size=Size(width=595, height=842))
doc.add_text(label=DocItemLabel.TEXT, text="Body of page one",
prov=ProvenanceItem(page_no=1, bbox=BBOX, charspan=(0, 16)))
group = doc.add_group(label=GroupLabel.KEY_VALUE_AREA)
for text in ("Phone", "Fax"):
doc.add_text(label=DocItemLabel.TEXT, text=text, parent=group,
prov=ProvenanceItem(page_no=2, bbox=BBOX, charspan=(0, len(text))))
print(doc.export_to_markdown(page_break_placeholder=""))
```
Observed:
```
Body of page one
Phone
Fax
```
Expected — `Phone` and `Fax` have `page_no=2`, so they belong after the break:
```
Body of page one
Phone
Fax
```
Suggested fix, in `_iterate_items()`: match any `GroupItem` in the group branch (not just
`ListGroup | InlineGroup`), and set `prev_page_nr = page_no` / increment `page_break_i` when the
group's break is yielded, exactly as the `DocItem` branch does.
### Docling version
```
docling: 2.115.0
docling-core: 2.87.1
docling-ibm-models: 3.13.2
docling-parse: 7.8.1
```
### Python version
```
Python 3.10.12
```
Contributor guide
Research direction
Read `_iterate_items()` in `docling_core/transforms/serializer/common.py`, especially lines 99-136, then run the two-page reproduction from the issue. Done means the page-break placeholder appears before the `GroupItem` children when they start on a new page, and the same boundary does not produce a duplicate break for supported group types.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100