#4409·docling

Heading hierarchy: promote body-size runs set in a non-dominant face (labeled `list_item`/`text`) to `section_header`, the way bookmarks already do

Author: dylan82Created Sep 28, 2026Updated Sep 28, 2026

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.

python
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 statement

The 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 as enabled: 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: TextItem and ListItem whose provenance box overlaps a run of two or more cells, all at body size (within style_size_tolerance), all in one face that is not the dominant one. The cell collection is the one _heading_style already does (prov.bbox.to_top_left_origin(page_height) against cell.rect.to_bounding_box().to_top_left_origin(page_height), hbox.overlaps(cbox)), applied to non-heading items; parse_font_style is not needed for this signal, only font_name identity.
  • Promotion exactly as _infer_from_bookmarks does it: a new SectionHeaderItem carrying the item's parent, content_layer, prov, orig, text, formatting and hyperlink, swapped in with document.replace_item, before the heading list is collected. Level: one below the nearest preceding section_header, then left to the numbering and style passes like any other heading. TextItem would need the same treatment the bookmark pass currently reserves for ListItem.
  • 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 because generate_parsed_pages=True keeps the cells alive. But every consumer would have to re-derive the same page statistics and the same bbox reconciliation that _heading_style already 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.