Skip to content

Commit 6a0d1d4

Browse files
authored
fix(parser): PDF text via pdftable v0.4.0 (fixes reversed/garbled extraction) (#51)
* 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). * chore(deps): go mod tidy after pdftable v0.4.0 bump * fix(parser): serialize pdftable Words() to avoid data race pdftable's Words() mutates the same package-level state as OpenBytes and is not concurrent-safe; parallel ingest parses tripped the race detector. Hold pdftableOpenMu during row extraction, matching the existing guard on OpenBytes.
1 parent e637d13 commit 6a0d1d4

3 files changed

Lines changed: 59 additions & 45 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 & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+u
134134
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
135135
github.com/hallelx2/llmgate v0.3.0 h1:OLm2L41WFAqE2ka64RUdLirXmVRbvbY8zu7N3NSkfS8=
136136
github.com/hallelx2/llmgate v0.3.0/go.mod h1:MK2Ol/5CIweTQ2/9eSiTJ5g/KSSuobNZL9TD3s57JxY=
137-
github.com/hallelx2/pdftable v0.3.1 h1:Uqe+9G8s9jrGYwxk8dEMXBCB+SlzvWPmW0Ze5863W1I=
138-
github.com/hallelx2/pdftable v0.3.1/go.mod h1:pxNlc4D43wjzis7M6EfgQZvHOsQ4okggm+xqUu+OokI=
137+
github.com/hallelx2/pdftable v0.4.0 h1:ldF8qQrUbejWsbB/JSIqliEXF2jF8z8kjEsbKS8r2BU=
138+
github.com/hallelx2/pdftable v0.4.0/go.mod h1:pxNlc4D43wjzis7M6EfgQZvHOsQ4okggm+xqUu+OokI=
139139
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
140140
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
141141
github.com/hhrutter/pkcs7 v0.2.2 h1:xMoifoVWah1LNym3C0pomEiLmyJyVIBXt/8oTPyPz+8=

pkg/parser/pdf.go

Lines changed: 56 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,48 @@ 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+
// pdftable's Words() mutates the same package-level state as OpenBytes
966+
// (see pdftableOpenMu) — it is not safe for concurrent callers, and
967+
// ingest workers parse documents in parallel. Serialize it too until
968+
// pdftable itself is made concurrency-safe. (Proper fix tracked in the
969+
// Foundational Libraries project.)
970+
pdftableOpenMu.Lock()
971+
defer pdftableOpenMu.Unlock()
972+
973+
numPages := doc.NumPages()
969974
var out []pdfRow
970975

976+
// pdftable v0.4.0 word extraction: a size-relative gap tolerance plus
977+
// explicit-space boundaries give correct reading order and word
978+
// spacing on real design/academic PDFs — replacing the ledongthuc
979+
// glyph path that produced reversed, replacement-char-laden text.
980+
wopts := pdftable.WordOpts{
981+
XTolerance: 3,
982+
XToleranceRatio: 0.15,
983+
YTolerance: 3,
984+
HorizontalLTR: true,
985+
VerticalTTB: true,
986+
Expand: true,
987+
UseExplicitSpaces: true,
988+
}
989+
971990
for pageNum := 1; pageNum <= numPages; pageNum++ {
972-
page := reader.Page(pageNum)
973-
if page.V.IsNull() {
991+
page, perr := doc.Page(pageNum)
992+
if perr != nil {
993+
continue
994+
}
995+
words, werr := page.Words(wopts)
996+
if werr != nil {
974997
continue
975998
}
976-
content := page.Content()
977999

978-
// Group letters by (approximate) baseline Y. Values within 2pt
979-
// are considered the same row — PDFs frequently jitter Y by a
980-
// fraction.
1000+
// Bucket words into rows by visual top (Y1), within 2pt — PDFs
1001+
// frequently jitter Y by a fraction.
9811002
type rowBucket struct {
9821003
y float64
9831004
maxFS float64
984-
chars []pdflib.Text
1005+
words []pdftable.Word
9851006
}
9861007
var buckets []*rowBucket
9871008
find := func(y float64) *rowBucket {
@@ -994,37 +1015,30 @@ func extractPDFRows(reader *pdflib.Reader) ([]pdfRow, error) {
9941015
buckets = append(buckets, b)
9951016
return b
9961017
}
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
1018+
for _, wd := range words {
1019+
b := find(wd.Y1)
1020+
b.words = append(b.words, wd)
1021+
if wd.FontSize > b.maxFS {
1022+
b.maxFS = wd.FontSize
10021023
}
10031024
}
10041025
sort.Slice(buckets, func(i, j int) bool { return buckets[i].y > buckets[j].y })
10051026

10061027
for _, b := range buckets {
1007-
sort.Slice(b.chars, func(i, j int) bool { return b.chars[i].X < b.chars[j].X })
1028+
// Left-to-right. pdftable already resolved word boundaries +
1029+
// spacing, so we just order the words and join them.
1030+
sort.Slice(b.words, func(i, j int) bool { return b.words[i].X0 < b.words[j].X0 })
10081031
var sb strings.Builder
1009-
var lastX float64
10101032
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 {
1033+
for i, wd := range b.words {
1034+
if i > 0 {
10191035
sb.WriteString(" ")
10201036
}
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-
}
1037+
sb.WriteString(wd.Text)
1038+
n := len([]rune(strings.TrimSpace(wd.Text)))
1039+
totalGlyphs += n
1040+
if isBoldFont(wd.FontName) {
1041+
boldGlyphs += n
10281042
}
10291043
}
10301044
// Wide letter-tracking — common on filing cover pages and

0 commit comments

Comments
 (0)