@@ -424,10 +424,32 @@ func (p *PDF) parseDoc(_ context.Context, buf []byte) (*ParsedDoc, error) {
424424 // depth relative to the font-derived level. We only ever DEEPEN
425425 // (never change the base level), so top-level headings — numbered
426426 // "3" or not ("Abstract") — stay siblings at the same font level.
427+ newLevel := lvl
427428 if nd , ok := numberedHeadingDepth (text ); ok && nd > 1 {
428- lvl += nd - 1
429+ newLevel += nd - 1
429430 }
430- current = & flat {level : lvl , title : text }
431+ // Multi-line display heading: a run of heading rows at the SAME
432+ // level, on the SAME page, with NO body text accrued between them
433+ // is one visual heading wrapped across lines — e.g. a hero title
434+ // "THE SYSTEM, END-TO-END" / "How Vectorless" / "actually works."
435+ // Left un-merged, each line becomes its own heading flat whose
436+ // body is empty, shipping as a "purely structural" Section that
437+ // returns empty content to the section browser / API. Fold the
438+ // continuation line into the current heading instead so the body
439+ // that follows attaches to the whole title and no empty fragment
440+ // section is emitted. We only merge when the current flat is
441+ // itself an as-yet-empty heading at the same level on the same
442+ // page — never across a level change (real nesting) or once body
443+ // text has begun.
444+ if current .level > 0 && newLevel == current .level &&
445+ current .title != "" &&
446+ strings .TrimSpace (current .body .String ()) == "" &&
447+ (current .pageEnd == 0 || row .page == 0 || row .page == current .pageEnd ) {
448+ current .title = strings .TrimSpace (current .title + " " + text )
449+ touch (current , row .page )
450+ continue
451+ }
452+ current = & flat {level : newLevel , title : text }
431453 touch (current , row .page )
432454 flats = append (flats , current )
433455 continue
@@ -509,6 +531,11 @@ func (p *PDF) parseDoc(_ context.Context, buf []byte) (*ParsedDoc, error) {
509531 }}
510532 }
511533
534+ // Drop empty "structural" leaf fragments (multi-line title remnants,
535+ // running headers) so no section returns empty content to the browser /
536+ // API; their titles ride onto the sibling body they head.
537+ rootSec .Children = foldEmptyLeafSections (rootSec .Children )
538+
512539 // Internal sections inherit the union of their children's page ranges
513540 // so callers reading the outline can still cite a page span.
514541 propagateSectionPages (rootSec .Children )
@@ -550,6 +577,115 @@ func (p *PDF) resolvedParseTimeout() time.Duration {
550577 return p .ParseTimeout
551578}
552579
580+ // foldEmptyLeafSections eliminates leaf sections (no children) whose
581+ // content is empty. A PDF whose heading detector fires on a fragment that
582+ // carries no body — a lone display-title line the row-merge didn't catch,
583+ // a repeated running header, a stray bold word — otherwise ships as a
584+ // "purely structural" Section that returns empty content to the section
585+ // browser and the /v1/sections/{id} API, which reads as a broken/empty
586+ // part of the document.
587+ //
588+ // The fold is lossless: an empty leaf's TITLE is preserved by attaching it
589+ // as a bold markdown heading line to the content of the sibling it heads —
590+ // the NEXT sibling when one exists (the body it introduces), else the
591+ // PREVIOUS sibling. Consecutive empty leaves accumulate and flush together
592+ // into the first sibling that can carry them, so a multi-line title that
593+ // escaped the row-merge still lands as one heading block above its body.
594+ // Page ranges union so citations stay correct. Internal nodes (sections
595+ // WITH children) are recursed into but never dropped — an empty heading
596+ // with real sub-sections is legitimate structure whose content lives in
597+ // its children.
598+ //
599+ // A lone empty leaf with no siblings to carry its title is kept as-is
600+ // (dropping it would erase the only trace of that heading); this is the
601+ // rare degenerate case and harms nothing.
602+ func foldEmptyLeafSections (sections []Section ) []Section {
603+ for i := range sections {
604+ if len (sections [i ].Children ) > 0 {
605+ sections [i ].Children = foldEmptyLeafSections (sections [i ].Children )
606+ }
607+ }
608+
609+ // Collect the indices of empty leaves eligible to fold. A section is an
610+ // empty leaf when it has no children and no non-whitespace content.
611+ emptyLeaf := func (s * Section ) bool {
612+ return len (s .Children ) == 0 && strings .TrimSpace (s .Content ) == ""
613+ }
614+ // Nothing to do unless there is at least one empty leaf AND at least one
615+ // sibling that can carry a folded title.
616+ empties , carriers := 0 , 0
617+ for i := range sections {
618+ if emptyLeaf (& sections [i ]) {
619+ empties ++
620+ } else {
621+ carriers ++
622+ }
623+ }
624+ if empties == 0 || carriers == 0 {
625+ return sections
626+ }
627+
628+ out := make ([]Section , 0 , len (sections ))
629+ var pendingTitles []string
630+ var pendStart , pendEnd int
631+ takePending := func (target * Section ) {
632+ if len (pendingTitles ) == 0 {
633+ return
634+ }
635+ var b strings.Builder
636+ for _ , t := range pendingTitles {
637+ b .WriteString ("**" )
638+ b .WriteString (t )
639+ b .WriteString ("**\n \n " )
640+ }
641+ b .WriteString (target .Content )
642+ target .Content = strings .TrimSpace (b .String ())
643+ target .PageStart = minNonZero (pendStart , target .PageStart )
644+ if pendEnd > target .PageEnd {
645+ target .PageEnd = pendEnd
646+ }
647+ pendingTitles = nil
648+ pendStart , pendEnd = 0 , 0
649+ }
650+
651+ for i := range sections {
652+ s := sections [i ]
653+ if emptyLeaf (& s ) {
654+ if t := strings .TrimSpace (s .Title ); t != "" {
655+ pendingTitles = append (pendingTitles , t )
656+ pendStart = minNonZero (pendStart , s .PageStart )
657+ if s .PageEnd > pendEnd {
658+ pendEnd = s .PageEnd
659+ }
660+ }
661+ continue // drop the empty leaf; its title rides on pendingTitles
662+ }
663+ // A real section: it carries any pending titles as heading prefix
664+ // (these are the fragments it follows).
665+ takePending (& s )
666+ out = append (out , s )
667+ }
668+
669+ // Any titles still pending had no following carrier — attach them to the
670+ // last emitted sibling as a trailing heading block so nothing is lost.
671+ if len (pendingTitles ) > 0 && len (out ) > 0 {
672+ last := & out [len (out )- 1 ]
673+ var b strings.Builder
674+ b .WriteString (last .Content )
675+ for _ , t := range pendingTitles {
676+ b .WriteString ("\n \n **" )
677+ b .WriteString (t )
678+ b .WriteString ("**" )
679+ }
680+ last .Content = strings .TrimSpace (b .String ())
681+ if pendEnd > last .PageEnd {
682+ last .PageEnd = pendEnd
683+ }
684+ last .PageStart = minNonZero (pendStart , last .PageStart )
685+ }
686+ return out
687+ }
688+
553689// propagateSectionPages fills internal-node PageStart/PageEnd from the union
554690// of descendant leaf ranges where the internal node didn't have its own
555691// (because its body was empty / hoisted into children). Leaves keep their
@@ -1268,7 +1404,9 @@ func parsePDFWithOutline(outline pdflib.Outline, rows []pdfRow) (*ParsedDoc, boo
12681404 title = rootSec .Children [0 ].Title
12691405 }
12701406
1271- // Propagate page ranges so internal nodes span their children.
1407+ // Fold empty leaf fragments, then propagate page ranges so internal
1408+ // nodes span their children.
1409+ rootSec .Children = foldEmptyLeafSections (rootSec .Children )
12721410 propagateSectionPages (rootSec .Children )
12731411
12741412 return & ParsedDoc {
0 commit comments