@@ -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