docling-project / docling-project/docling
Reading order: top-of-page heading emitted last on same page despite correct bbox (single-column)
- Dominant language
- Python
- Stars
- 66.4k
- Forks
- 4.8k
- Avg merge
- 2d 21h
- Merged PRs (30d)
- 84
Description
### Bug
For a single-column regulatory PDF (IAEA SSG-42), `docling.convert(...)`
emits `doc.body.children` in an order that places a chapter heading
**after** its subheadings on the same physical page, even though the
bbox provenance of every item is correct.
The layout model knows where each block is — but the reading-order step
linearises them into a sequence that contradicts the bbox info.
Concretely, for IAEA Safety Standards Series **SSG-42 (Rev. 1)** —
*Safety of Nuclear Fuel Reprocessing Facilities* (2025, ISBN
978-92-0-101725-3, STI/PUB/2106):
- Direct PDF:
- Landing:
On docling page 19 (the first content page of chapter "1. INTRODUCTION";
docling's `page_no` is 1-indexed and counts the front matter, so the
docling-numbered page 19 corresponds to the page that physically reads
"1. INTRODUCTION / BACKGROUND / 1.1. Requirements... / OBJECTIVE / 1.5..."
— see the screenshots below), docling emits:
| body order | self_ref | label | page | bbox.t (top) | text |
|---:|---|---|---:|---:|---|
| earlier | `#/texts/135` | section_header | 19 | 572.88 | `"BACKGROUND"` |
| ... | `#/texts/140` | section_header | 19 | 247.88 | `"OBJECTIVE"` |
| ... | `#/texts/143` | footnote | 19 | 103.20 | `"1 INTERNATIONAL ATOMIC ENERGY AGENCY..."` |
| **later** | **`#/texts/144`** | **section_header** | **19** | **613.20** | **`"1. INTRODUCTION"`** |
Coords use `BOTTOMLEFT` origin, so a higher `t` value = higher position
on the page. `1. INTRODUCTION` (top=613.2) is **physically the topmost
item on page 19**, sitting above `BACKGROUND` (572.9), well above
`OBJECTIVE` (247.9), and far above the footnote (103.2). docling's body
order ranks it **last** among the four — a 510-point inversion against
the same-page footnote, with no bbox overlap to explain it.
The Markdown / HTML renderings inherit this:
```markdown
## BACKGROUND
- 1.1. Requirements...
- 1.2. ...
## OBJECTIVE
- 1.5. ...
## 1. INTRODUCTION ← misplaced
## SCOPE
```
(Should be `## 1. INTRODUCTION` first, then `## BACKGROUND`,
`## OBJECTIVE`, `## SCOPE`.)
### Steps to reproduce
1. Download `SSG-42_Rev1_2025.pdf` from
.
2. Run:
```python
from docling.document_converter import DocumentConverter
doc = DocumentConverter().convert("SSG-42_Rev1_2025.pdf").document
# Find every "INTRODUCTION" / "BACKGROUND" / "OBJECTIVE" item and dump prov.
for item in doc.texts:
text = (item.text or "").strip()
if any(k in text[:50] for k in ("INTRODUCTION", "BACKGROUND", "OBJECTIVE")):
prov = (item.prov or [None])[0]
print(
f"{item.self_ref} page={prov.page_no} top={prov.bbox.t:.1f} "
f"label={item.label} text={text[:60]!r}"
)
# Walk body.children in order, show items on page 19.
print("\n--- body order, page 19 only ---")
for ref in doc.body.children:
node = ref.resolve(doc=doc)
prov = getattr(node, "prov", None)
if prov and prov[0].page_no == 19:
text = (getattr(node, "text", None) or "")[:60]
print(f" {ref.cref} top={prov[0].bbox.t:.1f} {text!r}")
```
Expected: `1. INTRODUCTION` (top=613.2) appears first among page-19
items.
Actual: it is the LAST page-19 item before the page-20 chapter content.
### Expected behavior
Within a page, items emitted in body order should follow the physical
reading order indicated by their bbox provenance — at minimum, an item
that is unambiguously above another (no bbox overlap, large vertical
gap) should never be emitted later.
### Workaround
A page-scoped, stable insertion sort by `(page_no, -bbox.t)` of
`doc.body.children`, applied after `doc = converter.convert(...).document`
and before any `export_to_*` call, fully fixes our test case while
leaving correctly-ordered sequences untouched. Single-column only — a
naive `(page, top)` sort would corrupt multi-column layouts where
docling's current model (correctly) interleaves columns.
```python
def correct_reading_order(doc) -> int:
"""Re-order doc.body.children by (page, -bbox.t) within each page so
docling's reading-order flips on single-column PDFs are corrected.
Items lacking provenance (empty groups, etc.) are anchored to their
original position; cross-page order is never touched. Stable.
Returns the number of items repositioned.
"""
body = doc.body
children = list(body.children)
if len(children) < 2:
return 0
key_cache: dict = {}
def _first_prov(ref_item, _visited=None):
if _visited is None:
_visited = set()
cref = getattr(ref_item, "cref", None)
if cref is not None and cref in key_cache:
return key_cache[cref]
if id(ref_item) in _visited:
return None
_visited.add(id(ref_item))
try:
node = ref_item.resolve(doc=doc)
except Exception:
result = None
else:
result = None
prov = getattr(node, "prov", None)
if prov:
try:
result = (prov[0].page_no, -prov[0].bbox.t)
except (AttributeError, IndexError):
result = None
if result is None:
for child in getattr(node, "children", []) or []:
sub = _first_prov(child, _visited)
if sub is not None:
result = sub
break
if cref is not None:
key_cache[cref] = result
return result
# Insertion sort, page-scoped: each item floats up past same-page
# siblings whose key is greater (= top is lower on the page).
result: list = []
moved = 0
for cur in children:
cur_key = _first_prov(cur)
if cur_key is None:
result.append(cur)
continue
insert_at = len(result)
for j in range(len(result) - 1, -1, -1):
prev_key = _first_prov(result[j])
if prev_key is None:
break # anchor — don't pass items lacking prov
if prev_key[0] != cur_key[0]:
break # different page — stop
if prev_key[1] <= cur_key[1]:
break # prev already sorts before cur
insert_at = j
if insert_at != len(result):
moved += 1
result.insert(insert_at, cur)
body.children = result
return moved
```
The proper fix likely lives in `ReadingOrderPredictor`
(docling-ibm-models) so it benefits all serializers natively without
each downstream user having to apply this patch.
### Other layout models
We tried each of `heron`, `heron_101`, `egret_medium`, `egret_large`,
`egret_xlarge`, and `v2` (via `LayoutOptions(model_spec=...)`); the
`1. INTRODUCTION` misplacement persists across all of them, so this is
not a heron-specific layout-detection bug — it's downstream of layout,
in the body-order linearisation.
### Related issues
- #2971 — *PDF text items being positioned at bottom of document*. Very
similar symptom; the reported root cause there was
`do_table_structure=False`, but ours occurs with the default
`do_table_structure=True`.
- #1558 — *Suggestion to fix the reading order issue*. Architectural
insight: `ReadingOrderPredictor` DFS is sensitive to overlapping
bboxes; the suggested `eps` knob does not apply here because our items
have ~40-point vertical gaps and a 510-point page-scale inversion.
- #570 — *Incorrect Reading Order in Single-page Image-Text Layouts*.
Different layout class (image+text), same family of symptoms.
- #3233 — feature request `do_reading_order` option. A
`do_reading_order=...` knob could expose a fallback like the
workaround above.
### Docling version
```
docling: 2.93.0
docling-core: 2.74.1
docling-ibm-models: 3.13.2
docling-parse: 5.11.0
```
### Python version
```
Python 3.11.14
```
### Platform
macOS 26.3.1 (Apple Silicon, MPS accelerator).
`accelerator_options.device='auto'`. Same misorder reproduces with
`device='cpu'`.
Contributor guide
Assessment
This issue has not been assessed yet.