Skip to content

Use PDF outline to correct the header hierarchy - #1509

Open
lfoppiano wants to merge 6 commits into
masterfrom
feature/use-outline-to-structure-head
Open

Use PDF outline to correct the header hierarchy#1509
lfoppiano wants to merge 6 commits into
masterfrom
feature/use-outline-to-structure-head

Conversation

@lfoppiano

@lfoppiano lfoppiano commented Jul 16, 2026

Copy link
Copy Markdown
Member

This PR exploit the PDF outline (when available) to improve the structuring of the sections and sections headers in fulltext. From the text alone you can tell when a new sub-level starts (two heads in a row), but not when a section returns to a higher level. That "ascent" is only knowable from the PDF outline (bookmarks). Section numbering can express it, but on a 1,985-document sample 79% of heads carry no @n number at all and 72% of documents have no numbered head, so numbering cannot carry hierarchy for most papers (Nature/ASM-style "Methods" with unnumbered sub-methods are the typical case).

Summary

It uses the outline only to state the level, never to restructure the document. The <div> structure is unchanged — one <div> per head, byte-identical to the outline-less output — and each head that can be located in the outline gets a @level attribute (1 = main section, 2 = sub-section, …):

<div><head n="3." level="1">Models</head><p>…</p></div>
<div><head n="3.1." level="2">Analytical model</head><p>…</p></div>
<div><head n="3.2." level="2">Finite element model</head><p>…</p></div>
<div><head n="3.2.1." level="3">Magnetization of bulk superconductors</head><p>…</p></div>
<div><head n="4." level="1">Results and discussion</head><p>…</p></div>   <!-- ascent back to 1 -->

level="1|2|3" matches the spelling already used in the fulltext training corpus (grobid-trainer/resources/dataset/fulltext/corpus/tei/), which until now was dead data no parser read.

Properties that fall out of this design:

  • Robust to missed headings. Each head is levelled independently, so a heading the model misses simply carries no level; it cannot drag other heads anywhere.
  • Omit when unknown. Heads that cannot be matched in the outline are left unmarked rather than guessed. Documents without a usable outline are unchanged.
  • No metric impact. section_title is scored as the set of head texts, and neither head text nor div structure changes — only an attribute is added.

Schema changes

@level had to be declared — it was legal only on <title> (the bibliographic sense).

While doing so it became clear GROBID's output has never validated against its own shipped grobid-home/schemas/rng/Grobid.rng. This PR also closes those pre-existing gaps, so the RelaxNG schema now accepts real GROBID output:

addition where
@coords (PDF bounding boxes) att.global (all elements)
@lang (bare variant of xml:lang, e.g. on orgName) att.global
<rs> (referencing string: funders, grant numbers/names in text) model.phrase
<funder>, <respStmt>, <resp> model.respLike (title statement)
<listOrg> model.listLike, plus interleavable with <div>s in <back>
xsi:schemaLocation root <TEI>
bare pointer/reference (model.ptrLike) directly under <div> (annex markers)
@url made optional on <graphic> GROBID emits graphics located only by @coords

@level is mirrored to the XSD and DTD. The element additions are RelaxNG-only: the shipped XSD has a pre-existing non-deterministic content-model defect that prevents it from loading at all, so it cannot be validated or meaningfully extended without a separate overhaul.

Note: xmllint --relaxng hangs on this schema (libxml2 RelaxNG compilation blowup, independent of this change). Validation here was done with jing.

Verification

Five corpora, 15,925 documents, all processed with this build.

corpus docs heads heads with @level ascents captured <head> after <p>
PMC_sample_1943 1,943 25,718 7,504 (29%) 1,050 0
biorxiv-10k-test-2000 1,999 32,139 1,735 (5%) 189 0
PLOS_1000 1,000 15,937 2 (0%) 0 0
eLife_984 983 20,224 4,204 (20%) 326 0
published (internal) 10,000 147,103 46,377 (31%) 5,292 0
total 15,925 241,121 59,822 6,857 0
  • 6,857 ascents recovered — the return-to-a-higher-level that is not inferable from the text.
  • <head> after <p> is 0 on every document, confirming the output stays structurally valid at scale (this is exactly the failure mode that sank the earlier attempts).
  • Coverage tracks outline availability. PLOS gets essentially nothing because its author-version PDFs carry no usable outline — the correct outcome: no levels are invented, and its output is effectively unchanged.
  • Schema validity (jing, samples per corpus): 150/150 on the internal dataset, 96/100 on each HF dataset, 115/116 on a PMC + publisher-diverse set. Every remaining failure is a malformed @target URI extracted from a corrupt PDF — a data issue the schema correctly rejects, not a schema gap.

Also included

  • PDFALTOOutlineSaxHandler: fixes nested-outline parent tracking. A single currentParentId was reset at every </TOCITEMLIST>, so items following a nested list were reattached to the root instead of their real parent. Replaced with a balanced parent-id stack. Java test ported to Kotlin, with nested and malformed outline fixtures.
  • DocumentNode.findNode / findNodeDepth: soft outline-label matching (Ratcliff/Obershelp ≥ 0.90) normalising the non-breaking spaces, soft hyphens and | separators that pdfalto outline labels carry. Heads are matched by their labelled text first, then retried with the section number stripped, since outlines commonly store the bare title ("Results") while the head reads "2. Results".
  • FullTextParser: enables outline extraction for full-text processing.

Limitations

  • Levels are only available where the PDF ships a usable outline (~20–31% of documents in modern corpora, near zero for older or author-version PDFs). Elsewhere heads are unmarked by design.
  • The level is normalised against the shallowest matched head in the piece; if a document's only matched heads are sub-sections, levels are relative rather than absolute. This degrades to omitted/relative levels, never to wrong structure.

@lfoppiano lfoppiano linked an issue Jul 18, 2026 that may be closed by this pull request
@lfoppiano lfoppiano linked an issue Jul 18, 2026 that may be closed by this pull request
@lfoppiano lfoppiano added this to the 0.10.0 milestone Jul 19, 2026
@lfoppiano
lfoppiano marked this pull request as ready for review July 19, 2026 07:34
The single currentParentId int was reset to -1 at every TOCITEMLIST close, so
items following a nested list were reattached to the root instead of to their
real parent section. Replace it with a balanced Deque pushed once per
TOCITEMLIST (-1 when the list has no idItemParent) and popped on close.

Port the SAX handler test to Kotlin with nested and malformed outline
fixtures; drop the superseded Java test.
Locate a section head in the outline tree by Ratcliff/Obershelp similarity
(>=0.90), returning its depth. Normalizes non-breaking spaces, soft hyphens
and '|' number separators that pdfalto outline labels carry but section heads
do not.
When the outline shows a head/sub-head hierarchy, keep sub-heads inside the
current section's <div> instead of opening a new (empty) <div> per head. A new
div opens only at outline-confirmed main-level heads (depth == minDepth) or at
heads absent from the outline; the flat div structure is preserved. Activated
per text piece only when adjacent section heads exist and the outline is
present, so documents without an outline are unchanged.

The legacy </head><head> div-splitting cleanup is skipped when this applies and
scoped to the region the call appended, since the buffer is shared across
body/annex calls. Enable outline extraction for full-text processing.
…ection

The previous rule folded any head deeper than the shallowest matched one into
the open div. When the sequence labeller missed a mid-level heading, the next
section's sub-heads were pulled into the previous section's div. An audit over
10k PDFs found 92 such cross-section divs.

Fold a sub-head only when all of the following hold:
 - its outline node is a descendant of the head that opened the div, so a
   sub-head whose parent heading was missed starts its own div instead;
 - the section numbering does not contradict that nesting, which overrides
   degenerate outlines that linearly nest unrelated sections;
 - the head could be located in the outline at all.

Match heads against the outline by their labelled text first, then retry with
the section number stripped: outlines commonly store the bare title ("Results")
while the head carries a number ("2. Results"), which a short title pushes below
the similarity threshold and would leave the parent head unanchored.

Measured over a 20% sample (1985 documents) against the same documents: cross
-section divs 7 -> 0, while grouping still removes 4621 head-only divs.
Replace the earlier div-grouping approach with a flat, non-destructive one. Grouping
sub-heads into a shared <div> put a <head> after a <p> in ~91% of grouped divs, which
is invalid TEI -- the same ill-formed output that shelved section nesting (issues #377,
#1074) before.

Instead keep the div structure identical to the outline-less output (one div per head)
and add a @Level attribute (1 = main section, 2 = sub-section, ...) on heads that can be
located in the PDF outline. The outline is the only signal that reveals when a section
returns to a higher level, which cannot be inferred from the text: 79% of heads carry no
number, so numbering cannot express hierarchy for most documents. A head GROBID misses
simply carries no level and affects nothing else; heads absent from the outline are left
unmarked.

computeSectionLevels matches each SECTION head to the outline (retrying with the section
number stripped, since outlines often store the bare title) and sets level = outline depth
- minDepth + 1. Removes the grouping helpers (isDescendantOf, numberingContradictsNesting,
computeSectionNodes).
…ments)

Add the section-head @Level attribute (the outline hierarchy level) to <head>.

Also bring the shipped RelaxNG schema in line with what GROBID actually emits: its
output has never validated against grobid-home/schemas/rng/Grobid.rng. Add the elements
and attributes GROBID produces but the schema omitted:
 - @coords (PDF bounding boxes) and @lang (bare variant of xml:lang, e.g. on orgName)
   on att.global, so they are accepted on every element;
 - <rs> (referencing string, e.g. funders/grants in running text) in model.phrase;
 - <funder> and <respStmt>/<resp> in model.respLike (title statement);
 - <listOrg>/<org> in model.listLike, and allowed interleaved with <div>s in <back>;
 - xsi:schemaLocation on the root <TEI>;
 - a bare pointer/reference (model.ptrLike) directly under <div> (annex markers);
 - make @url optional on <graphic> (GROBID emits graphics located only by @coords).

Validated with jing over ~275 documents (PMC, a publisher-diverse set, and the dataseer
corpus): all validate except a couple carrying malformed @target URIs extracted from
corrupt PDFs (a data issue, correctly rejected). The @Level addition is mirrored to the
XSD and DTD; the element additions are RelaxNG-only because the shipped XSD has a
pre-existing non-deterministic content-model defect that prevents it from loading at all.
@lfoppiano
lfoppiano force-pushed the feature/use-outline-to-structure-head branch from 2bc72ba to 3b22481 Compare August 8, 2026 21:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

How to get section hierarchy from fulltext? Support nested document sections

1 participant