A pure-Go port of pdfplumber.
pdfgrab reads a PDF's content streams and returns every character, word, line, rectangle and table cell together with the rectangle it occupies on the page. No CGo, no Python, MIT.
Status · Why · Quick start · How it works · Benchmarks · Agreement with pdfplumber · Limits · API · CLI
pdfgrab was written for Vectorless. The hosted Vectorless service has been paused since 12 October 2026, with its data backed up and its infrastructure kept as code so it can come back. pdfgrab stays open source like the engine it was written for. Development is slow while the service is paused: issues and pull requests are read, but replies may take a while. For anything else, write to halleluyaholudele@gmail.com.
All four pdfplumber table strategies are implemented (lines, lines_strict, text, explicit) and can be mixed per axis. There is also an opt-in auto strategy, opt-in text-edge region detection and a pdfgrab CLI.
A retrieval system that answers from a PDF usually cites a page. A page of a 10-K can hold eleven paragraphs and a table, so the reader still has to search it for the sentence that was used. To draw a box around that sentence, the system needs the coordinates of every word, and it needs them from ingest onwards. Chunking a document into fixed-size text pieces discards them, and nothing later in the pipeline can put them back.
Vectorless keeps the document whole, picks sections, and highlights the exact lines an answer came from. That needed a PDF layer in Go that keeps every coordinate. When the Go field was compared on 2026-09-17, the options were these:
pdfcpuhandles objects, the cross-reference table and decompression, and does not extract text.ledongthuc/pdf,rsc.io/pdfanddslipak/pdfgive text with positions and no table structure.klippa-app/go-pdfiumgives per-character boxes, runs without CGo in its WebAssembly mode, and has no table layer.unidoc/unipdfv5 has the best table output in Go, with a box and grid indices per cell, and is commercial-licence-only.coregx/gxpdf(MIT) extracts tables and scored 0.179 F1 in the 2026-09-17 field comparison.
Python has had pdfplumber since 2018. pdfgrab is a port of it, built on pdfcpu for the object layer, so a Go service can read a PDF's geometry in-process.
go get github.com/hallelx2/pdfgrab@mainUse @main. Every tag from v0.0.1 to v0.4.0 was cut before the module was renamed from github.com/hallelx2/pdftable, so go get github.com/hallelx2/pdfgrab@v0.4.0 fails with a module path mismatch (HAL-2690). pdfgrab needs Go 1.25 or later.
package main
import (
"fmt"
"log"
"os"
"github.com/hallelx2/pdfgrab"
)
func main() {
doc, err := pdfgrab.OpenFile(os.Args[1])
if err != nil {
log.Fatal(err)
}
defer doc.Close()
page, err := doc.Page(1)
if err != nil {
log.Fatal(err)
}
// Every word, with its rectangle in PDF points.
words, err := page.Words(pdfgrab.DefaultWordOpts())
if err != nil {
log.Fatal(err)
}
for _, w := range words[:min(5, len(words))] {
fmt.Printf("%-16q x0=%.2f y0=%.2f x1=%.2f y1=%.2f\n", w.Text, w.X0, w.Y0, w.X1, w.Y1)
}
// Every table, with one rectangle per cell.
tables, err := page.ExtractTables(pdfgrab.DefaultTableSettings())
if err != nil {
log.Fatal(err)
}
for _, t := range tables {
for i, row := range t.Rows {
for j, cell := range row {
b := t.CellsBBox[i][j]
// Fractions of the page, origin top-left: ready for a CSS overlay.
r := b.Normalized(page.Width(), page.Height())
fmt.Printf("%-12q left=%.3f top=%.3f w=%.3f h=%.3f\n", cell, r.Left, r.Top, r.Width, r.Height)
}
}
}
}Run on testdata/table-2x3-ruled.pdf (output captured 2026-10-12):
"Name" x0=110.00 y0=675.93 x1=136.67 y1=685.93
"Age" x0=210.00 y0=675.93 x1=227.79 y1=685.93
"Alice" x0=110.00 y0=645.93 x1=131.67 y1=655.93
"30" x0=210.00 y0=645.93 x1=221.12 y1=655.93
"Bob" x0=110.00 y0=615.93 x1=127.79 y1=625.93
"Name" left=0.163 top=0.116 w=0.163 h=0.038
"Age" left=0.327 top=0.116 w=0.163 h=0.038
"Alice" left=0.163 top=0.154 w=0.163 h=0.038
"30" left=0.327 top=0.154 w=0.163 h=0.038
"Bob" left=0.163 top=0.192 w=0.163 h=0.038
"25" left=0.327 top=0.192 w=0.163 h=0.038
Word coordinates are PDF user space: points, origin at the bottom-left, Y growing up. The cell rectangles are converted to viewer space: fractions of the page, origin at the top-left, Y growing down.
A PDF page stores drawing instructions, not text. pdfgrab replays those instructions, records where each glyph and line lands, and rebuilds words, lines and tables from the positions. Each stage below names the file that does it.
bytes ─ pdfcpu ─▶ objects, fonts, content streams internal/pdf/reader.go
─ interpreter ─▶ Char, Line, Rect, Curve internal/pdf/content.go, ops.go, state.go, font.go, cmap.go
─ grouping ─▶ Word, text text.go, clustering.go
─ edges, intersections, cells ─▶ Table page.go, finder.go, finder_text.go, internal/layout/lines.go
─ BBox.Viewport / Normalized ─▶ ViewRect geometry.go
pdfcpu parses the file: the cross-reference table, indirect objects, stream decompression, and decryption when the user password is empty. pdfgrab takes each page's content stream from there and runs its own interpreter over it, modelled on pdfminer.six.
A lexer splits the stream into operands and operators. A dispatch table in internal/pdf/ops.go maps every operator in PDF 1.7 §A.2 to a handler, so the operand stack stays balanced even for operators pdfgrab ignores, such as colour. The handlers that matter:
| Operators | What pdfgrab does with them |
|---|---|
q Q cm |
Push, pop and concatenate the graphics state and its current transformation matrix (CTM). |
m l c v y h re |
Build a path. |
S s f F f* B B* b b* n |
Paint (or discard) the path. A single re becomes a Rect, a chain of straight segments becomes Lines, anything else a Curve, each with stroke and fill flags. |
BT ET Tf Tc Tw Tz TL Ts Tr |
Begin and end text, and set the text state: font, size, character and word spacing, horizontal scale, leading, rise. |
Td TD Tm T* |
Move the text matrix. |
Tj TJ ' " |
Show glyphs. In a TJ array each number shifts the next glyph left by number/1000 of the font size. |
Do |
Run a Form XObject's own content stream in a sub-interpreter under its /Matrix, with its own resources. Image XObjects are skipped. |
Before the stream runs, the CTM is set so that the MediaBox's lower-left corner is (0,0) and any /Rotate is already applied. Every coordinate pdfgrab reports is therefore in the space of Page.Width() by Page.Height() as a viewer would display it.
A content stream gives the starting point of a run of text. It does not say where the second glyph goes. That position is the first glyph's position plus its advance width, and so on along the line, so one wrong width shifts every glyph after it.
For each glyph, showString in internal/pdf/content.go does this:
- Combine the text matrix with the CTM.
- Build the glyph box in text space: from 0 to the advance width horizontally, and from the font's descent to descent plus font size vertically, shifted by the rise.
- Transform the box to page space and emit a
Charwith its text, font name, size, anUprightflag and the advance. - Move the text matrix by the advance, plus character spacing, plus word spacing when the byte is a space in a simple font.
The advance width comes from the font's /Widths array. PDF 1.7 lets the 14 standard fonts (Helvetica, Times, Courier, Symbol, ZapfDingbats and their variants) omit /Widths, because a reader is expected to know them. Before 0.5.0 pdfgrab did not, and fell back to 500/1000 of the size for every glyph, so i (222) and m (833) came out the same width.
pdfgrab now bundles the Adobe Core 14 AFM widths, ascents and descents (internal/pdf/afm_widths.go, afm_vmetrics.go). The lookup order for a width is: the font's own /Widths, then the bundled Core 14 table by Unicode, then the font's default width, then 500. Common substitute names such as Arial, Times New Roman and Courier New alias to their Core 14 equivalents. Narrow and Condensed variants do not alias, because they share a family name and not the metrics.
The vertical box had two faults of its own, fixed in the same work. The standard-14 exemption that omits /Widths also omits /FontDescriptor, so the descent was 0; and the descent was scaled by 0.001 but not by the font size. Glyph boxes sat exactly 0.207 × size too high in Helvetica: 2.484 pt at 12 pt, 4.968 pt at 24 pt (2026-08-02 report). Row boundaries in the text and lines_strict strategies come from word extents, so this was a table bug as well as a highlight bug.
The bytes in a Tj string are character codes, not characters.
internal/pdf/font.go turns codes into text in this order:
- Codes. A simple font (Type1, TrueType) uses one byte per code. A composite (Type0) font is read as two-byte big-endian codes, which is the
Identity-Hlayout. - ToUnicode first. If the font has a
/ToUnicodeCMap (internal/pdf/cmap.goparsesbfcharandbfrange), it wins, as PDF 1.7 §9.10.2 requires. It is often the only way ligature glyphs map back tofiorffi. - Encoding. Otherwise a simple font's base encoding (StandardEncoding, WinAnsiEncoding, MacRomanEncoding or PDFDocEncoding), overlaid with its
/Differencesarray, gives a glyph name. The Adobe Glyph List turns the name into Unicode, includinguniXXXXnames and compound names such asf_i. - Fallbacks. Printable ASCII bytes in a simple font fall back to Latin-1. Anything else becomes
(cid:N), the same placeholder pdfminer.six uses, and keeps its box.
Symbol and ZapfDingbats carry their own built-in encodings and do not declare /Encoding. pdfgrab used to hand them StandardEncoding, so Symbol's 0x61 came out as a instead of α. They now decode with their own tables. pdfplumber 0.11.9 still has this defect (2026-08-02). Because the Core 14 width lookup goes through Unicode, a wrong glyph name broke the width as well as the text.
text.go is a port of pdfplumber's WordExtractor and extract_text.
- Drop characters with no text. Whitespace characters are dropped too, unless
UseExplicitSpaceskeeps them as word boundaries. - Group characters by orientation (and by font name or size, if
ExtraAttrsasks for it). - Cluster each group into lines by the top of the glyph, within
YTolerance(3 pt by default). Rotated text clusters by X instead. - Sort each line in reading direction (
ltr,rtl,ttborbtt) and walk it. A new word starts at an explicit space glyph, or where the gap to the previous glyph exceedsXTolerance(3 pt), orXToleranceRatio× the font size when that ratio is set. - Join the characters, expanding ligatures such as
fitofi. The word's box is the union of its characters' boxes.
DefaultWordOpts() sets UseExplicitSpaces, as pdfplumber does. Without it, 8 pt text merged into single words: an 8 pt space is 278/1000 × 8 = 2.22 pt wide, under the 3 pt tolerance. DefaultTextOpts() also sets XToleranceRatio to 0.15, so the word gap is 1.5 pt at 10 pt and 3.6 pt at 24 pt.
ExtractText joins words with spaces and lines with newlines. With Layout: true it places text on a character grid (XDensity 7.25 pt per character, YDensity 13 pt per line), like pdftotext -layout.
Every box pdfgrab reports is in PDF user space: origin at the bottom-left, Y growing up, in points. Browsers, canvases, SVG and PDF.js put the origin at the top-left with Y growing down. A highlight drawn with the wrong convention lands upside down on the page.
geometry.go does the flip in one place and gives the result its own type, ViewRect, so the two spaces cannot be mixed up at a call site:
b := t.CellsBBox[row][col] // BBox: PDF user space
// Pixels, for a page rendered at 150 DPI (or the scale passed to PDF.js getViewport).
r := b.Viewport(page.Height(), 150.0/72.0)
// left: r.Left, top: r.Top, width: r.Width, height: r.Height
// Fractions of the page, 0 to 1, which stay correct when the viewer resizes.
n := b.Normalized(page.Width(), page.Height())Viewport computes top = (pageHeight − Y1) × scale. Normalized divides by the page size and returns a zero ViewRect instead of NaN when a dimension is not positive. The 2026-08-02 audit checked this geometry by projecting real cell boxes onto pages rendered with pdftoppm and confirming that the highlights landed on the intended rows.
A table is not stored in a PDF either. It is inferred from rules and from how text lines up. ExtractTables runs pdfplumber's TableFinder pipeline: derive edges, snap and join them, find where they cross, build cells from crossings, group cells into tables, then fill each cell with text.
Each axis picks where its edges come from:
| Strategy | Edges come from | Use it when |
|---|---|---|
lines (default) |
Drawn lines, all four sides of every rectangle, axis-aligned curve segments | The table has rules on both axes. |
lines_strict |
Drawn lines only | Cell backgrounds are filled rectangles you do not want read as row boundaries. |
text |
Clusters of words that share a left edge, right edge or centre (columns, at least MinWordsVertical = 3 words) or a top (rows, at least MinWordsHorizontal = 1) |
The table has no rules. |
explicit |
ExplicitVerticalLines and ExplicitHorizontalLines, at least two per axis |
You already know the grid, for example from a layout model. |
auto (opt-in) |
lines on an axis that has rulings; text on an axis without them only if the other axis is ruled; otherwise nothing |
The corpus has tables ruled on one axis only. |
s := pdfgrab.DefaultTableSettings()
s.VerticalStrategy = pdfgrab.StrategyText // columns from word alignment
s.HorizontalStrategy = pdfgrab.StrategyLines // rows from drawn rules
tables, err := page.ExtractTables(s)The rest of the pipeline, in page.go, finder.go and internal/layout/lines.go:
- Edges. Drop raw edges shorter than
EdgeMinLengthPrefilter(1 pt). Snap near-collinear edges withinSnapTolerance(3 pt) onto their mean, join collinear pieces withinJoinTolerance(3 pt), and drop merged edges shorter thanEdgeMinLength(3 pt). Explicit lines are added on top of derived ones for every strategy exceptexplicit, where they are the only edges. - Intersections. Every crossing of a vertical and a horizontal edge, with
IntersectionTolerance(3 pt) of slack. - Cells. From each intersection, the smallest rectangle whose four corners are intersections joined by shared edges.
- Tables. Connected groups of cells, sorted top to bottom, each with a grid of cell boxes.
- Text. A glyph belongs to the cell that contains its centre. The cell's words are extracted with
TextTolerance(3 pt) and trimmed. The result isTable.Rows([][]string, missing cells are"") andTable.CellsBBox, one box per cell.
Two guards stop pathological pages: more than MaxEdgesPerAxis (1000) merged edges on an axis, or more than MaxIntersections (50,000) crossings, skips table finding for that page with a logged warning. FindTables returns the intermediate edges, intersections and cells without the text step, for debugging.
MergeSplitTokens (off by default) rejoins two cells when the column boundary between them cuts through a single word, as text columns sometimes do: (16,135) read as ( and 16,135). pdfplumber produces the same splits, so turning it on by default would break parity.
DetectRegions (off by default) restricts the text strategy to regions found by Anssi Nurminen's text-edge method, the one camelot's stream flavour uses: a column exists only where at least 4 vertically adjacent lines share an alignment. Prose shares a left margin but its interior positions wander, so it rarely passes. DetectTextEdgeRegions exposes the detector on its own. The results are under negative results.
Assigning a glyph to the cell that holds its centre is right at an interior boundary, where two cells compete for a glyph that straddles them. At the table's outer edge there is no neighbour, and the same rule deletes the glyph.
On page 58 of 3M's 2018 Form 10-K, the ) of (16,048) has its centre at x = 537.879 and the last column ends at x = 537.871. The parenthesis was dropped, and accounting notation for −16,048 came back as 16,048. Across the filing's five financial statements (pages 56 to 60) this flipped the sign of 20 of 103 negative numbers while every magnitude stayed correct. pdfplumber has the same defect and returns 16,048 for that row (2026-08-02).
The fix is charsInCellEdges in page.go: on a table's outer left or right edge, a cell also keeps a glyph that overlaps the edge even when its centre falls outside. The widening is bounded by the glyph itself, so it cannot pull in unrelated text. Minus signs kept went from 83/103 to 103/103.
The ICDAR 2013 results put most of pdfgrab's error in finding tables and their rows and columns, not in reading cells. An oracle experiment on 2026-08-03 measured how much is left once the grid is right. It gave the explicit strategy the correct column and row boundaries from the ground truth and scored the output.
| Configuration (107 single-region PDFs, pooled F1) | Precision | Recall | F1 |
|---|---|---|---|
pdfgrab lines, finding the grid itself |
0.865 | 0.229 | 0.362 |
pdfgrab explicit with the correct grid |
0.948 | 0.922 | 0.935 |
The same, with MergeSplitTokens on |
0.806 | 0.660 | 0.726 |
So a model that outputs rows and columns, such as Table Transformer, would supply what pdfgrab lacks, while pdfgrab keeps what a generative model cannot give: the exact cell text and the exact cell coordinates. The integration point is already in the API, and pdfgrab makes no model or network call itself:
s := pdfgrab.DefaultTableSettings()
s.VerticalStrategy = pdfgrab.StrategyExplicit
s.HorizontalStrategy = pdfgrab.StrategyExplicit
s.ExplicitVerticalLines = colBoundaries // x positions in PDF points, from the layout model
s.ExplicitHorizontalLines = rowBoundaries // y positions in PDF points
s.MergeSplitTokens = false // the default; keep it off with a supplied grid
tables, err := page.ExtractTables(s)Keep MergeSplitTokens off with a supplied grid. It exists to repair columns that a geometric guess cut through a value. With a correct grid there is nothing to repair, and every merge destroys a correct cell: F1 fell from 0.935 to 0.726.
What this does not show: no layout model was run. The grid came from the ground truth, so 0.935 is a ceiling for the hybrid, not a measurement of one. The F1 is pooled over 107 single-region documents, which is a different metric and corpus from the 0.442 end-to-end figure below. Pages with several tables were excluded.
Every figure links to the dated report that measured it. All reports are in docs/evaluations.
The corpus is the ICDAR 2013 Table Competition set in Smock's corrected edition: 125 born-digital PDFs and 39,524 ground-truth adjacency relations. Each system has to find every table and grid it. The score is adjacency-relation F1 averaged per document, the competition's own aggregation. The harness is bench/icdar2013, a separate Go module.
| System | Version | Precision | Recall | F1 per document | Pooled F1 | ms per document | p95 ms |
|---|---|---|---|---|---|---|---|
| camelot (stream) | 2.0.0 | 0.514 | 0.762 | 0.582 | 0.716 | 300 | 899 |
PyMuPDF find_tables |
1.28.2 | 0.560 | 0.472 | 0.485 | 0.392 | 730 | 2116 |
| camelot (lattice) | 2.0.0 | 0.520 | 0.453 | 0.467 | 0.396 | 1554 | 3237 |
| pdfplumber (lines) | 0.11.10 | 0.558 | 0.440 | 0.458 | 0.370 | 794 | 2066 |
| pdfgrab (auto) | main | 0.548 | 0.422 | 0.443 | 0.358 | 86 | 274 |
| pdfgrab (lines) | main | 0.545 | 0.422 | 0.442 | 0.362 | 81 | 247 |
| tabula (stream) | 2.10.0 | 0.387 | 0.460 | 0.397 | 0.437 | 1100 | 2680 |
| tabula (lattice) | 2.10.0 | 0.258 | 0.339 | 0.257 | 0.107 | 164 | 415 |
| pdfplumber (text) | 0.11.10 | 0.188 | 0.559 | 0.248 | 0.267 | 1456 | 4043 |
| gxpdf (Go) | v0.9.4 | 0.201 | 0.176 | 0.179 | 0.293 | 73 | 213 |
pdfgrab is fifth of ten on accuracy, 0.016 below pdfplumber, the library it ports. It is the fastest system of comparable accuracy: 81 ms per document against pdfplumber's 794 and camelot lattice's 1554, in one static binary with no Python runtime. No system failed on any document. The whole table was produced twice in independent processes, and every per-document F1 agreed to every decimal place.
What these numbers do not show:
- This is end to end, not structure only. Published ICDAR 2013 figures of 0.85 to 0.95 hand the system the table region. A table missed at IoU below 0.5 counts every one of its relations as a false negative.
- This is not the 67-PDF competition test set. The 125 PDFs are the Smock-corrected superset (competition set plus the 2012 practice data), and the corrected ground truth is a slightly more forgiving target.
- Adjacency relations only see non-blank cells, compared by exact string after whitespace normalisation.
- Reports before 2026-09-17 quote 0.362. That was pooled F1 over every relation in the corpus, not the per-document average. The older reports are kept as written, with a note pointing to the correction.
gxpdf's cells come back as merged text blocks with the glyph spacing unresolved, for example "N i t r og en ox id es (N Ox/N O2 )" from eu-001.pdf. That is the font-metrics problem pdfgrab fixed in August, showing up at the output layer. unipdf has the best table output in Go and could not be benchmarked without a commercial key.
The two bars differ in regime, metric and corpus, as the hybrid section explains. The three cards are the text-edge and sweep results, all 2026-09-17.
Each of these was a hypothesis that the measurement rejected. They ship opt-in, or not at all.
| Hypothesis | Result | Date | Report |
|---|---|---|---|
Choosing text on an axis without rules, when the other axis is ruled (auto), raises accuracy |
Documents with no table found fell from 28 (22%) to 23 (18%), but pooled F1 went from 0.362 to 0.358. The newly found tables were gridded badly. Opt-in; TestAutoIsNotTheDefault keeps it so. |
2026-08-02 | strategy-auto |
A naive lines, then text fallback finds the missing tables |
Pooled precision fell from 0.865 to 0.223, because prose aligns too. auto refuses to guess when neither axis is ruled for this reason. |
2026-08-02 | icdar2013 |
| Porting Nurminen's text-edge detector closes the gap to camelot | Against the page-wide text strategy, F1 rose from 0.245 to 0.337 and time fell from 288 to 227 ms per document. Recall reached 0.635 against camelot's 0.762, but precision stayed at 0.281 against 0.514, and lines (0.442) still wins. Opt-in as DetectRegions. |
2026-09-17 | text-edge-region-detection |
| The in-region precision gap is a threshold tuned for a whole page | Twelve configurations of padding and word thresholds. Precision ranged from 0.128 to 0.289; the best F1, 0.342, is noise against the 0.337 baseline. Lowering MinWordsVertical to 1 halved precision. The gap is in the gridding logic, not the settings. |
2026-09-17 | region-gridding-threshold-sweep |
One method note from the sweep: a three-document smoke run put padding 2.0 first, at F1 0.628. Over all 125 documents the same configuration came sixth. A small sample is for checking that the harness runs, not for reading a result.
These figures measure agreement with pdfplumber, not ground truth. pdfplumber is the reference pdfgrab ports, and in two places it is wrong (the Symbol encoding and the clipped parenthesis), so agreement is not correctness.
| Measure | Before | After | Date | Report |
|---|---|---|---|---|
| Horizontal word-position drift against pdfplumber, golden fixtures | 11.99 pt max, 4.79 pt mean | 0.0000 pt | 2026-08-02 | font metrics and table fidelity |
| Vertical drift | 4.968 pt at 24 pt, 2.484 pt at 12 pt | 0.0000 pt | 2026-08-02 | same |
| Tolerance the golden test asserts | 15 pt | 0.01 pt, both axes | 2026-08-02 | same |
| Minus signs kept, 3M 2018 10-K, pages 56 to 60 | 83/103 | 103/103 | 2026-08-02 | same |
| Font coverage of the golden corpus | 1 font, 26 words | 12 fonts, 3 sizes, 170 words | 2026-08-02 | same |
Table F1 against pdfplumber lines, ICDAR 2013 per document |
0.442 against 0.458 | 2026-09-17 | field comparison |
The minus-sign figure is the one place pdfgrab now does better than pdfplumber rather than matching it: pdfplumber drops the same parentheses. Findings in the font audit were confirmed against the page rendered with pdftoppm, poppler's pdftotext -layout, and pdfplumber 0.11.9. One reported defect, "Consolidated Balance Shee t", turned out to be in the source document: the rendered page and poppler show the same gap.
What matches pdfplumber exactly: word grouping (the same line clustering and gap merge, defaults, blank-character filtering, ligature expansion and punctuation splitting), word order when UseTextFlow is off, and direction handling. What does not:
Layout: truetext is column-aligned with the same density defaults, but not byte-equal to pdfplumber's, whose layout output changes between releases.- Not ported:
extract_text_lines,searchover the text map, andExtraAttrsbeyondfontnameandsize. - Coordinates are PDF user space (
Y0,Y1, origin bottom-left). pdfplumber reportstopandbottomfrom the top-left.top = page.Height() - Y1. - Pages are 1-based:
doc.Page(1)is pdfplumber'spdf.pages[0]. - Rows are
[]string: a missing cell is"", notNone.
- Scanned PDFs. A scan has no text layer, so there is nothing to position. pdfgrab does no OCR.
- CJK and composite fonts. Type0 fonts are decoded as two-byte
Identity-Hcodes, and vertical writing is not implemented. CID and Type0 fonts, which cover CJK and many modern subset fonts, are unmeasured (listed untested on 2026-08-02). - Embedded subset fonts. Subset fonts that ship their own
/Widthsare the common production case and are unmeasured. A font with neither/Widthsnor a recognised standard-14 name falls back to 500/1000 per glyph and will drift. - Rotated and multi-column layouts are unmeasured.
- Type 3 fonts are not interpreted.
- Table detection is the weak half: on ICDAR 2013,
linesfinds no table at all in 22% of documents (28 of 125, 2026-08-02), because a table ruled on one axis has no crossings. - Encryption (measured 2026-10-12 with AES-256 files made by qpdf): a PDF with only an owner password opens and reads normally. A PDF that needs a user password returns
ErrEncrypted, and there is no way to pass a password.TestEncryptedDocumentspins both. - Concurrency (measured 2026-10-12 with
go test -race): opening and parsing documents from several goroutines at once is a data race, in pdfgrab's lazily built operator table and in pdfcpu's shared configuration. SerialiseOpenand page parsing until that is fixed (HAL-118). - Tags. No tag exists under the
github.com/hallelx2/pdfgrabmodule path. Use@main(HAL-2690).
vectorless-engine uses pdfgrab for pincites, citations pinned to the exact line of a page. At ingest, PDFLayout in pkg/parser/pdf_layout.go reads every page's words with XToleranceRatio 0.15 and UseExplicitSpaces, groups them into lines, and stores each line's words with positions normalised to 0..1 of the page from the top-left. When an answer cites a quote, the engine finds the quote among those lines and draws one rectangle per line. The page text that retrieval reads is built from the same words, so a quote that matches the text also matches the geometry. The engine also uses ExtractTables for table sections.
The engine imports pdfgrab under its earlier module path, github.com/hallelx2/pdftable v0.4.0, and serialises OpenBytes behind a mutex because of the race above.
The full reference is on pkg.go.dev. The surface, abridged:
// Open a document.
func Open(r io.Reader) (Document, error)
func OpenBytes(b []byte) (Document, error) // b is not copied; do not mutate it while in use
func OpenFile(path string) (Document, error)
type Document interface {
NumPages() int
Page(n int) (Page, error) // 1-based
Pages() iter.Seq2[int, Page] // for n, page := range doc.Pages()
Close() error
}
type Page interface {
Number() int
Width() float64 // points, after /Rotate
Height() float64
// Primitives, in content-stream order.
Chars() ([]Char, error)
Lines() ([]Line, error)
Rects() ([]Rect, error)
Curves() ([]Curve, error)
Objects() (Objects, error) // all four in one pass
// Text.
Words(opts WordOpts) ([]Word, error)
ExtractText(opts TextOpts) (string, error)
ExtractTextSimple(xTolerance, yTolerance float64) (string, error)
// Tables.
FindTables(settings TableSettings) ([]TableFinder, error) // geometry only
ExtractTables(settings TableSettings) ([]*Table, error)
}
type Char struct {
Text string // "(cid:N)" when the code has no Unicode mapping
X0, Y0, X1, Y1 float64
FontName string
FontSize float64
Upright bool
Advance float64
}
type Word struct {
Text string
X0, Y0, X1, Y1 float64
Upright bool
Direction string // "ltr", "rtl", "ttb" or "btt"
FontName string
FontSize float64
Chars []Char // when WordOpts.KeepChars is set
}
type Table struct {
Rows [][]string // row 0 is the top row
BBox BBox
Page int
CellsBBox [][]BBox // CellsBBox[i][j] is the box of Rows[i][j]
}
type BBox struct{ X0, Y0, X1, Y1 float64 } // PDF user space
type ViewRect struct{ Left, Top, Width, Height float64 } // viewer space
func (b BBox) Viewport(pageHeight, scale float64) ViewRect
func (b BBox) Normalized(pageWidth, pageHeight float64) ViewRect
func DefaultWordOpts() WordOpts // XTolerance 3, YTolerance 3, UseExplicitSpaces, Expand
func DefaultTextOpts() TextOpts // adds XToleranceRatio 0.15, XDensity 7.25, YDensity 13
func DefaultTableSettings() TableSettings // pdfplumber's defaults, strategy "lines"
func DefaultTextEdgeOpts() TextEdgeOpts // MinLines 4, EdgeTol 50, CoordTol 0.5, MinTextLen 2, LineTol 2, PadLines 1
func DetectTextEdgeRegions(words []Word, opts TextEdgeOpts) []BBox
const (
StrategyLines TableStrategy = "lines"
StrategyLinesStrict TableStrategy = "lines_strict"
StrategyText TableStrategy = "text"
StrategyExplicit TableStrategy = "explicit"
StrategyAuto TableStrategy = "auto"
)
// Sentinel errors, for errors.Is:
// ErrInvalidPDF the bytes are not a readable PDF
// ErrPageOutOfRange Page(n) with n < 1 or n > NumPages()
// ErrUnsupported a PDF feature pdfgrab does not implement
// ErrEncrypted the PDF needs a user password
var ErrInvalidPDF, ErrPageOutOfRange, ErrUnsupported, ErrEncrypted errorTableSettings fields and defaults: VerticalStrategy and HorizontalStrategy (lines), SnapTolerance 3, JoinTolerance 3, EdgeMinLength 3, EdgeMinLengthPrefilter 1, IntersectionTolerance 3, TextTolerance 3, MinWordsVertical 3, MinWordsHorizontal 1, KeepBlankChars false, ExplicitVerticalLines and ExplicitHorizontalLines (none), MaxEdgesPerAxis 1000, MaxIntersections 50000, MergeSplitTokens false, DetectRegions false, TextEdge (DefaultTextEdgeOpts). A zero field takes its default, so a struct literal with only the fields you need behaves like DefaultTableSettings() plus your changes.
go install github.com/hallelx2/pdfgrab/cmd/pdfgrab@mainpdfgrab extract invoice.pdf --tables --format json # every table on every page
pdfgrab extract 10k.pdf --tables \
--vertical-strategy text --horizontal-strategy text \
--min-words-vertical 4 # tables without rules
pdfgrab extract report.pdf --text --format text # text only, \f between pages
pdfgrab extract report.pdf --tables --pages 1,3-5 --indent 2 # some pages, indented JSON
pdfgrab extract statement.pdf --tables \
--vertical-strategy explicit --horizontal-strategy explicit \
--explicit-vertical-lines 100,200,300,400 \
--explicit-horizontal-lines 600,650,700,720 # a known grid| Flag | Default | Description |
|---|---|---|
--pages |
all | Pages, as 1,3-5. |
--tables |
off | Output detected tables. |
--text |
off | Output extracted text. |
--format |
json |
json or text. |
--vertical-strategy |
lines |
lines, lines_strict, text or explicit. |
--horizontal-strategy |
lines |
The same set. |
--snap-tolerance |
3 | Points. |
--join-tolerance |
3 | Points. |
--edge-min-length |
3 | Drop merged edges shorter than this. |
--edge-min-length-prefilter |
1 | Drop raw edges shorter than this before merging. |
--intersection-tolerance |
3 | Slack on edge crossings. |
--text-tolerance |
3 | Per-cell text tolerance. |
--min-words-vertical |
3 | text strategy column threshold. |
--min-words-horizontal |
1 | text strategy row threshold. |
--explicit-vertical-lines |
none | Comma-separated x positions. |
--explicit-horizontal-lines |
none | Comma-separated y positions. |
--indent |
0 | JSON indent; 0 is compact. |
Checked 2026-10-11.
| Module path | What it contains | |
|---|---|---|
main |
github.com/hallelx2/pdfgrab |
Everything below, plus the module rename (2026-08-21), the ICDAR metric correction and opt-in text-edge region detection (DetectRegions, DetectTextEdgeRegions), 2026-09-17. |
| 0.5.0 in CHANGELOG (2026-08-20) | Standard-14 font metrics, glyph geometry, cell text at table edges, the first ICDAR 2013 evaluation. Not tagged. | |
v0.4.0 tag (2026-07-03), the latest tag |
github.com/hallelx2/pdftable |
Size-relative word gaps, explicit spaces. |
Earlier: v0.0.x content-stream primitives; v0.1.x words and text extraction; v0.2.x table finding with lines and lines_strict; v0.3.x the text and explicit strategies and the CLI. The release history is in CHANGELOG.md.
pdfgrab is a single Go package, so its source sits in the repository root, which is the import path.
| Path | Contents |
|---|---|
*.go |
The library: pdftable.go (open), pdf.go, page.go, char.go, text.go, table.go, finder.go, finder_text.go, detect_textedge.go, clustering.go, geometry.go, errors.go |
internal/pdf/ |
pdfcpu bridge, content-stream interpreter, text state, fonts, encodings, CMaps, the bundled Core 14 metrics |
internal/layout/ |
Edge snapping, joining and filtering |
cmd/pdfgrab/ |
CLI |
examples/ |
A runnable table-extraction example |
bench/ |
ICDAR 2013 harness; a separate Go module, so it adds no dependency here |
docs/evaluations/ |
Dated measurement reports, with their caveats |
docs/media/ |
The figures in this README |
scripts/ |
Fixture and golden-file generators |
testdata/ |
golden/ (compared against pdfplumber), fonts/ (fixtures where pdfplumber is wrong), encrypted/ |
.lorx/actions/ci.yml runs gofmt, go vet, go build, golangci-lint v2.14.0 and go test -race in containers. Run it locally with bit run --all. .github/workflows/test.yml runs gofmt, go vet and go test -race on Linux, macOS and Windows, but GitHub Actions on this account cannot start jobs (HAL-1712).
MIT. See LICENSE.
The bundled font-metric and glyph tables in internal/pdf/ are generated from Adobe's Glyph List and Core 14 AFM data (BSD-3-Clause) and from pdf.js's encoding vectors (Apache-2.0). Attribution and the upstream licence texts are in THIRD_PARTY_NOTICES.md.
pdfgrab ports algorithms from pdfminer.six and pdfplumber. Their authors worked out how to recover structure from the PDF format; this is that work in Go.










