vesey.techpdftxtractrepoREADME.md

pdftxtract

Renders PDF pages as monospaced text grids. Each page becomes a 2D character array where text is snapped to the nearest cell and table borders are drawn with ASCII box characters.

Usage

pdftxtract <PDF> [PAGES] [OPTIONS]

Arguments

ArgumentDescription
PDFPath to the PDF file
PAGESPage or range to extract, e.g. 3 or 2-5. Omit for all pages.

Options

FlagDescription
--size WxHOverride grid dimensions, e.g. 200x80. Either component may be omitted (200x, x80). Omit entirely to autodetect.
--collision first|last|walkHow to handle two text items that snap to the same cell (default: walk)
--trim none|bounds|collapseWhitespace trimming mode (default: collapse)

As a library

The crate also ships a library target. Add it as a dependency:

[dependencies]
pdftxtract = { git = "https://git.int.vesey.tech/will/pdftxtract.git" }

Then call pdf::run, which returns one RenderedPage per page instead of printing:

use pdftxtract::pdf::{self, Collision, GridConfig, Trim};

fn main() -> Result<(), pdf::PdfError> {
    let pages = pdf::run(
        "document.pdf",
        &[1, 2],                // 1-based page numbers
        GridConfig {
            cols: None,         // None autodetects from glyph metrics
            rows: None,
            collision: Collision::Walk,
            trim: Trim::Collapse,
        },
    )?;

    for page in &pages {
        println!("--- page {} ({}x{}) ---", page.page_num, page.cols, page.rows);
        println!("{}", page.text()); // or iterate `page.lines`
    }
    Ok(())
}

To render a PDF you already hold in memory rather than one on disk, use pdf::run_backend. It accepts anything implementing the re-exported Backend trait — Vec<u8>, &[u8], Box<[u8]>, Arc<[u8]>, a memory-mapped file, etc. (anything that derefs to [u8]):

use pdftxtract::pdf::{self, Collision, GridConfig, Trim};

fn render(bytes: Vec<u8>) -> Result<(), pdf::PdfError> {
    let pages = pdf::run_backend(
        bytes,                  // or &bytes[..] to render from a borrowed slice
        &[1, 2],
        GridConfig { cols: None, rows: None, collision: Collision::Walk, trim: Trim::Collapse },
    )?;
    for page in &pages {
        println!("{}", page.text());
    }
    Ok(())
}

PDF parsing seeks via the cross-reference table and so needs random access to the whole document; there is no streaming Read-based entry point. To render from a Reader, read it fully into a Vec<u8> first and pass that to run_backend. (pdf::run is just a thin wrapper that reads the file off disk and calls run_backend.)

Rendering emits progress on the tracing log targets; install a subscriber if you want to see it.

A note on build profiles

Cargo applies profile settings only from the root crate being built, so the opt-level override in this repo's Cargo.toml does not carry over to downstream consumers. If you depend on pdftxtract and build in debug, PDF decompression (pdf/flate2/miniz_oxide) runs unoptimized and is slow. For fast debug builds, add this to your root manifest:

[profile.dev.package."*"]
opt-level = 2

How it works

Grid sizing

Each page is sized independently. Cell width is derived from the weighted-average glyph advance width of the page's body text, read from the PDF font metrics. Cell height is twice the cell width (2:1 aspect ratio). The cell width is then capped downward if any text item would overflow the right edge of the grid.

When --size is provided, the page dimensions are divided by the given column/row counts to fix the cell size directly.

Text placement

Text items are extracted from the PDF content stream with their positions in page-point coordinates. The current transformation matrix (CTM) is tracked so that cm operators are correctly applied. Each character is snapped to the nearest grid cell; the row is chosen from the vertical center of the glyph's em square.

Collision modes control what happens when two items map to the same cell:

  • first — first item wins; later items skip occupied cells
  • last — later item overwrites
  • walk — later item advances past occupied cells to the next empty one

Table line rendering

Horizontal and vertical line segments are extracted from the PDF path stream (stroke and fill+stroke operations). Axis-aligned segments are snapped to the grid and drawn with -, |, and + characters. Pure fills are discarded (they are colored regions, not borders).

Line characters interact with whitespace collapsing: - counts as blank for column collapse, | counts as blank for row collapse, and + anchors both axes and is never collapsed away.

Whitespace trimming

After rendering, output can be trimmed:

  • none — full grid as rendered
  • bounds — crop to the bounding box of all content
  • collapse — bounds crop plus collapse internal runs of blank rows/columns to one

Architecture

src/
  main.rs   CLI argument parsing (clap)
  pdf.rs    All extraction and rendering logic

Key pipeline in pdf.rs:

  1. build_font_map — load ToUnicode CMap and glyph width table per font
  2. extract_items — walk content stream, track CTM, decode text runs into TextItems with positions and glyph-metric widths
  3. extract_segments — walk content stream, track CTM, collect H/V line segments into LineSegments
  4. compute_cell_size — derive cell dimensions from glyph metrics with overflow cap
  5. render_page — snap segments then text onto a char grid; apply collision mode
  6. trim_grid — apply trim mode and serialize to strings