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
| Argument | Description |
|---|---|
PDF | Path to the PDF file |
PAGES | Page or range to extract, e.g. 3 or 2-5. Omit for all pages. |
Options
| Flag | Description |
|---|---|
--size WxH | Override grid dimensions, e.g. 200x80. Either component may be omitted (200x, x80). Omit entirely to autodetect. |
--collision first|last|walk | How to handle two text items that snap to the same cell (default: walk) |
--trim none|bounds|collapse | Whitespace 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 cellslast— later item overwriteswalk— 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 renderedbounds— crop to the bounding box of all contentcollapse— 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:
build_font_map— load ToUnicode CMap and glyph width table per fontextract_items— walk content stream, track CTM, decode text runs intoTextItems with positions and glyph-metric widthsextract_segments— walk content stream, track CTM, collect H/V line segments intoLineSegmentscompute_cell_size— derive cell dimensions from glyph metrics with overflow caprender_page— snap segments then text onto achargrid; apply collision modetrim_grid— apply trim mode and serialize to strings