docling-project / docling-project/docling
Chapter numbers without a trailing separator are not recognised, which inverts the hierarchy
- Dominant language
- Python
- Stars
- 66.4k
- Forks
- 4.8k
- Avg merge
- 2d 21h
- Merged PRs (30d)
- 84
Description
### Bug
`HeadingHierarchyOptions(enabled=True)` improves on the flat PDF heading stream, but on
documents that number clauses **without a trailing separator** — `1 Introduction` rather than
`1. Introduction` — the result is not just incomplete, it is **inverted**: a chapter is
assigned a deeper level than its own sections.
On [this minimal document](https://github.com/user-attachments/files/31845554/heading-levels-repro.pdf) (no PDF outline, so the bookmark signal cannot apply):
| Heading | Rank in the document | Expected | Actual | |
| --------------------------------- | -------------------- | -------- | ------ | ------------------------ |
| Lorem Ipsum Technical Note | title | 1 | 1 | |
| `1 INTRODUCTION` | chapter | 2 | 2 | |
| `1.1 Background` | section | 3 | 1 | ← shallower than chapter |
| `1.1.1 Earlier work` | subsection | 4 | 2 | ← same level as chapter |
| `Context` | unnumbered | 5 | 4 | |
| `1.2 Objectives` | section | 3 | 1 | ← shallower than chapter |
| `2 REQUIREMENTS` | chapter | 2 | 2 | |
| `2.1 Functional requirements` | section | 3 | 1 | ← shallower than chapter |
| `2.1.1 Interfaces` | subsection | 4 | 2 | ← same level as chapter |
| `Assumptions` | unnumbered | 5 | 4 | |
| `2.2 Non-functional requirements` | section | 3 | 1 | ← shallower than chapter |
| `Constraints` | unnumbered | 5 | 4 | |
| `3 CONCLUSION` | chapter | 2 | 2 | |
| `3.1 Summary` | section | 3 | 1 | ← shallower than chapter |
The absolute numbers do not matter — only the relative order does, and it is wrong in two ways:
1. Every `x.y` section is assigned a **shallower** level than the chapter that contains it, and
every `x.y.z` subsection sits at the **same** level as the chapter. Expected is
chapter < section < subsection < unnumbered heading.
2. Level 3 is never assigned, so the emitted levels have a hole.
By the documentation's own standard — "a wrong level is worse than a missing one" — this is
the case the feature is meant to avoid: the flat baseline asserts nothing about the hierarchy,
whereas this output asserts one that is upside down.
#### How common this numbering style is
It is what ISO prescribes. [ISO/IEC Directives, Part 2](https://boss.cen.eu/media/yypjl3mn/iso_iec_directives_part2.pdf)
§5.2.2 says "The clauses in each document or part shall be numbered with Arabic numerals,
beginning with 1 for the 'Scope' clause", and the Directives PDF is itself typeset that way —
its own headings read `3 Terms and definitions`, `4 General principles`, `5 Structure`, with
no full stop. Every ISO/IEC/EN/NEN standard follows it, as does the engineering and
consultancy report style derived from it, which is where we hit this: technical notes with
`1 INLEIDING`, `2 UITGANGSPUNTEN`, `2.1 …`, `2.1.1 …`.
(That PDF carries bookmarks, so it is not itself a reproduction — the bookmark signal takes
precedence there. It is offered only as evidence that the convention is widespread.)
#### Cause
In `docling/models/stages/heading_hierarchy/heading_hierarchy_model.py`:
```python
_DOTTED = re.compile(r"^(\d+(?:\.\d+)+)(?:[.)\]\s]|$)")
_ARABIC = re.compile(r"^(\d+)[.)]")
```
`_ARABIC` requires a trailing `.` or `)`, so `1 INTRODUCTION` parses as **no marker at all**,
while `1.1 …` and `1.1.1 …` match `_DOTTED`. Because `_infer_from_numbering` compresses the
distinct `(family rank, depth)` keys *actually present* into contiguous levels, the shallowest
depth seen is 2, and `x.y` collapses to level 1 with `x.y.z` at 2.
The chapters then fall through to the style fallback. `_infer_from_style` ranks **all**
headings and its result is applied with `setdefault`, i.e. only to those numbering did not
claim — so the levels that survive are whatever the numbered headings did not consume (hence
the hole at 3), and they are compared against a numbering scale they were never calibrated
with. The chapter lands at 2, its own sections at 1.
#### Suggestion
Recognising a bare arabic marker would fix the primary case and, on this document, the
inversion with it: with `1 INTRODUCTION` at depth 1, the dotted depths land at 2 and 3 and the
style leftovers rank below them.
A bare leading number is genuinely ambiguous (years, quantities, figure captions), so matching
`^(\d+)\s+\S` unconditionally is probably too eager. A guard that keeps it conservative:
accept the pattern only when **several headings in the document share it and their numbers
increment** — a real chapter sequence is 1, 2, 3, whereas an incidental leading number is a
one-off. That also fits the existing design, where a scheme is only meaningful relative to the
other headings in the same document.
Note for whatever pattern replaces `[.)]`: the separator is usually typeset as a tab or a wide
fixed gap rather than a single space, and text extraction renders that as one or more spaces
depending on the producer — so the matcher wants `\s+`, not `" "`.
Separately, and lower priority: it may be worth making the numbering/style merge scale-aware,
so a style-derived level is never allowed to sit deeper than a numbering-derived ancestor.
That would contain the damage whenever numbering is recognised for only part of a document,
rather than relying on every marker style being covered.
### Steps to reproduce
1. Take the attached `heading-levels-repro.pdf` — 2 pages of Lorem Ipsum, generated with
WeasyPrint. Chapters are `1 INTRODUCTION`, sections `1.1 Background`, subsections
`1.1.1 Earlier work`, plus unnumbered headings (`Context`, `Assumptions`); each rank has its
own font size (20/16/13/11pt). Every heading carries `bookmark-level: none`, so the file has
**no outline** and the behaviour of the numbering and style signals is isolated.
2. Convert it with heading hierarchy enabled:
```python
from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import HeadingHierarchyOptions, PdfPipelineOptions
from docling.document_converter import DocumentConverter, PdfFormatOption
from docling_core.types.doc import SectionHeaderItem
options = PdfPipelineOptions()
options.generate_parsed_pages = True
options.heading_hierarchy_options = HeadingHierarchyOptions(enabled=True)
converter = DocumentConverter(
format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=options)}
)
doc = converter.convert("heading-levels-repro.pdf").document
for item, _ in doc.iterate_items():
if isinstance(item, SectionHeaderItem):
print(item.level, item.text)
```
3. Observed output — `1.1 Background` (1) is shallower than `1 INTRODUCTION` (2):
```
1 Lorem Ipsum Technical Note
2 1 INTRODUCTION
1 1.1 Background
2 1.1.1 Earlier work
4 Context
1 1.2 Objectives
2 2 REQUIREMENTS
1 2.1 Functional requirements
2 2.1.1 Interfaces
4 Assumptions
1 2.2 Non-functional requirements
4 Constraints
2 3 CONCLUSION
1 3.1 Summary
```
The same output comes back through docling-serve 1.31.0 (`do_pdf_heading_hierarchy: true`),
which pins this docling version.
### Docling version
```
Docling version: 2.121.0
Docling Core version: 2.92.0
Docling IBM Models version: 3.14.0
Docling Parse version: 7.15.0
Python: cpython-312 (3.12.13)
Platform: Linux-6.8.0-117-generic-x86_64-with-glibc2.34
```
### Python version
```
Python 3.12.13
```
Contributor guide
Research direction
Start in docling/models/stages/heading_hierarchy/heading_hierarchy_model.py, reading _DOTTED, _ARABIC, _infer_from_numbering, and _infer_from_style. Run the supplied conversion against heading-levels-repro.pdf and verify that bare-number chapters produce chapter < section < subsection < unnumbered heading levels without the level-3 hole.
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
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100