Skip to content

Commit cfa25e8

Browse files
committed
fix(parser): extract PDF text via pdftable v0.4.0, not ledongthuc
PDF section titles came out reversed and full of U+FFFD (e.g. 'Retrieval'->'laveirteR', plus 654 spurious sections) because text rows were sourced from ledongthuc/pdf, whose weak font-encoding + ordering mangles real design/academic PDFs. pdftable (our pdfplumber port) reads them correctly; v0.4.0 also fixes its word spacing (size-relative tolerance + explicit-space boundaries). Switch extractPDFRows to pdftable's Words() with those options, reusing the existing row bucketing / bold / letter-spacing-collapse logic. ledongthuc/pdf is now used ONLY for /Outlines and its failure is non-fatal (text no longer depends on it). Verified: the Vectorless whitepaper now parses to 6 clean sections with correct titles and zero replacement chars (was 654 reversed).
1 parent e637d13 commit cfa25e8

3 files changed

Lines changed: 51 additions & 43 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ require (
1515
github.com/go-chi/chi/v5 v5.2.5
1616
github.com/google/uuid v1.6.0
1717
github.com/hallelx2/llmgate v0.3.0
18-
github.com/hallelx2/pdftable v0.3.1
18+
github.com/hallelx2/pdftable v0.4.0
1919
github.com/hibiken/asynq v0.26.0
2020
github.com/jackc/pgx/v5 v5.9.2
2121
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ github.com/hallelx2/llmgate v0.3.0 h1:OLm2L41WFAqE2ka64RUdLirXmVRbvbY8zu7N3NSkfS
136136
github.com/hallelx2/llmgate v0.3.0/go.mod h1:MK2Ol/5CIweTQ2/9eSiTJ5g/KSSuobNZL9TD3s57JxY=
137137
github.com/hallelx2/pdftable v0.3.1 h1:Uqe+9G8s9jrGYwxk8dEMXBCB+SlzvWPmW0Ze5863W1I=
138138
github.com/hallelx2/pdftable v0.3.1/go.mod h1:pxNlc4D43wjzis7M6EfgQZvHOsQ4okggm+xqUu+OokI=
139+
github.com/hallelx2/pdftable v0.4.0 h1:ldF8qQrUbejWsbB/JSIqliEXF2jF8z8kjEsbKS8r2BU=
140+
github.com/hallelx2/pdftable v0.4.0/go.mod h1:pxNlc4D43wjzis7M6EfgQZvHOsQ4okggm+xqUu+OokI=
139141
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
140142
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
141143
github.com/hhrutter/pkcs7 v0.2.2 h1:xMoifoVWah1LNym3C0pomEiLmyJyVIBXt/8oTPyPz+8=

pkg/parser/pdf.go

Lines changed: 48 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)