Skip to content

Commit 19feb96

Browse files
committed
fix(parser): revert primitive layer to ledongthuc/pdf (keep pdftable for tables)
PR #20's pdftable.Words()-based primitive layer broke section titles on real SEC filings. Root cause: pdftable v0.3.0 ships without standard-14 AFM metrics, so glyph X-advance is estimated wrong on Times Roman / Helvetica (the only fonts a 10-K uses), inter-word X-gaps shrink below pdftable's default 3pt tolerance, and adjacent words get concatenated. The 3M 10-Q's 508 sections ended up with titles like: ChangesinAccumulatedOtherComprehensiveIncome(Loss)Attributableto3MbyComponent Currentmarketablesecurities 56 238 The selection LLM, given 112K tokens of outline like that, picked zero sections — driving FinanceBench vectorless to 0.000 on the post-deploy run. Pre-#20 the parser fix from PR #12 was producing 174 readable sections from the same PDF and the bench was on track. This commit restores extractPDFRows to use ledongthuc/pdf's Content() glyph stream (the implementation PR #12 tuned for SEC filings): - X-gap > 0.20·fontSize → insert a space (the per-glyph heuristic that gave us clean word boundaries on 10-Ks). - collapseLetterSpacing / looksLetterSpaced restored — they fix the "U N I T E D S T A T E S" cover-page artifact. - multiSpaceRe restored. The pdftable extractPDFTables stage is UNTOUCHED — line/lines_strict table finding works correctly because it operates on drawn rules, not glyph X-advances. The 3M 10-Q still emits 62 table sections under the "Tables" container; verified end-to-end via /v1/documents/.../tree. When pdftable bundles standard-14 AFM metrics (filed upstream as a v0.4.x goal), we can flip extractPDFRows back to pdftable.Words().
1 parent 99a1963 commit 19feb96

1 file changed

Lines changed: 113 additions & 47 deletions

File tree

pkg/parser/pdf.go

Lines changed: 113 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -152,15 +152,17 @@ func (p *PDF) Parse(_ context.Context, r io.Reader) (*ParsedDoc, error) {
152152

153153
reader, err := pdflib.NewReader(bytes.NewReader(docBytes), int64(len(docBytes)))
154154
if err != nil {
155-
// ledongthuc/pdf can fail on PDFs pdftable accepts (e.g. some
156-
// xref-stream variants). Outline access is optional, so a
157-
// failure here is not fatal — we just skip the outline path.
158-
// Log at debug level and carry on with the heuristic flow.
159-
slog.Debug("pdf: outline backend unavailable", "err", err)
160-
reader = nil
155+
// ledongthuc/pdf failed on a PDF pdftable accepted (some
156+
// xref-stream variants are pdftable-only). Without ledongthuc
157+
// we lose both outline access AND the primitive layer the
158+
// row extractor uses — so we bail with a clear message rather
159+
// than emit garbled text from pdftable.Words() (its word
160+
// grouping concatenates words on standard-14 fonts without
161+
// AFM metrics; see v0.4.x followup).
162+
return nil, fmt.Errorf("pdf: open: ledongthuc/pdf backend rejected the document: %w", err)
161163
}
162164

163-
rows, err := extractPDFRows(pdoc)
165+
rows, err := extractPDFRows(reader)
164166
if err != nil {
165167
return nil, err
166168
}
@@ -526,33 +528,37 @@ type pdfRow struct {
526528
// The bucket tolerance (Y1 within 2pt) matches what the previous
527529
// ledongthuc-backed implementation used; word-level Y1 jitter is the
528530
// same scale as the per-glyph jitter it replaced.
529-
func extractPDFRows(doc pdftable.Document) ([]pdfRow, error) {
530-
numPages := doc.NumPages()
531+
// extractPDFRows walks each page, grouping glyphs into rows by Y-position
532+
// and recording the dominant font size + bold ratio per row.
533+
//
534+
// We use ledongthuc/pdf's Content() as the primitive source rather than
535+
// pdftable.Words() because pdftable v0.3.0's word grouping silently
536+
// concatenates adjacent words on the standard-14 fonts SEC filings use
537+
// (no bundled AFM widths → glyph X-advance estimated wrong → word
538+
// X-gaps collapse → "Currentmarketablesecurities"). The X-gap-into-spaces
539+
// heuristic below is robust to that because we never trust pdftable's
540+
// word boundaries — we re-derive them from raw glyph X positions.
541+
//
542+
// Once pdftable bundles standard-14 AFM metrics (v0.4.x goal) we can
543+
// swap back to its Words() output.
544+
func extractPDFRows(reader *pdflib.Reader) ([]pdfRow, error) {
545+
numPages := reader.NumPage()
531546
var out []pdfRow
532547

533548
for pageNum := 1; pageNum <= numPages; pageNum++ {
534-
page, err := doc.Page(pageNum)
535-
if err != nil {
536-
// A bad page shouldn't take down the document — pdftable
537-
// can fail page-by-page on malformed content streams. Skip.
538-
continue
539-
}
540-
words, err := page.Words(pdftable.DefaultWordOpts())
541-
if err != nil {
542-
continue
543-
}
544-
if len(words) == 0 {
549+
page := reader.Page(pageNum)
550+
if page.V.IsNull() {
545551
continue
546552
}
553+
content := page.Content()
547554

548-
// Group words by visual top (Y1). Values within 2pt are
549-
// considered the same row — pdftable already clusters chars
550-
// into words by its own YTolerance, so this is just the next
551-
// step up: words at near-identical baselines become a row.
555+
// Group letters by (approximate) baseline Y. Values within 2pt
556+
// are considered the same row — PDFs frequently jitter Y by a
557+
// fraction.
552558
type rowBucket struct {
553559
y float64
554560
maxFS float64
555-
words []pdftable.Word
561+
chars []pdflib.Text
556562
}
557563
var buckets []*rowBucket
558564
find := func(y float64) *rowBucket {
@@ -565,55 +571,115 @@ func extractPDFRows(doc pdftable.Document) ([]pdfRow, error) {
565571
buckets = append(buckets, b)
566572
return b
567573
}
568-
for _, w := range words {
569-
b := find(w.Y1)
570-
b.words = append(b.words, w)
571-
if w.FontSize > b.maxFS {
572-
b.maxFS = w.FontSize
574+
for _, t := range content.Text {
575+
b := find(t.Y)
576+
b.chars = append(b.chars, t)
577+
if t.FontSize > b.maxFS {
578+
b.maxFS = t.FontSize
573579
}
574580
}
575-
// Sort rows top-to-bottom (higher Y = higher on page in PDF
576-
// user space).
577581
sort.Slice(buckets, func(i, j int) bool { return buckets[i].y > buckets[j].y })
578582

579583
for _, b := range buckets {
580-
sort.Slice(b.words, func(i, j int) bool { return b.words[i].X0 < b.words[j].X0 })
584+
sort.Slice(b.chars, func(i, j int) bool { return b.chars[i].X < b.chars[j].X })
581585
var sb strings.Builder
582-
boldWords, totalWords := 0, 0
583-
for i, w := range b.words {
584-
if i > 0 {
586+
var lastX float64
587+
boldGlyphs, totalGlyphs := 0, 0
588+
for i, ch := range b.chars {
589+
// Insert a space when the gap between the previous
590+
// glyph's end and this glyph's start exceeds 0.20 of
591+
// the font size. Tuned against real PDFs (arXiv +
592+
// SEC 10-Ks): word-boundary gaps land around
593+
// 0.20-0.30·fontSize; intra-word kerning stays well
594+
// below 0.10.
595+
if i > 0 && ch.X-lastX > ch.FontSize*0.20 {
585596
sb.WriteString(" ")
586597
}
587-
sb.WriteString(w.Text)
588-
if strings.TrimSpace(w.Text) != "" {
589-
totalWords++
590-
if isBoldFont(w.FontName) {
591-
boldWords++
598+
sb.WriteString(ch.S)
599+
lastX = ch.X + ch.W
600+
if strings.TrimSpace(ch.S) != "" {
601+
totalGlyphs++
602+
if isBoldFont(ch.Font) {
603+
boldGlyphs++
592604
}
593605
}
594606
}
595-
text := strings.TrimSpace(sb.String())
607+
// Wide letter-tracking — common on filing cover pages and
608+
// bold section headers — makes every glyph gap exceed the
609+
// space threshold, yielding "U N I T E D S T A T E S".
610+
// Re-join those runs into real words.
611+
text := collapseLetterSpacing(strings.TrimSpace(sb.String()))
596612
if text == "" {
597613
continue
598614
}
599-
// Drop publisher/preprint boilerplate (e.g. the rotated
600-
// arXiv license stamp in the left margin). Left in, it
601-
// pollutes the structure with junk top-level "headings"
602-
// and the document title.
603615
if isBoilerplateLine(text) {
604616
continue
605617
}
606618
out = append(out, pdfRow{
607619
page: pageNum,
608620
fontSize: b.maxFS,
609-
bold: totalWords > 0 && boldWords*2 > totalWords,
621+
bold: totalGlyphs > 0 && boldGlyphs*2 > totalGlyphs,
610622
text: text,
611623
})
612624
}
613625
}
614626
return out, nil
615627
}
616628

629+
// multiSpaceRe matches two or more consecutive whitespace characters.
630+
var multiSpaceRe = regexp.MustCompile(`\s{2,}`)
631+
632+
// looksLetterSpaced reports whether s appears to have been rendered with
633+
// wide letter-tracking — a chain of single characters separated by
634+
// spaces ("U N I T E D"). Used to detect cover-page / heading rows that
635+
// the glyph-spacing heuristic over-split.
636+
func looksLetterSpaced(s string) bool {
637+
s = strings.TrimSpace(s)
638+
if len(s) < 5 {
639+
return false
640+
}
641+
parts := strings.Fields(s)
642+
if len(parts) < 4 {
643+
return false
644+
}
645+
singles := 0
646+
for _, p := range parts {
647+
if len(p) <= 1 {
648+
singles++
649+
}
650+
}
651+
// At least half of the parts must be single characters for us to
652+
// call the run letter-spaced.
653+
return singles*2 >= len(parts)
654+
}
655+
656+
// collapseLetterSpacing rejoins runs of single-character "words"
657+
// (the artifact of wide letter-tracking) into real words. Multi-space
658+
// gaps between letter-spaced groups become regular word boundaries.
659+
func collapseLetterSpacing(s string) string {
660+
if !looksLetterSpaced(s) {
661+
return s
662+
}
663+
// Split on runs of 2+ spaces to identify word groups, then collapse
664+
// each group's single-character chain.
665+
groups := multiSpaceRe.Split(s, -1)
666+
for i, g := range groups {
667+
parts := strings.Fields(g)
668+
// If every part is a single character, glue them.
669+
allSingles := true
670+
for _, p := range parts {
671+
if len(p) > 1 {
672+
allSingles = false
673+
break
674+
}
675+
}
676+
if allSingles {
677+
groups[i] = strings.Join(parts, "")
678+
}
679+
}
680+
return strings.Join(groups, " ")
681+
}
682+
617683
// buildHeadingLevelMap returns a map from rounded-font-size → heading level
618684
// (1 = largest = h1). Only sizes above headingFloor are considered.
619685
// Levels are capped at 6.

0 commit comments

Comments
 (0)