Added
ReadingOrder::Structure— order a Tagged PDF by a pre-order traversal of its/StructTreeRoot(ISO 32000-1 §14.8.2.3), fixing table and complex-layout order where a geometric XY-cut guesses; falls back toColumnAwarewhen the structure tree is absent or not trustworthy (#877).- Mapping provenance —
extract_spansreports which ISO 32000-1 §9.10.2 tier (ToUnicode / encoding / heuristic) produced each span's text, surfaced across all bindings (#893). extract_spans_filtered_with_reading_order— reading-order extraction combined with optional-content (OCG) and ink-coverage filtering (#883).- Type 0 (sampled) and Type 3 (stitching) tint-transform evaluation in the renderer for Separation/DeviceN colours (#849).
- CJK vertical-writing running header/footer detection — recognizes folios in the left/right side bands on tategaki pages (#889).
resolve_named_destinationis now public (#881); aremove_artifactsmarkdown-conversion example (#845).
Fixed
Rendering
- DeviceCMYK rendered via the naive
1−(C+K)additive clamp — now converts through measured SWOP-style process inks across every composite / vector / text / image path, so0 0 0 1 kblack renders#231F20and process cyan renders#00ADEFinstead of over-saturated additive values (#861). /Rotate 270rendered MIRRORED rather than rotated; negative/Rotatevalues were mishandled (%instead ofrem_euclid); real/Rotateand indirect Separation alternates are now resolved correctly (#862, #848, #854).
Text extraction
- Form-XObject (
Do) text went missing when stray operands preceded the name — a dropped/malformedcmleft dangling numeric operands, soDoreadoperands[0](a stray number) and resolved to an empty name; it now reads the operand immediately preceding the operator per ISO 32000-1 §7.8.2 (#914). - Pages were lost when
/Count-based page counting returned 0 — now recovered by walking the page tree (#909). - Inline images (
BI/ID/EI) were parsed but never decoded —/Subtypeis now supplied and the Table 92 abbreviated keys/values expanded (#863). - Unique
/PlacedPDFbodies are kept instead of suppressed (#896); spans entirely outside the MediaBox are dropped fromextract_spans_with_reading_order(#894); the top-level fill colour set beforeBTis preserved (scn/cs/rgno longer dropped) (#857). - Running-header/footer detection now requires position-consistency before removing repeated text as chrome, and recognizes non-Latin folio digits (#888, #887).
Images
/Decode [1 0]is honoured for 1-bit CCITT/DeviceGray images (#856); the jpeg-decoder Adobe inversion is undone for CMYK JPEGs (#855).
Recovery / parsing
- A truncated file that lost its own Catalog is recovered by rebuilding one from the surviving pages (#890); a file padded after
%%EOFis no longer rejected outright as 0 pages (#875).
Fonts
- The referenced
/Encoding /Differencesis folded into the font identity hash, and subset choice inextract_embedded_fontsis made deterministic (#878, #853).
Writer
- Coloured text on a registered embedded font rendered black —
FluentPageBuilder::inline_color(and anyTextStyle.color) was silently dropped for embedded fonts —PdfWriter::add_elementrouted embedded-font text through the deliberately colour-agnosticadd_embedded_text(the HTML painter sets and resets the fill colour around its own calls) without ever emitting the element's fill colour, so norgoperator reached the content stream and the glyphs painted in whatever fill colour was last set (default black). The base-14 path (add_text_content) always emitsrgfromstyle.color; the embedded path now matches it by emittingfill_colorbefore the glyph run. No restore is needed — every text element sets its own colour, mirroring the base-14 branch's "always set explicitly" contract.
Performance
extract_words/extract_textspent most of their time re-deriving per-glyph facts that cannot change (#882) — text extraction asked each font for its weight and slant once per glyph, inside the show-text loop. Both answers are name-derived: the weight lowercases the base font name (allocating) and runs up to a dozen substring searches, and the slant lowercases it again for two more — so a 13,234-page document repeated ~14 substring scans and 2 allocations 48.7M times for a value fixed at font-load. A sampling profile putstr::containsand friends at ~38% of all samples. The Standard-14 width lookup had the same shape, re-stripping the subset prefix and re-scanning the 15-name table per glyph purely to choose a width table. Both are now resolved once per font and memoized, mirroring the existing byte-width-table memo. Alongside:postprocess_spansrescanned every glyph on the page per span to find its baseline (O(spans × chars) — now a bracketed y-sorted index); the page's characters were re-parsed for span post-processing and re-copied on every access (now cached and shared); the word and line paths materialized every glyph twice; article threads — a document-wide parse that walks the whole page tree — were re-parsed per page; the glyph dedup rebuilt the whole array to drop a handful; and the word-merge loop re-derived RTL-ness from an accumulating buffer, costing O(k²) characters per merge chain (the exact blow-up the backtrack guard above it exists to prevent). Measured on the reporter's PDFs:extract_words156.9s → ~121s andextract_spans88.7s → ~54s on a 13,234-page document;extract_text6.03s → 3.94s on a 2,124-page one. Output is byte-identical — verified across a 419-PDF corpus forextract_spans/extract_chars/extract_words/extract_text_lines(including geometry and per-glyph x-offsets) and for text/markdown/HTML.
Changed / Dependencies
- Text shaping migrated from
rustybuzztoharfrust(#899);fax0.2 → 0.3 (#873); thettf-parsermigration decision for RUSTSEC-2026-0192 is documented (#900). office_oxidebumped to 0.1.8 (#904, #932). A combined dependency roundup (crates + CI actions + Go), plus routine crate and CI-action bumps (#931, #907, #898, #872, #869, #870, #871, #864, #865, #866, #867, #868, #874, #891).- Test/CI stability: the flaky
structured_warningsround-trip test is fixed (#912); misc test guards and binding-format fixes (#846, #897, #880).
Contributors
The majority of this release was contributed by @ajbufort — rendering (/Rotate handling and Separation/DeviceN tint transforms #848, #849, #854, #862; DeviceCMYK process inks #861), image decode (#855, #856), text extraction (#857, #863, #894, #896, #909), fonts (#853, #878), recovery/parsing (#875, #890), and reading-order / span filtering (#877, #881, #883). Additional fixes from @norbusan (#911) and @ultrasaurus (#845, #846). Thank you!
Issues reported by:
- @ankursri494 — #882 (
extract_words/extract_textfar slower thanextract_spanson large PDFs) - @tobocop2 — #876 (signal to callers when a page's text cannot be extracted)
- @ultrasaurus — #879 (
remove_footersremoved real content on IRS forms) - @norbusan — #913 (text inside a Form XObject missing from extraction)
Installation
Rust (crates.io)
cargo add pdf_oxidePython (PyPI)
pip install pdf_oxideJavaScript/WASM (npm)
npm install pdf-oxide-wasmCLI (Homebrew)
brew install yfedoseev/tap/pdf-oxideCLI (Scoop — Windows)
scoop bucket add pdf-oxide https://github.com/yfedoseev/scoop-pdf-oxide
scoop install pdf-oxideCLI (Shell installer)
curl -fsSL https://raw.githubusercontent.com/yfedoseev/pdf_oxide/main/install.sh | shCLI (cargo-binstall)
cargo binstall pdf_oxide_cliMCP Server (for AI assistants)
cargo install pdf_oxide_mcpPre-built Binaries
Download archives for Linux, macOS, and Windows from the assets below. Each archive includes both pdf-oxide (CLI) and pdf-oxide-mcp (MCP server).
Platform Support
| Platform | Architecture | Archive |
|---|---|---|
| Linux | x86_64 (glibc) | pdf_oxide-linux-x86_64-*.tar.gz
|
| Linux | x86_64 (musl) | pdf_oxide-linux-x86_64-musl-*.tar.gz
|
| Linux | ARM64 | pdf_oxide-linux-aarch64-*.tar.gz
|
| macOS | x86_64 (Intel) | pdf_oxide-macos-x86_64-*.tar.gz
|
| macOS | ARM64 (Apple Silicon) | pdf_oxide-macos-aarch64-*.tar.gz
|
| Windows | x86_64 | pdf_oxide-windows-x86_64-*.zip
|
Changelog
See CHANGELOG.md for full details.