@@ -307,19 +307,16 @@ func (p *PDF) parseDoc(_ context.Context, buf []byte) (*ParsedDoc, error) {
307307 }
308308 defer func () { _ = pdoc .Close () }() // best-effort close
309309
310- reader , err := pdflib .NewReader (bytes .NewReader (docBytes ), int64 (len (docBytes )))
311- if err != nil {
312- // ledongthuc/pdf failed on a PDF pdftable accepted (some
313- // xref-stream variants are pdftable-only). Without ledongthuc
314- // we lose both outline access AND the primitive layer the
315- // row extractor uses — so we bail with a clear message rather
316- // than emit garbled text from pdftable.Words() (its word
317- // grouping concatenates words on standard-14 fonts without
318- // AFM metrics; see v0.4.x followup).
319- return nil , fmt .Errorf ("pdf: open: ledongthuc/pdf backend rejected the document: %w" , err )
310+ // ledongthuc/pdf is now used ONLY for /Outlines (bookmarks). Text and
311+ // rows come from pdftable's Words() (v0.4.0: correct order + spacing).
312+ // A reader failure is therefore non-fatal — we just lose the outline
313+ // hint and fall back to the font-size heuristic on the pdftable rows.
314+ reader , rerr := pdflib .NewReader (bytes .NewReader (docBytes ), int64 (len (docBytes )))
315+ if rerr != nil {
316+ reader = nil
320317 }
321318
322- rows , err := extractPDFRows (reader )
319+ rows , err := extractPDFRows (pdoc )
323320 if err != nil {
324321 return nil , err
325322 }
@@ -964,24 +961,40 @@ type pdfRow struct {
964961//
965962// Once pdftable bundles standard-14 AFM metrics (v0.4.x goal) we can
966963// swap back to its Words() output.
967- func extractPDFRows (reader * pdflib. Reader ) ([]pdfRow , error ) {
968- numPages := reader . NumPage ()
964+ func extractPDFRows (doc pdftable. Document ) ([]pdfRow , error ) {
965+ numPages := doc . NumPages ()
969966 var out []pdfRow
970967
968+ // pdftable v0.4.0 word extraction: a size-relative gap tolerance plus
969+ // explicit-space boundaries give correct reading order and word
970+ // spacing on real design/academic PDFs — replacing the ledongthuc
971+ // glyph path that produced reversed, replacement-char-laden text.
972+ wopts := pdftable.WordOpts {
973+ XTolerance : 3 ,
974+ XToleranceRatio : 0.15 ,
975+ YTolerance : 3 ,
976+ HorizontalLTR : true ,
977+ VerticalTTB : true ,
978+ Expand : true ,
979+ UseExplicitSpaces : true ,
980+ }
981+
971982 for pageNum := 1 ; pageNum <= numPages ; pageNum ++ {
972- page := reader .Page (pageNum )
973- if page .V .IsNull () {
983+ page , perr := doc .Page (pageNum )
984+ if perr != nil {
985+ continue
986+ }
987+ words , werr := page .Words (wopts )
988+ if werr != nil {
974989 continue
975990 }
976- content := page .Content ()
977991
978- // Group letters by (approximate) baseline Y. Values within 2pt
979- // are considered the same row — PDFs frequently jitter Y by a
980- // fraction.
992+ // Bucket words into rows by visual top (Y1), within 2pt — PDFs
993+ // frequently jitter Y by a fraction.
981994 type rowBucket struct {
982995 y float64
983996 maxFS float64
984- chars []pdflib. Text
997+ words []pdftable. Word
985998 }
986999 var buckets []* rowBucket
9871000 find := func (y float64 ) * rowBucket {
@@ -994,37 +1007,30 @@ func extractPDFRows(reader *pdflib.Reader) ([]pdfRow, error) {
9941007 buckets = append (buckets , b )
9951008 return b
9961009 }
997- for _ , t := range content . Text {
998- b := find (t . Y )
999- b .chars = append (b .chars , t )
1000- if t .FontSize > b .maxFS {
1001- b .maxFS = t .FontSize
1010+ for _ , wd := range words {
1011+ b := find (wd . Y1 )
1012+ b .words = append (b .words , wd )
1013+ if wd .FontSize > b .maxFS {
1014+ b .maxFS = wd .FontSize
10021015 }
10031016 }
10041017 sort .Slice (buckets , func (i , j int ) bool { return buckets [i ].y > buckets [j ].y })
10051018
10061019 for _ , b := range buckets {
1007- sort .Slice (b .chars , func (i , j int ) bool { return b .chars [i ].X < b .chars [j ].X })
1020+ // Left-to-right. pdftable already resolved word boundaries +
1021+ // spacing, so we just order the words and join them.
1022+ sort .Slice (b .words , func (i , j int ) bool { return b .words [i ].X0 < b .words [j ].X0 })
10081023 var sb strings.Builder
1009- var lastX float64
10101024 boldGlyphs , totalGlyphs := 0 , 0
1011- for i , ch := range b .chars {
1012- // Insert a space when the gap between the previous
1013- // glyph's end and this glyph's start exceeds 0.20 of
1014- // the font size. Tuned against real PDFs (arXiv +
1015- // SEC 10-Ks): word-boundary gaps land around
1016- // 0.20-0.30·fontSize; intra-word kerning stays well
1017- // below 0.10.
1018- if i > 0 && ch .X - lastX > ch .FontSize * 0.20 {
1025+ for i , wd := range b .words {
1026+ if i > 0 {
10191027 sb .WriteString (" " )
10201028 }
1021- sb .WriteString (ch .S )
1022- lastX = ch .X + ch .W
1023- if strings .TrimSpace (ch .S ) != "" {
1024- totalGlyphs ++
1025- if isBoldFont (ch .Font ) {
1026- boldGlyphs ++
1027- }
1029+ sb .WriteString (wd .Text )
1030+ n := len ([]rune (strings .TrimSpace (wd .Text )))
1031+ totalGlyphs += n
1032+ if isBoldFont (wd .FontName ) {
1033+ boldGlyphs += n
10281034 }
10291035 }
10301036 // Wide letter-tracking — common on filing cover pages and
0 commit comments