Heading hierarchy: promote body-size runs set in a non-dominant face (labeled `list_item`/`text`) to `section_header`, the way bookmarks already do
Requested feature
The new heading-hierarchy stage on main (HeadingHierarchyOptions, docs at usage/heading_levels.md) reads font style from the parsed cells, but only to rank items that the layout model has already labeled section_header. The one structural change it makes is promoting a list_item on a confident bookmark match. There is a common class of documents where the headings that carry the hierarchy are never labeled section_header in the first place, and have no bookmarks to rescue them: institutional reports whose headline statements are set as full paragraphs in a bolder face at body size. The stage has everything it needs to catch them (the parsed cells, per page, with font_name and a rect) and already has the promotion mechanism; what is missing is a style-based promotion signal, opt-in like the rest.
Reproducer, public document. IPCC AR6 Synthesis Report, Summary for Policymakers, 42 pages:
https://www.ipcc.ch/report/ar6/syr/downloads/report/IPCC_AR6_SYR_SPM.pdf (5 552 060 bytes, sha256 starts 3e347d04913c8fcc).
Its structure is three sections (A, B, C), each with a handful of headline statements (A.1 … A.4, B.1 … B.7, C.1 … C.7, eighteen in all) followed by numbered paragraphs (A.1.1, A.1.2 …). The statements are the hierarchy: they are the heading path you want on every chunk of the paragraphs beneath them.
import collections, re, sys
from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import HeadingHierarchyOptions, PdfPipelineOptions
from docling.document_converter import DocumentConverter, PdfFormatOption
HEADLINE = re.compile(r"^[ABC]\.\d+\s")
SUB = re.compile(r"^[ABC]\.\d+\.\d+\s")
opts = PdfPipelineOptions()
opts.do_ocr = False
opts.generate_parsed_pages = True
opts.heading_hierarchy_options = HeadingHierarchyOptions(enabled=True)
res = DocumentConverter(
format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=opts)}
).convert(sys.argv[1])
labels = collections.Counter()
for item in res.document.texts:
t = (item.text or "").strip()
if HEADLINE.match(t) and not SUB.match(t):
labels[str(item.label)] += 1
print(labels)
page = res.pages[9] # the page of A.1
by_font = collections.Counter()
for c in page.parsed_page.textline_cells:
by_font[c.font_name] += len(c.text or "")
print(by_font.most_common(3))
for c in page.parsed_page.textline_cells:
if c.text.strip().startswith(("A.1 ", "A.1.1")):
print(c.font_name, round(c.rect.to_bounding_box().height, 1), repr(c.text[:40]))On the released 2.69.1 (no hierarchy stage) all eighteen statements are list_item, as are the A.1.1 paragraphs; the section titles ("A. Current Status and Trends", "Observed Warming and its Causes") are section_header. Markdown export:
## A. Current Status and Trends
## Observed Warming and its Causes
- A.1 Human activities, principally through emissions of greenhouse gases, have unequivocally caused global warming, …
- A.1.1 Global surface temperature was 1.09 …On main with HeadingHierarchyOptions(enabled=True) and generate_parsed_pages=True:
label counts: Counter({'list_item': 18})
p10 list_item level=None A.1 Human activities, principally through emission
p11 list_item level=None A.2 Widespread and rapid changes in the atmosphere
p14 list_item level=None A.3 Adaptation planning and implementation has pro
p16 list_item level=None A.4 Policies and laws addressing mitigation have c
section headers with levels (first 12):
L1 CLIMATE CHANGE 2023 Synthesis Report
L2 Summary for Policymakers
L4 CLIMATE CHANGE 2023 Synthesis Report Summary for Policymaker
L6 Core Writing Team
L6 Technical Support Unit for the Synthesis Report
…The stage does what it says: the front-matter headings get levels from their style. The eighteen statements are untouched.
The document has no bookmarks (pypdfium2 outline: 0 entries), so the promotion path never fires, and the style signal never looks at these items because they are not SectionHeaderItems.
What the parsed cells know on the page of A.1 (characters per font_name):
2301 /WBVOOP+FrutigerLTPro-Condensed body
1795 /WBVOOP+FrutigerLTPro-LightCn footnotes and small print
524 /WBVOOP+FrutigerLTPro-Bold the statementThe four cells of the A.1 block are all FrutigerLTPro-Bold; the first cell of A.1.1 is FrutigerLTPro-Condensed. Median cell height is 8.4 for both, so size cannot separate them. Pages 11 and 14 repeat the pattern. The signal is: a run of at least two lines, at the page's body size, set entirely in a face that is not the page's dominant face.
Proposed shape. A fourth, opt-in signal in HeadingHierarchyModel, run right after the bookmark pass and before headings are collected, since it changes the set of headings like the bookmark pass does:
HeadingHierarchyOptions.promote_by_style: bool = False(off by default, same reasoning asenabled: a wrong heading is worse than a missing one).- Per page, from
parsed_pages[page_no].textline_cells: dominant face by character count; body size as the median cell height. Skip the page when no face reaches a threshold share of its characters (no dominant face, no decision). - Candidates:
TextItemandListItemwhose provenance box overlaps a run of two or more cells, all at body size (withinstyle_size_tolerance), all in one face that is not the dominant one. The cell collection is the one_heading_stylealready does (prov.bbox.to_top_left_origin(page_height)againstcell.rect.to_bounding_box().to_top_left_origin(page_height),hbox.overlaps(cbox)), applied to non-heading items;parse_font_styleis not needed for this signal, onlyfont_nameidentity. - Promotion exactly as
_infer_from_bookmarksdoes it: a newSectionHeaderItemcarrying the item'sparent,content_layer,prov,orig,text,formattingandhyperlink, swapped in withdocument.replace_item, before the heading list is collected. Level: one below the nearest precedingsection_header, then left to the numbering and style passes like any other heading.TextItemwould need the same treatment the bookmark pass currently reserves forListItem. - Guard against display pages: skip a run that holds more than a fixed share of the page's characters (that is a page set in a display face, not a statement).
On this document that promotes the eighteen statements and leaves the A.1.1 paragraphs alone, because those are in the dominant face. The name of the face plays no part: this file happens to name its faces truthfully under docling-parse, but the rule only needs "different from the dominant one", which also covers subset fonts with opaque names.
Related: #2246 reports the neighbouring symptom (nested numbered items split between section_header and list_item), which the same promotion signal would not fix on its own but would make consistent when the mis-labeled items share the heading face.
Alternatives
- Doing it downstream, in the application, right after
converter.convert()and before the document reaches the chunker. That is what I have outlined for one consumer (apowerb/th2rag#6), and it works becausegenerate_parsed_pages=Truekeeps the cells alive. But every consumer would have to re-derive the same page statistics and the same bbox reconciliation that_heading_stylealready performs inside Docling, and would have to reimplement promotion. The stage is the natural home. - Improving the layout model so it labels these paragraphs
section_header. Better in principle, and a different conversation: the model sees a paragraph-shaped block and calls it a paragraph, and no amount of post-hoc levelling changes that. The style signal is a cheap, deterministic complement that runs with no model. - Relying on bookmarks. Fine when they exist; this document, like most institutional PDFs I have measured, has none.
I have an implementation of this rule in TypeScript in a closed codebase (it is how my converter handles this PDF), so I can answer questions about thresholds and failure cases, and I can put together a PR if the shape above reads right to you. Environment: released numbers on Docling 2.69.1 / docling-core 2.60.1 / docling-parse 4.7.3, Python 3.9, macOS arm64; hierarchy run on main at c14c373 (reports itself as 2.130.0) / docling-core 2.99.0, Python 3.12, same machine and same PDF.
Source: docling-project/docling