Skip to content

DocLangDocSerializer raises ValueError for SectionHeaderItem.level=6 (unclamped +1 overflows heading token range) #685

Description

@Steve-Allison

Summary

DocLangDocSerializer raises ValueError: level must be in [1, 6] for any SectionHeaderItem with level == 6. This is a real, reachable case (not a synthetic edge case): DoclingDocument.SectionHeaderItem.level (LevelNumber) is allowed up to 100, and Docling's own PDF heading-hierarchy inference (HeadingHierarchyOptions, docling#3633, on by default with max_level=6 in docling-core's own default) legitimately produces level-6 headings on real documents. We hit this converting a 12-page PDF with a nested procedure list ("To configure the DaVinci Resolve Project Server:", etc.) — 10 of its headings landed at level 6.

Root cause

docling_core/transforms/serializer/doclang.py (current main, and every released version through 2.87.1 — this file is untouched in the 2.87.0→2.87.1 diff):

elif isinstance(item, SectionHeaderItem):
    wrap_open_token = DocLangVocabulary._create_heading_token(level=item.level + 1)

_create_heading_token_create_level_open_token enforces the HEADING token's allowed range from _doclang_utils.py:

DocLangToken.HEADING: {DocLangAttributeKey.LEVEL: (1, 6)},

So item.level == 6level=7ValueError: level must be in [1, 6]. The +1 shift exists because level 1 is reserved for TitleItem, but nothing clamps the result back into range.

This is inconsistent with the library's own HTML serializer

docling_core/transforms/serializer/html.py performs the identical item.level + 1 computation for the same SectionHeaderItem, but clamps it:

section_level = min(item.level + 1, 6) if isinstance(item, SectionHeaderItem) else 1

(latex.py takes a third approach — it explicitly validates and raises a self-describing error, "LaTeX serializer: SectionHeaderItem.level must be in [1, 3]", since LaTeX only has 3 section depths. doctags.py passes item.level straight through with no transform.) doclang.py is the only one of the four that neither clamps, validates, nor passes through — it just lets the arithmetic overflow into the range check.

Repro

from docling_core.types.doc.document import DoclingDocument
from docling_core.transforms.serializer.doclang import DocLangDocSerializer, DocLangParams

doc = DoclingDocument(name="repro")
doc.add_heading(text="Top", level=1)
doc.add_heading(text="Deep", level=6)  # legal: LevelNumber allows up to 100

DocLangDocSerializer(doc=doc, params=DocLangParams()).serialize()
# ValueError: level must be in [1, 6]

Suggested fix

Mirror html.py's existing pattern — clamp instead of raising:

elif isinstance(item, SectionHeaderItem):
    wrap_open_token = DocLangVocabulary._create_heading_token(level=min(item.level + 1, 6))

(or, if silently flattening depth beyond 6 is undesirable, extend ALLOWED_ATTRIBUTE_RANGE[DocLangToken.HEADING]'s upper bound to accommodate the +1 shift against whatever the practical max SectionHeaderItem.level is expected to be — but the min(..., 6) clamp is the smallest change and matches existing precedent in the same file tree.)

Environment

  • docling-core 2.87.0 (installed), confirmed still present verbatim on main and in the 2.87.1 tag as of 2026-07-16.
  • macOS, Python 3.14.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions