folio

package module
v0.0.0-...-3c5fbf9 Latest Latest
Warning

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

Go to latest
Published: Aug 28, 2026 License: MIT Imports: 9 Imported by: 0

README

folio

Rasterize PDF pages to PNG/JPEG images from pure Go, by driving the PDFium C library directly through puregono cgo. It builds with CGO_ENABLED=0 and cross-compiles cleanly.

This is a self-contained library with a small, stable public API, designed to be embedded as a package in larger Go projects or used standalone via the CLI.

folio/
├── doc.go              package documentation
├── errors.go           sentinel errors
├── pdfium.go           PDFium C bindings (purego) — all C-API detail lives here
├── bitmap.go           BGRA buffer -> image.NRGBA (pure Go, unit-tested)
├── renderer.go         public API: Options, Renderer, Document
├── *_test.go           unit + integration tests
├── scripts/            get-pdfium.sh — fetch the PDFium shared library
└── cmd/folio/          thin CLI wrapper

Why purego + pdfium

  • Single static binary. No cgo, no CGO toolchain, no .so linking at build time. The pdfium shared library is loaded at runtime.
  • Uses a standard prebuilt libpdfium.{dylib,so,dll} (see Obtaining the PDFium library) — no native dependency to build or link.
  • In-process. No subprocess, no IPC; rendering returns an image.Image.
  • BSD 3-Clause (PDFium) — permissive, and compatible with this project's MIT license.

The pattern is proven in the wild: go-fitz (PyMuPDF's Go port) uses purego in its nocgo build mode.

Building

cd folio
CGO_ENABLED=0 go build -o folio ./cmd/folio

The pdfium shared library is located at runtime (see Library location).

Obtaining the PDFium library

folio does not bundle PDFium — it loads a shared library at runtime. Fetch a prebuilt one with the helper script:

scripts/get-pdfium.sh                 # latest release, current platform
scripts/get-pdfium.sh chromium/8009   # pin a specific release tag

The script downloads the right binary for your platform from bblanchon/pdfium-binaries and places it in pdfium/ together with its BSD 3-Clause license files. It honours GOOS/GOARCH when Go is installed, so it also works for cross-compilation targets. Supported: darwin / linux / windows × amd64 / arm64 / 386.

Prefer to manage the library yourself? Any of these work:

  • --lib /path/to/libpdfium.dylib (CLI flag), or
  • PDFIUM_LIB=/path/to/libpdfium.dylib (environment variable), or
  • drop libpdfium.{dylib,so,dll} in the current directory, ./pdfium/, or next to the executable.

PDFium is BSD 3-Clause. Keep the LICENSE / licenses/ files the script writes alongside the library when you redistribute it.

CLI

folio [flags] <input.pdf> [more.pdf ...]
flag default description
-o . output directory
--dpi 150 rasterization DPI (scale = DPI/72)
--format png png or jpeg
--quality 90 JPEG quality (1–100)
--lib auto explicit path to libpdfium
--flat off flat naming <stem>-NNN.<ext>
--pages all 1-based range, e.g. 1-5,8
--list off print page count and exit

Output layout (default): <output>/<stem>/page-NNN.png. With --flat: <output>/<stem>-NNN.png.

# Render a document at 300 DPI
folio --dpi 300 -o out document.pdf

# First 3 pages, flat layout, JPEG
folio --pages 1-3 --flat --format jpeg -o out document.pdf

Library

Add it as a dependency, then import and use it:

go get github.com/fabiomarini/folio
import "github.com/fabiomarini/folio"

r := folio.New(folio.Options{
    DPI:     150,                    // 0 -> default (150)
    LibPath: "libpdfium.dylib",      // "" -> auto-detect
})

doc, err := r.OpenDocument("file.pdf")
if err != nil {
    // ErrNoLibrary if the lib can't be found; other errors if the PDF is bad
}
defer doc.Close()

for i := 0; i < doc.PageCount(); i++ {
    img, err := doc.RenderPage(i)    // image.Image (concretely *image.NRGBA)
    if err != nil {
        // ...
    }
    // encode img to PNG/JPEG, feed a VLM, etc.
}

The API follows the io.Reader / database.Conn resource pattern: New makes a reusable Renderer (the pdfium library is loaded once, lazily, on first use); OpenDocument opens a Document that you Close when done.

Detecting scanned vs text pages

Every page can be classified as digital (has a usable text layer) or scanned (no extractable text, e.g. an image-only page) in-process, without OCR:

doc, _ := r.OpenDocument("invoice.pdf")
defer doc.Close()

info, _ := doc.PageTextInfo(0)   // per page
if info.HasMeaningfulText {
    // digital page — route to the fast text path
} else {
    // scanned page — route to VLM/OCR
}

summary, _ := doc.TextSummary()  // whole document: AllText / AllScanned / Mixed

The signal is the PDFium text layer (FPDFText_CountChars). A non-zero count means Chromium's text engine decoded real glyphs (including ToUnicode/CID/CJK fonts). HasMeaningfulText requires ≥ 10 chars so a page carrying only a watermark, page number, or stray glyph is not mistaken for a digital page; route those to VLM. This is detection only — folio does not extract the text.

Concurrency
  • A Renderer is safe to share across goroutines.
  • A Document is not safe for concurrent use — render one page at a time from a single goroutine.
  • Multiple Documents can be processed in parallel (each on its own goroutine), matching PDFium's one-document-per-thread model.

Library location

Options.LibPath is used if set. Otherwise the library is searched (in order):

  1. the PDFIUM_LIB environment variable,
  2. ./libpdfium.{dylib,so,so.0} and ./pdfium/…,
  3. the directory of the running executable (and its pdfium/ subdir).

Set --lib or PDFIUM_LIB for an explicit path.

Rendering model

Each page is rendered as follows:

  1. Load the document and page.
  2. Read the page size in points (CropBox, falling back to MediaBox).
  3. target = round(points * DPI/72); scale = target / points (so the page fills the bitmap exactly — using the raw DPI/72 leaves a sub-pixel drift).
  4. Create a BGRA bitmap, clear it to opaque white.
  5. FPDF_RenderPageBitmapWithMatrix with a scale matrix and a full-bounds clip rect in device coords, FPDF_ANNOT flag.
  6. Convert the BGRA buffer to image.NRGBA (swap R↔B).
PDFium gotchas (verified against this lib)

These are non-obvious and cost real debugging time — keep them in mind:

  • Clip rect must be the full bitmap (0,0,w,h), not NULL. A NULL clip renders nothing (the page comes out blank).
  • FPDFBitmap_Destroy takes the handle by value, not a pointer (void FPDFBitmap_Destroy(FPDF_BITMAP)). Passing &handle intermittently crashes (SIGTRAP) because the pointer is treated as a garbage handle.
  • FPDFPage_GetCropBox/GetMediaBox out-params are left, bottom, right, top — not left, bottom, width, height. Compute width = right-left, height = top-bottom (they coincide only when the box origin is (0,0)).
  • FPDF_RenderPageBitmapWithMatrix returns void in this build (not BOOL).
  • The document-close symbol is FPDF_CloseDocument (older naming), not FPDF_DestroyDocument.

Validation

Rendered output was compared pixel-for-pixel against a reference PDFium-based rasterizer (using the same libpdfium) at 300 DPI, across a set of multi-page sample documents. Every page matched to within ≤0.63/255 mean per-channel difference with 99%+ near-identical pixels; one document matched exactly (0.00/255, 100%).

The residual difference is font anti-aliasing (sub-pixel glyph rendering) and is immaterial for downstream VLM/OCR use.

Tests

go test ./...
  • bitmap_test.go — pure-Go unit tests for the BGRA→NRGBA conversion (channel swap, stride/padding). No native library required.
  • renderer_test.go — integration tests that build a minimal PDF in-memory, render it, and assert on size and pixel color. Skipped automatically if the pdfium library isn't found (set PDFIUM_LIB to enable).
  • cmd/folio/main_test.go — page-range parser tests.

Licensing

  • folio (this code): MIT — see LICENSE.
  • PDFium (libpdfium.{dylib,so,dll}): BSD 3-Clause. It is not part of this module — you provide the shared library at runtime (see Library location). Because BSD 3-Clause is permissive it is fully compatible with MIT, but when you distribute the library alongside folio you must preserve PDFium's copyright notice and license text.

Documentation

Overview

Package folio rasterizes PDF pages to images by driving the PDFium C library directly from Go via purego — no cgo, so it builds with CGO_ENABLED=0 and cross-compiles cleanly.

It is a self-contained prototype designed to be embedded later as a library or internal package. The public surface is intentionally small:

r := folio.New(folio.Options{LibPath: "libpdfium.dylib"})
doc, err := r.OpenDocument("file.pdf")
if err != nil { ... }
defer doc.Close()
for i := 0; i < doc.PageCount(); i++ {
    img, err := doc.RenderPage(i) // image.Image (RGBA)
    // encode img to PNG/JPEG ...
}

Rendering: each page is drawn into a white-cleared BGRA bitmap at the requested DPI (scale = DPI/72) with annotations, then converted to an RGBA image.

The PDFium shared library (libpdfium.{dylib,so,dll}) is loaded at runtime; see Options.LibPath and findLibrary for how it is located.

Index

Constants

View Source
const DefaultDPI = 150

DefaultDPI is used when Options.DPI is zero. 150 DPI puts an A4 page at ~1240×1754 px, right around the "large" VLM resolution budget.

Variables

View Source
var (
	// ErrNoLibrary is returned when the pdfium shared library cannot be
	// located (Options.LibPath empty and no candidate found on disk).
	ErrNoLibrary = errors.New("folio: pdfium shared library not found")
)

Sentinel errors returned by the package. Use errors.Is to test for them.

Functions

This section is empty.

Types

type Document

type Document struct {
	// contains filtered or unexported fields
}

Document is an open PDF ready for page rendering. Callers must Close it. A Document is not safe for concurrent use.

func (*Document) Close

func (d *Document) Close() error

Close releases the document's native resources. It is idempotent.

func (*Document) PageCount

func (d *Document) PageCount() int

PageCount returns the number of pages in the document.

func (*Document) PageHasText

func (d *Document) PageHasText(i int) (bool, error)

PageHasText reports whether page i has any text layer at all. Convenience wrapper over PageTextInfo.

func (*Document) PageTextInfo

func (d *Document) PageTextInfo(i int) (PageTextInfo, error)

PageTextInfo inspects page i (0-based) and reports its text-layer presence. A page with no text layer is NOT an error — it returns a zero CharCount. Errors are reserved for real failures: out-of-range page index, or pdfium failing to load the page/textpage (e.g. a corrupt page).

func (*Document) RenderPage

func (d *Document) RenderPage(i int) (image.Image, error)

RenderPage renders the 0-based page i to an RGBA image.

func (*Document) TextSummary

func (d *Document) TextSummary() (TextSummary, error)

TextSummary classifies every page and rolls the results up. It returns an error if any page fails to load (the caller can fall back to a VLM path). Equivalent to building TextSummary from repeated PageTextInfo, provided as a convenience for doc-extract's document-level routing decision.

type Options

type Options struct {
	// DPI is the rasterization resolution (default DefaultDPI). scale = DPI/72.
	DPI float64
	// LibPath is the path to the pdfium shared library. If empty, common
	// locations are searched (see findLibrary).
	LibPath string
}

Options configures a Renderer. Zero values are replaced with defaults.

type PageTextInfo

type PageTextInfo struct {
	// CharCount is the number of characters PDFium finds in the page's text
	// layer (FPDFText_CountChars). 0 means the page has no extractable text
	// (e.g. a scanned page backed only by an image). A small non-zero value
	// may be a watermark, page number, or stray glyph — check HasMeaningfulText.
	CharCount int

	// HasTextLayer is true when CharCount > 0 (PDFium found any text).
	HasTextLayer bool

	// HasMeaningfulText is true when CharCount >= minTextChars. This is the
	// routing signal doc-extract uses to decide the fast text path.
	HasMeaningfulText bool
}

PageTextInfo reports whether a page has a usable text layer.

type Renderer

type Renderer struct {
	// contains filtered or unexported fields
}

Renderer renders PDF pages to images. A Renderer is safe to share; the pdfium library is loaded once, lazily, on first use.

func New

func New(opts Options) *Renderer

New creates a Renderer, applying defaults to zero-valued options.

func (*Renderer) OpenDocument

func (r *Renderer) OpenDocument(path string) (*Document, error)

OpenDocument opens the PDF at path for rendering. It loads the pdfium library on first use. The caller must Close the returned Document.

type TextSummary

type TextSummary struct {
	TotalPages   int
	TextPages    int // pages with HasMeaningfulText
	ScannedPages int // pages without HasMeaningfulText
	AllText      bool
	AllScanned   bool
	Mixed        bool
}

TextSummary is a whole-document rollup of per-page text presence.

Directories

Path Synopsis
cmd
folio command
Command folio rasterizes PDF documents to per-page PNG (or JPEG) images using the folio library (PDFium via purego).
Command folio rasterizes PDF documents to per-page PNG (or JPEG) images using the folio library (PDFium via purego).

Jump to

Keyboard shortcuts

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