pdftable

package module
v0.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 26, 2026 License: MIT Imports: 8 Imported by: 0

README

pdftable

A Go-native port of Python's pdfplumber.

pdftable reads PDF documents, walks the content streams, and surfaces the positioned primitives — characters, lines, rectangles, curves — that higher-level layout algorithms (text extraction, word grouping, table detection) operate on. It is built on top of pdfcpu for low-level object parsing, xref handling, and FlateDecode decompression; everything above that (operator dispatch, text state, glyph positioning, ToUnicode CMaps, font encodings) is implemented here.

The library targets the gap in the Go PDF ecosystem: existing libraries either render PDFs to images, manipulate metadata, or extract bag-of- words text. None of them give you what pdfplumber gives Python users — a structured per-page object model you can run table-detection heuristics on. This is that.

Status

v0.0.1 — content-stream primitives layer. The public API surface is stable; higher-level operations (ExtractText, FindTables, ExtractTables) are coming in subsequent releases.

Go Reference CI

Install

go get github.com/hallelx2/pdftable@v0.0.1

Requires Go 1.25+ (uses the standard-library iter package for the Pages() range-over-func iterator, and pdfcpu v0.12+).

Quickstart

package main

import (
    "fmt"
    "log"

    "github.com/hallelx2/pdftable"
)

func main() {
    doc, err := pdftable.OpenFile("report.pdf")
    if err != nil {
        log.Fatal(err)
    }
    defer doc.Close()

    for n, page := range doc.Pages() {
        chars, _ := page.Chars()
        rects, _ := page.Rects()
        lines, _ := page.Lines()
        fmt.Printf("page %d: %d chars, %d rects, %d lines\n",
            n, len(chars), len(rects), len(lines))

        // Each Char carries its own bbox, font name, font size, and
        // upright flag — feed them to your own layout algorithm.
        for _, c := range chars[:min(5, len(chars))] {
            fmt.Printf("  %q at (%.1f, %.1f) - (%.1f, %.1f) %s %.1fpt\n",
                c.Text, c.X0, c.Y0, c.X1, c.Y1, c.FontName, c.FontSize)
        }
    }
}

API surface

// Constructors.
func Open(r io.Reader) (Document, error)
func OpenBytes(b []byte) (Document, error)
func OpenFile(path string) (Document, error)

// Document.
type Document interface {
    NumPages() int
    Page(n int) (Page, error)              // 1-indexed
    Pages() iter.Seq2[int, Page]           // Go 1.23+ range-over-func
    Close() error
}

// Page.
type Page interface {
    Number() int
    Width() float64
    Height() float64
    Chars() ([]Char, error)
    Lines() ([]Line, error)
    Rects() ([]Rect, error)
    Curves() ([]Curve, error)
    Objects() (Objects, error)
}

// Primitives.
type Char struct {
    Text                  string
    X0, Y0, X1, Y1        float64
    FontName              string
    FontSize              float64
    Upright               bool
    Advance               float64
}

type Line struct { X0, Y0, X1, Y1 float64; Stroke bool; Width float64 }

type Rect struct { X0, Y0, X1, Y1 float64; Stroke, Fill bool; Width float64 }

type Curve struct { Points [][2]float64; Stroke, Fill bool; Width float64 }

type Objects struct { Chars []Char; Lines []Line; Rects []Rect; Curves []Curve }

// Sentinel errors.
var (
    ErrInvalidPDF     = errors.New("pdftable: invalid PDF")
    ErrPageOutOfRange = errors.New("pdftable: page out of range")
    ErrUnsupported    = errors.New("pdftable: unsupported feature")
    ErrEncrypted      = errors.New("pdftable: encrypted PDF (decryption not yet supported)")
)

Side-by-side comparison with pdfplumber

# Python (pdfplumber)
import pdfplumber

with pdfplumber.open("report.pdf") as pdf:
    page = pdf.pages[0]
    for char in page.chars:
        print(char["text"], char["x0"], char["y0"])
// Go (pdftable)
import "github.com/hallelx2/pdftable"

doc, _ := pdftable.OpenFile("report.pdf")
defer doc.Close()
page, _ := doc.Page(1)
chars, _ := page.Chars()
for _, c := range chars {
    fmt.Println(c.Text, c.X0, c.Y0)
}

Three differences worth noting:

  1. Page indexing is 1-based, matching the PDF spec and pdfplumber's pdf.pages[0] is actually the first page (Python is 0-indexed, pdfplumber compensates). Our Page(1) is the same first page.
  2. Coordinates are in PDF user space with origin at bottom-left. pdfplumber by default reports top (origin top-left, Y growing down) on its chars; we report Y0 / Y1 in PDF native coordinates. The conversion is top = mediabox.height - Y1.
  3. No layout-analysis methods yet. extract_text, extract_tables, find_tables are coming in later releases.

Architecture

pdftable/
├── pdftable.go        // Open / OpenBytes / OpenFile entry points
├── pdf.go             // Document interface + implementation
├── page.go            // Page interface + implementation
├── char.go            // Public Char / Line / Rect / Curve / Objects
├── errors.go          // Sentinel errors
└── internal/pdf/
    ├── reader.go      // pdfcpu bridge
    ├── content.go     // Content-stream interpreter
    ├── ops.go         // Operator dispatch table
    ├── state.go       // Graphics + text state, matrix math
    ├── font.go        // Font + encoding tables + glyph-name resolution
    └── cmap.go        // ToUnicode CMap parser

The public pdftable package is small and stable. The internal/pdf package owns the interpreter — its types are not exposed because they will evolve as more PDF features are added (Type 3 fonts, vertical writing, more exotic CMaps).

Why pdfcpu and not write a PDF parser from scratch?

PDF object parsing — xref tables, indirect-object resolution, stream decompression (FlateDecode, LZWDecode, ASCII85Decode), encryption — is a large amount of mostly-uninteresting code. pdfcpu is mature, well- tested, and gives us a parsed *model.Context to work with. We layer the content-stream interpreter (which pdfcpu doesn't have) on top.

If pdfcpu's dependency footprint becomes a problem (it pulls in image codecs we don't strictly need), the blast radius of swapping it out is limited to internal/pdf/reader.go. The rest of the package is stdlib-only.

Roadmap

  • v0.0.x — content-stream primitives (this release).
  • v0.1.x — text extraction: Page.ExtractText, Page.Words, word grouping with reading-order sort.
  • v0.2.x — table finding: Page.FindTables using ruling-line + whitespace heuristics, Page.ExtractTables returning row/cell text.
  • v0.3.x — performance pass: parser benchmarking against pdfminer.six and pdfplumber on a representative document corpus.

License

MIT. See LICENSE.

Acknowledgements

This library is a direct port of the algorithms in pdfminer.six and pdfplumber. Their authors did the hard work of figuring out how to robustly recover structure from the PDF wire format; this is that work translated into Go.

Documentation

Overview

Package pdftable is a Go-native port of Python's pdfplumber. It reads a PDF document, walks the content streams, and surfaces the positioned primitives (characters, lines, rectangles, curves) that higher-level layout algorithms — text extraction, word grouping, table detection — operate on.

The library is structured in layers that mirror the pdfplumber + pdfminer.six split:

  • This package (pdftable) is the public API. It is small, stable, and contains no PDF parsing logic of its own — it just exposes Document, Page, Char, Line, Rect, Curve and constructs them from the internal package.
  • github.com/hallelx2/pdftable/internal/pdf is the content-stream interpreter. It is intentionally not public: the data shapes there will evolve as we add more PDF features, and we don't want callers depending on them.

Typical usage:

doc, err := pdftable.OpenFile("report.pdf")
if err != nil { return err }
defer doc.Close()

for n, p := range doc.Pages() {
    chars, _ := p.Chars()
    fmt.Printf("page %d: %d chars\n", n, len(chars))
}

Phase scope: this initial release exposes the primitives. The higher-level operations (ExtractText, ExtractTables, FindTables, Words) are explicit future phases — see the README for the roadmap. The Page interface is designed so those methods can be added without breaking existing callers.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidPDF is returned by Open / OpenBytes / OpenFile when the
	// input bytes can't be parsed as a PDF. The underlying pdfcpu error
	// is wrapped so callers can still inspect the details with errors.As.
	ErrInvalidPDF = errors.New("pdftable: invalid PDF")

	// ErrPageOutOfRange is returned by Document.Page when n is < 1 or
	// > NumPages(). The PDF page index is 1-based, matching pdfplumber.
	ErrPageOutOfRange = errors.New("pdftable: page out of range")

	// ErrUnsupported is returned when we hit a PDF feature this library
	// does not yet implement (e.g. an exotic CMap, an unsupported XObject
	// subtype, vertical writing). The error string names the feature.
	ErrUnsupported = errors.New("pdftable: unsupported feature")

	// ErrEncrypted is returned when the PDF is encrypted and we can't
	// decrypt it with the empty password. Full encryption support is
	// out of scope for the initial release — callers that need it can
	// pre-decrypt with pdfcpu's api.Decrypt and feed the cleaned bytes
	// to OpenBytes.
	ErrEncrypted = errors.New("pdftable: encrypted PDF (decryption not yet supported)")
)

Sentinel errors returned by the public API. Callers can match these with errors.Is(); functions that surface a parser-level problem from pdfcpu or the content-stream interpreter wrap the underlying error so the cause is preserved.

We keep this set small on purpose. The PDF spec has hundreds of failure modes — most of them collapse into "the bytes do not look like a PDF", "you asked for a page that doesn't exist", or "we don't implement this feature yet". Anything more specific belongs in the wrapped error string, not as a new sentinel.

Functions

This section is empty.

Types

type Char

type Char struct {
	// Text is the Unicode payload of this glyph (one or more runes).
	// Empty string means the font's encoding and ToUnicode CMap both
	// failed to map the glyph; the bbox still describes where the
	// glyph was drawn so downstream layout heuristics can use the
	// positioning even when we can't read the text.
	Text string

	// Bounding box in PDF user space, with x0 <= x1 and y0 <= y1.
	// Y0 is the descender baseline of the glyph; Y1 is the top of the
	// ascender. The bbox is the typographic cell, not the ink — it
	// matches what pdfplumber reports.
	X0, Y0, X1, Y1 float64

	// FontName is the /BaseFont (or /Name) value from the font dict,
	// e.g. "Helvetica" or "ABCDEF+TimesNewRoman-Bold". It's surfaced
	// verbatim — callers that want to detect bold weight should match
	// on substrings ("bold", "-bd").
	FontName string

	// FontSize is the size the glyph was rendered at, in PDF user space
	// units (i.e. after applying the text matrix and CTM). For a glyph
	// drawn with `12 Tf` and identity CTM this is 12.0; if the CTM
	// scales by 2x it's 24.0.
	FontSize float64

	// Upright is true when the glyph is drawn in normal reading
	// orientation (a*d > 0 and b*c <= 0 in the combined text matrix).
	// Rotated and mirrored glyphs report false. pdfplumber uses the
	// same predicate to skip vertical-stamp text during table finding.
	Upright bool

	// Advance is the horizontal advance width the text matrix moved by
	// after drawing this glyph (in user-space units). It already
	// includes character spacing, word spacing for the space glyph,
	// and the font's per-glyph width — i.e. the actual ink-to-ink
	// distance to the next glyph.
	Advance float64
}

Char is a single positioned glyph on a page. Adjacent Chars do NOT share state — each carries its own font, size, and absolute bbox so downstream code (word grouping, table finding, text extraction) can reason about each glyph in isolation.

Text is the Unicode string this glyph represents. For most fonts a glyph maps to a single code point ("A"), but Adobe ligature glyph names ("fi", "ffi") and ToUnicode maps with multi-character bfchar entries can produce multi-rune strings — we never split them.

func (Char) Height

func (c Char) Height() float64

Height returns y1 - y0.

func (Char) Width

func (c Char) Width() float64

Width returns x1 - x0.

type Curve

type Curve struct {
	// Points are the vertices of the path in order. For curve segments,
	// only the on-path endpoints are emitted (control points are
	// dropped) — this matches pdfplumber's "pts" field, which is also
	// just the endpoints. Callers that need precise curve geometry
	// should request that extension; we don't expect tables to use
	// curves so dropping control points is fine for the initial scope.
	Points [][2]float64

	Stroke bool
	Fill   bool
	Width  float64
}

Curve is a path that contains at least one curve segment (`c`, `v`, or `y`) or any path that doesn't reduce to a single Rect or a series of straight Lines. Points are the path vertices in order, including intermediate control points; callers that need actual Bezier shapes can reconstruct them from the operator stream — for now we keep the simpler flattened representation that's enough for "did the page have decorative curves on it" detection.

type Document

type Document interface {
	// NumPages returns the page count. Always >= 1 for a valid PDF.
	NumPages() int

	// Page returns the n'th page (1-indexed). Returns
	// ErrPageOutOfRange if n is < 1 or > NumPages().
	Page(n int) (Page, error)

	// Pages returns a range iterator over (n, Page) pairs for use
	// with Go 1.23+ range-over-func syntax:
	//
	//   for n, p := range doc.Pages() {
	//       // ...
	//   }
	//
	// The iterator yields pages in 1-based order. It does NOT clone
	// the underlying Document — Close() on the doc still tears down
	// the iterator's pages.
	Pages() iter.Seq2[int, Page]

	// Close releases any resources held by the underlying parser.
	// For pdfcpu-backed documents this is currently a no-op (pdfcpu
	// holds everything in memory), but callers should still defer
	// Close() so that future backends (mmap, file handles) work.
	Close() error
}

Document represents one open PDF file. The interface (not a struct) keeps the API symmetric with Page and lets us swap implementations later — e.g. a Document that streams pages lazily from a remote blob — without breaking callers.

All accessors are safe to call concurrently. The underlying pdfcpu *model.Context is treated as read-only after the document is opened.

func Open

func Open(r io.Reader) (Document, error)

Open reads an entire io.Reader into memory and parses it as a PDF. pdfcpu's API requires an io.ReadSeeker; we buffer the input so the caller doesn't have to.

Use OpenBytes if you already have the file content as a []byte (avoids an extra copy), or OpenFile if you have a path.

func OpenBytes

func OpenBytes(b []byte) (Document, error)

OpenBytes parses an in-memory PDF.

The bytes are NOT copied — pdfcpu reads from them in-place and may retain references after this function returns. Callers must not mutate the slice for as long as the returned Document is in use.

func OpenFile

func OpenFile(path string) (Document, error)

OpenFile opens a PDF at the given filesystem path.

type Line

type Line struct {
	X0, Y0, X1, Y1 float64

	// Stroke is true if the path was stroked (S, s, B, b, B*, b*).
	// Lines emitted from non-stroked paths are dropped before the
	// caller sees them — but the field is preserved for symmetry
	// with Rect/Curve so layout code can branch on it uniformly.
	Stroke bool

	// Width is the stroke line width in user-space units at the time
	// the line was emitted. For ruling lines this is typically 0.5–1.0.
	Width float64
}

Line is a single straight-line segment emitted by an `S` (stroke) operator on a path that contained an `l` segment. Each `l` becomes one Line; a rectangle drawn with `re` does NOT decompose into four Lines (it becomes a single Rect) — that's how pdfplumber tells the two apart, and we keep the same distinction so downstream code can pick the right collection for table-finding.

type Objects

type Objects struct {
	Chars  []Char
	Lines  []Line
	Rects  []Rect
	Curves []Curve
}

Objects is the bundle of all primitive page objects, returned by Page.Objects(). It's a convenience for callers that want everything in one shot rather than four separate Page method calls — the per- type accessors (Chars/Lines/Rects/Curves) are independent and safe to call concurrently, but they each redo the page content-stream walk, so Objects() is the cheaper choice when you need it all.

type Page

type Page interface {
	// Number returns the page number (1-based).
	Number() int

	// Width and Height return the page's mediabox dimensions in PDF
	// points (1/72 inch), already adjusted for the page's /Rotate
	// entry — so a portrait letter-sized page rotated 90 degrees
	// reports Width=792 Height=612 (landscape), matching what a
	// PDF viewer would display.
	Width() float64
	Height() float64

	// Chars walks the page and returns every positioned glyph. The
	// order is content-stream order — i.e. the order the producer
	// drew them, NOT visual reading order. Downstream layout code
	// (extract_text, find_tables) sorts the chars by position.
	Chars() ([]Char, error)

	// Lines returns every straight-line segment drawn on the page.
	// Each `l` segment in the content stream becomes one Line.
	// Rectangles drawn via `re` are NOT decomposed into four Lines;
	// they're reported through Rects() instead.
	Lines() ([]Line, error)

	// Rects returns every rectangle drawn via the `re` operator.
	// Both stroked and filled rectangles are returned; the Stroke
	// and Fill flags say which.
	Rects() ([]Rect, error)

	// Curves returns every Bezier or composite path that isn't a
	// pure line-segment chain or a single rect.
	Curves() ([]Curve, error)

	// Objects returns Chars + Lines + Rects + Curves in a single
	// walk. Use this when you need all four — it's strictly
	// cheaper than calling each accessor separately because the
	// content stream is parsed exactly once.
	Objects() (Objects, error)
}

Page is one page of a PDF document. The interface (not a struct) is intentional: it lets us swap implementations later (e.g. for a streaming PDF parser) without breaking callers, and it makes the API surface easy to mock in tests.

Every accessor (Chars, Lines, Rects, Curves, Objects) walks the page content stream from scratch. We do NOT cache between calls because:

  1. Callers that need ALL the objects call Objects() once.
  2. Callers that need just the chars (say, for text extraction) don't pay for the path-painting machinery they aren't using.
  3. Caching means deciding when to invalidate, which is moot because a Page is immutable from the caller's perspective.

Pages are 1-indexed, matching pdfplumber. Number() returns the 1-based index so callers can format error messages without re-tracking which Page they were given.

type Rect

type Rect struct {
	X0, Y0, X1, Y1 float64

	// Stroke and Fill mirror the painting operator that closed the
	// path: S/s set Stroke; f/F/f* set Fill; B/b/B*/b* set both.
	// Either or both can be true — but not neither (paths with `n`
	// produce nothing).
	Stroke bool
	Fill   bool

	// Width is the stroke line width at the time the rectangle was
	// painted, in user-space units. Zero when Stroke is false.
	Width float64
}

Rect is a rectangle path emitted by an `re` operator (a single PDF instruction that draws a closed box). pdfplumber tracks these separately from generic four-segment paths because table grids, borders, and shaded cells are nearly always drawn this way — keeping the distinction makes table detection much more reliable.

Directories

Path Synopsis
internal
pdf
Package pdf is the internal content-stream interpreter for pdftable.
Package pdf is the internal content-stream interpreter for pdftable.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL